diff --git a/.dockerignore b/.dockerignore index 6c32d9cb..46846562 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,9 +9,7 @@ db/ LICENSE README.md Dockerfile -Dockerfile.dev Makefile docker-compose.yml -docker-compose.dev.yml CHANGELOG.md diff --git a/.env.example b/.env.example index 311a47a3..d8cfaf77 100644 --- a/.env.example +++ b/.env.example @@ -2,14 +2,14 @@ THOTH_GRAPHQL_API=http://localhost:8000 # THOTH_EXPORT_API is used at compile time, must be a public facing URL THOTH_EXPORT_API=http://localhost:8181 -# Authentication cookie domain -THOTH_DOMAIN=localhost # Full postgres URL DATABASE_URL=postgres://thoth:thoth@localhost/thoth # Full redis URL REDIS_URL=redis://localhost:6379 -# Authentication cookie secret key -SECRET_KEY=an_up_to_255_bytes_random_key +# AWS credentials for file uploads +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION= # Logging level RUST_LOG=info diff --git a/.github/workflows/build_test_and_check.yml b/.github/workflows/build_test_and_check.yml index 4aa9cbf3..9315d7e4 100644 --- a/.github/workflows/build_test_and_check.yml +++ b/.github/workflows/build_test_and_check.yml @@ -25,8 +25,8 @@ on: env: CARGO_TERM_COLOR: always - THOTH_GRAPHQL_API: https://api.thoth.pub THOTH_EXPORT_API: https://export.thoth.pub + TEST_DATABASE_URL: postgres://thoth:thoth@localhost:5432/thoth_test TEST_REDIS_URL: redis://localhost:6379 jobs: @@ -48,6 +48,19 @@ jobs: test: runs-on: ubuntu-latest services: + postgres: + image: postgres:17 + env: + POSTGRES_DB: thoth_test + POSTGRES_USER: thoth + POSTGRES_PASSWORD: thoth + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 redis: image: redis:alpine options: >- diff --git a/.github/workflows/docker_build_and_push_to_dockerhub.yml b/.github/workflows/docker_build_and_push_to_dockerhub.yml index 2899e54a..d6b12a5d 100644 --- a/.github/workflows/docker_build_and_push_to_dockerhub.yml +++ b/.github/workflows/docker_build_and_push_to_dockerhub.yml @@ -41,27 +41,6 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | - THOTH_GRAPHQL_API=https://api.test.thoth.pub THOTH_EXPORT_API=https://export.test.thoth.pub - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} - - build_dev_docker_image: - runs-on: ubuntu-latest - steps: - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Build - id: docker_build - uses: docker/build-push-action@v5 - with: - push: false - tags: thoth-pub/thoth:latest - file: Dockerfile.dev - build-args: | - THOTH_GRAPHQL_API=https://api.thoth.pub - THOTH_EXPORT_API=https://export.thoth.pub - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/docker_build_and_push_to_dockerhub_release.yml b/.github/workflows/docker_build_and_push_to_dockerhub_release.yml index ad848fd0..beedc5f2 100644 --- a/.github/workflows/docker_build_and_push_to_dockerhub_release.yml +++ b/.github/workflows/docker_build_and_push_to_dockerhub_release.yml @@ -43,7 +43,6 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | - THOTH_GRAPHQL_API=https://api.thoth.pub THOTH_EXPORT_API=https://export.thoth.pub - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.gitignore b/.gitignore index c4534ef2..05c6b644 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .env db/ target/ +machinekey/ +coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 851fa3dd..94b153aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + - [736](https://github.com/thoth-pub/thoth/pull/736) - Remove `Funding.jurisdiction` and `Language.mainLanguage`, and add `Issue.issueNumber` + - [732](https://github.com/thoth-pub/thoth/pull/732) - Add default fields for common metadata values to `Imprint` + - [697](https://github.com/thoth-pub/thoth/pull/697) - Migrated GraphQL API authentication to OIDC via Zitadel. Internal JWT handling has been replaced with introspection of Zitadel-issued tokens. Authorisation is now based entirely on token claims, removing the need for the internal `account` and `publisher_account` tables. + - [697](https://github.com/thoth-pub/thoth/pull/697) - Improved and standardised backend model test coverage. + - [689](https://github.com/thoth-pub/thoth/issues/689) - Move `Work.fullTitle`, `Work.title` and `Work.subtitle` into a dedicated `Title` table, supporting multilingual and rich text fields + - [689](https://github.com/thoth-pub/thoth/issues/689) - Move `Work.shortAbstract` and `Work.longAbstract` into a dedicated `Abstract` table with `abstractType`, supporting multilingual and rich text fields + - [689](https://github.com/thoth-pub/thoth/issues/689) - Move `Contribution.biography` into a dedicated `Biography` table, supporting multilingual and rich text fields + - [689](https://github.com/thoth-pub/thoth/issues/689) - Store all rich text fields internally as JATS XML, supporting conversion to/from HTML, Markdown, and plain text + - [689](https://github.com/thoth-pub/thoth/issues/689) - Mark existing GraphQL fields as deprecated and return only the canonical version + - [701](https://github.com/thoth-pub/thoth/issues/701) - Add accessibility-related metadata to Thoth data model and outputs + - [682](https://github.com/thoth-pub/thoth/issues/682) - Improve ONIX 3.0 and 3.1 outputs based on feedback from EDItEUR + +### Added + - [711](https://github.com/thoth-pub/thoth/pull/711) - Allow filtering work queries by publication date + - [715](https://github.com/thoth-pub/thoth/pull/715) - Support reordering items which have ordinals + - [713](https://github.com/thoth-pub/thoth/issues/713) - Add a secure and scalable file upload architecture for books and chapters. + - Added new work-linked domain entities: `additional_resource`, `award`, `endorsement`, `book_review`, `work_featured_video` + +### Fixed + - [712](https://github.com/thoth-pub/thoth/pull/712) - Make `updated_at_with_relations` propagation less deadlock-prone + +### Removed + - [710](https://github.com/thoth-pub/thoth/pull/710) - Deprecated thoth-app + + ## [[0.13.16]](https://github.com/thoth-pub/thoth/releases/tag/v0.13.16) - 2026-03-06 ### Changed - [#731](https://github.com/thoth-pub/thoth/pull/731) - Ignore hyphens when filtering publications on ISBN ## [[0.13.15]](https://github.com/thoth-pub/thoth/releases/tag/v0.13.15) - 2025-12-03 ### Changed - - [#717](https://github.com/thoth-pub/thoth/pull/717) - Update Thema codes to v1.6 +- [#717](https://github.com/thoth-pub/thoth/pull/717) - Update Thema codes to v1.6 ## [[0.13.14]](https://github.com/thoth-pub/thoth/releases/tag/v0.13.14) - 2025-10-14 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 9692ee16..6dcef3b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "bytes", "futures-core", "futures-sink", @@ -27,7 +27,7 @@ checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" dependencies = [ "actix-utils", "actix-web", - "derive_more 2.0.1", + "derive_more 2.1.1", "futures-util", "log", "once_cell", @@ -36,25 +36,25 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.10.0" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa882656b67966045e4152c634051e70346939fced7117d5f0b52146a7c74c9" +checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e" dependencies = [ "actix-codec", "actix-rt", "actix-service", "actix-utils", "base64 0.22.1", - "bitflags 2.9.0", + "bitflags 2.11.0", "brotli", "bytes", "bytestring", - "derive_more 2.0.1", + "derive_more 2.1.1", "encoding_rs", "flate2", - "foldhash", + "foldhash 0.1.5", "futures-core", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "httparse", "httpdate", @@ -64,7 +64,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.9.0", + "rand 0.9.2", "sha1", "smallvec", "tokio", @@ -73,22 +73,6 @@ dependencies = [ "zstd", ] -[[package]] -name = "actix-identity" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b8ddc6f6a8b19c4016aaa13519968da9969bc3bc1c1c883cdb0f25dd6c8cf7" -dependencies = [ - "actix-service", - "actix-session", - "actix-utils", - "actix-web", - "derive_more 1.0.0", - "futures-core", - "serde", - "tracing", -] - [[package]] name = "actix-macros" version = "0.2.4" @@ -96,14 +80,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "actix-router" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" dependencies = [ "bytestring", "cfg-if", @@ -116,9 +100,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" dependencies = [ "futures-core", "tokio", @@ -126,9 +110,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.5.1" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6398974fd4284f4768af07965701efbbb5fdc0616bff20cade1bb14b77675e24" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" dependencies = [ "actix-rt", "actix-service", @@ -136,7 +120,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2", + "socket2 0.5.10", "tokio", "tracing", ] @@ -151,23 +135,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "actix-session" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efe6976a74f34f1b6d07a6c05aadc0ed0359304a7781c367fa5b4029418db08f" -dependencies = [ - "actix-service", - "actix-utils", - "actix-web", - "anyhow", - "derive_more 1.0.0", - "rand 0.8.5", - "serde", - "serde_json", - "tracing", -] - [[package]] name = "actix-utils" version = "3.0.1" @@ -180,9 +147,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.10.2" +version = "4.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2e3b15b3dc6c6ed996e4032389e9849d4ab002b1e92fbfe85b5f307d1479b4d" +checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" dependencies = [ "actix-codec", "actix-http", @@ -197,9 +164,9 @@ dependencies = [ "bytestring", "cfg-if", "cookie", - "derive_more 2.0.1", + "derive_more 2.1.1", "encoding_rs", - "foldhash", + "foldhash 0.1.5", "futures-core", "futures-util", "impl-more", @@ -215,7 +182,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2", + "socket2 0.6.2", "time", "tracing", "url", @@ -230,64 +197,33 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.100", -] - -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", + "syn 2.0.117", ] [[package]] name = "adler2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" - -[[package]] -name = "aead" -version = "0.5.2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "aes" -version = "0.8.4" +name = "ahash" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", ] [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -308,10 +244,10 @@ dependencies = [ ] [[package]] -name = "android-tzdata" -version = "0.1.1" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" @@ -324,9 +260,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -339,62 +275,53 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "once_cell", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.97" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" - -[[package]] -name = "anymap" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33954243bd79057c2de7338850b85983a44588021f8a5fee574a8888c6de4344" - -[[package]] -name = "anymap2" -version = "0.13.0" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] [[package]] name = "argon2rs" @@ -421,15 +348,37 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] @@ -440,214 +389,734 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "auto_enums" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c170965892137a3a9aeb000b4524aa3cc022a310e709d848b6e1cdce4ab4781" +checksum = "65398a2893f41bce5c9259f6e1a4f03fbae40637c1bdc755b4f387f48c613b03" dependencies = [ "derive_utils", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "backtrace" -version = "0.3.74" +name = "aws-config" +version = "1.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "11493b0bad143270fb8ad284a096dd529ba91924c5409adeac856cc1bf047dbc" dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", ] [[package]] -name = "base64" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bincode" -version = "1.3.3" +name = "aws-credential-types" +version = "1.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" dependencies = [ - "serde", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", ] [[package]] -name = "bitflags" -version = "1.3.2" +name = "aws-lc-rs" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +dependencies = [ + "aws-lc-sys", + "zeroize", +] [[package]] -name = "bitflags" -version = "2.9.0" +name = "aws-lc-sys" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] [[package]] -name = "blake2-rfc" -version = "0.2.18" +name = "aws-runtime" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d530bdd2d52966a6d03b7a964add7ae1a288d25214066fd4b600f0f796400" +checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17" dependencies = [ - "arrayvec 0.4.12", - "constant_time_eq", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", ] [[package]] -name = "block-buffer" -version = "0.10.4" +name = "aws-sdk-cloudfront" +version = "1.113.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "c75ec35f8c6e83e537f8fd1047235dd17ddc568f87892bfa38aaa46f70f1bad0" dependencies = [ - "generic-array", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", ] [[package]] -name = "boolinator" -version = "2.4.0" +name = "aws-sdk-s3" +version = "1.125.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9" +checksum = "223f5c95650d9557925a91f4c2db3def189e8f659452134a29e5cd2d37d708ed" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] [[package]] -name = "brotli" -version = "7.0.0" +name = "aws-sdk-sso" +version = "1.96.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +checksum = "f64a6eded248c6b453966e915d32aeddb48ea63ad17932682774eb026fbef5b1" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", ] [[package]] -name = "brotli-decompressor" -version = "4.0.2" +name = "aws-sdk-ssooidc" +version = "1.98.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fa05ad7d803d413eb8380983b092cbbaf9a85f151b871360e7b00cd7060b37" +checksum = "db96d720d3c622fcbe08bae1c4b04a72ce6257d8b0584cb5418da00ae20a344f" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", ] [[package]] -name = "bumpalo" -version = "3.17.0" +name = "aws-sdk-sts" +version = "1.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "fafbdda43b93f57f699c5dfe8328db590b967b8a820a13ccdd6687355dfcc7ca" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] [[package]] -name = "byteorder" -version = "1.5.0" +name = "aws-sigv4" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "p256 0.11.1", + "percent-encoding", + "ring", + "sha2", + "subtle", + "time", + "tracing", + "zeroize", +] [[package]] -name = "bytes" -version = "1.10.1" +name = "aws-smithy-async" +version = "1.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] [[package]] -name = "bytestring" -version = "1.4.0" +name = "aws-smithy-checksums" +version = "0.64.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e465647ae23b2823b0753f50decb2d5a86d2bb2cac04788fafd1f80e45378e5f" +checksum = "6750f3dd509b0694a4377f0293ed2f9630d710b1cebe281fa8bac8f099f88bc6" dependencies = [ + "aws-smithy-http", + "aws-smithy-types", "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", ] [[package]] -name = "cc" -version = "1.2.16" +name = "aws-smithy-eventstream" +version = "0.60.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" dependencies = [ - "jobserver", - "libc", - "shlex", + "aws-smithy-types", + "bytes", + "crc32fast", ] [[package]] -name = "cc_license" -version = "0.1.0" +name = "aws-smithy-http" +version = "0.63.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e604b8591d0dbbd946023bcf91b4171f944765c1eb6c73003d3928d9e8c64c" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" dependencies = [ - "regex", + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", ] [[package]] -name = "cfg-if" -version = "1.0.0" +name = "aws-smithy-http-client" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower 0.5.3", + "tracing", +] [[package]] -name = "cfg-match" -version = "0.2.1" +name = "aws-smithy-json" +version = "0.62.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8100e46ff92eb85bf6dc2930c73f2a4f7176393c84a9446b3d501e1b354e7b34" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +dependencies = [ + "aws-smithy-types", +] [[package]] -name = "chrono" -version = "0.4.40" +name = "aws-smithy-observability" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", + "aws-smithy-runtime-api", ] [[package]] -name = "cipher" -version = "0.4.4" +name = "aws-smithy-query" +version = "0.60.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" dependencies = [ - "crypto-common", - "inout", + "aws-smithy-types", + "urlencoding", ] [[package]] -name = "clap" -version = "4.5.32" +name = "aws-smithy-runtime" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83" +checksum = "028999056d2d2fd58a697232f9eec4a643cf73a71cf327690a7edad1d2af2110" dependencies = [ - "clap_builder", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ab3c9c29791ba4ba02b780a3049e21ec63dabda09268b175272c3733a79e6" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2b1117b3b2bbe166d11199b540ceed0d0f7676e36e7b962b5a437a9971eac75" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8323699dd9b3c8d5b3c13051ae9cdef58fd179957c882f8374dd8725962d9" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper 1.0.2", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-compat" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8d4d2746f89841e49230dd26917df1876050f95abafafbe34f47cb534b88d7" +dependencies = [ + "byteorder", +] + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake2-rfc" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d530bdd2d52966a6d03b7a964add7ae1a288d25214066fd4b600f0f796400" +dependencies = [ + "arrayvec 0.4.12", + "constant_time_eq", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cc_license" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e604b8591d0dbbd946023bcf91b4171f944765c1eb6c73003d3928d9e8c64c" +dependencies = [ + "regex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.32" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -657,9 +1126,18 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] [[package]] name = "codegen" @@ -672,9 +1150,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "combine" @@ -704,14 +1182,10 @@ dependencies = [ ] [[package]] -name = "console_error_panic_hook" -version = "0.1.7" +name = "const-oid" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "constant_time_eq" @@ -719,20 +1193,22 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ - "aes-gcm", - "base64 0.20.0", - "hkdf", - "hmac", "percent-encoding", - "rand 0.8.5", - "sha2", - "subtle", "time", "version_check", ] @@ -747,6 +1223,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -762,15 +1248,66 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest", + "rustversion", + "spin 0.10.0", +] + [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -778,45 +1315,91 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] +[[package]] +name = "cssparser" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "csv" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" dependencies = [ "csv-core", "itoa", "ryu", - "serde", + "serde_core", ] [[package]] name = "csv-core" -version = "0.1.12" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" dependencies = [ "memchr", ] [[package]] -name = "ctr" -version = "0.9.2" +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "cipher", + "proc-macro2", + "quote", + "syn 2.0.117", ] +[[package]] +name = "custom_error" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8a51dd197fa6ba5b4dc98a990a43cc13693c23eb0089ebb0fcc1f04152bca6" + [[package]] name = "darling" -version = "0.20.10" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ "darling_core", "darling_macro", @@ -824,36 +1407,37 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.10" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "deadpool" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ed5957ff93768adf7a65ab167a17835c3d2c3c50d084fe305174c112f468e2f" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" dependencies = [ "deadpool-runtime", + "lazy_static", "num_cpus", "tokio", ] @@ -878,65 +1462,79 @@ dependencies = [ ] [[package]] -name = "deranged" -version = "0.4.0" +name = "der" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" dependencies = [ - "powerfmt", + "const-oid", + "zeroize", ] [[package]] -name = "derive_more" -version = "1.0.0" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "derive_more-impl 1.0.0", + "const-oid", + "pem-rfc7468", + "zeroize", ] [[package]] -name = "derive_more" -version = "2.0.1" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "derive_more-impl 2.0.1", + "powerfmt", + "serde_core", ] [[package]] -name = "derive_more-impl" -version = "1.0.0" +name = "derive_more" +version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", - "unicode-xid", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", - "syn 2.0.100", + "rustc_version", + "syn 2.0.117", "unicode-xid", ] [[package]] name = "derive_utils" -version = "0.15.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccfae181bab5ab6c5478b2ccb69e4c68a02f8c3ec72f6616bfec9dbc599d2ee0" +checksum = "362f47930db19fe7735f527e6595e4900316b893ebf6d48ad3d31be928d57dd6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] @@ -954,14 +1552,15 @@ dependencies = [ [[package]] name = "diesel" -version = "2.2.8" +version = "2.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "470eb10efc8646313634c99bb1593f402a6434cbd86e266770c6e39219adb86a" +checksum = "d9b6c2fc184a6fb6ebcf5f9a5e3bbfa84d8fd268cdfcce4ed508979a6259494d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "byteorder", "chrono", "diesel_derives", + "downcast-rs", "itoa", "pq-sys", "r2d2", @@ -978,7 +1577,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] @@ -989,27 +1588,27 @@ checksum = "d5adf688c584fe33726ce0e2898f608a2a92578ac94a4a92fcecf73214fe0716" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "diesel_derives" -version = "2.2.4" +version = "2.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93958254b70bea63b4187ff73d10180599d9d8d177071b7f91e6da4e0c0ad55" +checksum = "47618bf0fac06bb670c036e48404c26a865e6a71af4114dfd97dfe89936e404e" dependencies = [ "diesel_table_macro_syntax", "dsl_auto_type", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "diesel_migrations" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a73ce704bad4231f001bff3314d91dce4aba0770cee8b233991859abc15c1f6" +checksum = "745fd255645f0f1135f9ec55c7b00e0882192af9683ab4731e4bba3da82b8f9c" dependencies = [ "diesel", "migrations_internals", @@ -1018,60 +1617,191 @@ dependencies = [ [[package]] name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling", + "either", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der 0.6.1", + "elliptic-curve 0.12.3", + "rfc6979 0.3.1", + "signature 1.6.4", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "syn 2.0.100", + "pkcs8 0.10.2", + "signature 2.2.0", ] [[package]] -name = "digest" -version = "0.10.7" +name = "ed25519-dalek" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "block-buffer", - "crypto-common", + "curve25519-dalek", + "ed25519", + "serde", + "sha2", "subtle", + "zeroize", ] [[package]] -name = "displaydoc" -version = "0.2.5" +name = "ego-tree" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] +checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642" [[package]] -name = "dotenv" -version = "0.15.0" +name = "either" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] -name = "dsl_auto_type" -version = "0.1.3" +name = "elliptic-curve" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" dependencies = [ - "darling", - "either", - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.100", + "base16ct 0.1.1", + "crypto-bigint 0.4.9", + "der 0.6.1", + "digest", + "ff 0.12.1", + "generic-array", + "group 0.12.1", + "pkcs8 0.9.0", + "rand_core 0.6.4", + "sec1 0.3.0", + "subtle", + "zeroize", ] [[package]] -name = "either" -version = "1.15.0" +name = "elliptic-curve" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest", + "ff 0.13.1", + "generic-array", + "group 0.13.0", + "hkdf", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "zeroize", +] [[package]] name = "encode_unicode" @@ -1090,9 +1820,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.3" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -1100,9 +1830,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.7" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3716d7a920fb4fac5d84e9d4bce8ceb321e9414b4409da61b07b75c1e3d0697" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", @@ -1119,12 +1849,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1133,11 +1863,49 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" -version = "1.1.0" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1155,6 +1923,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1172,18 +1946,44 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[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" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1196,9 +1996,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1206,15 +2006,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1223,38 +2023,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1264,242 +2064,74 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] [[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "gloo" +name = "fxhash" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ce6f2dfa9f57f15b848efa2aade5e1850dc72986b87a2b0752d44ca08f4967" -dependencies = [ - "gloo-console-timer", - "gloo-events", - "gloo-file 0.1.0", - "gloo-timers 0.2.6", -] - -[[package]] -name = "gloo" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23947965eee55e3e97a5cd142dd4c10631cc349b48cecca0ed230fd296f568cd" -dependencies = [ - "gloo-console", - "gloo-dialogs", - "gloo-events", - "gloo-file 0.2.3", - "gloo-render", - "gloo-storage 0.2.2", - "gloo-timers 0.2.6", - "gloo-utils 0.1.7", -] - -[[package]] -name = "gloo-console" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f" -dependencies = [ - "gloo-utils 0.1.7", - "js-sys", - "serde", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-console-timer" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b48675544b29ac03402c6dffc31a912f716e38d19f7e74b78b7e900ec3c941ea" -dependencies = [ - "web-sys", -] - -[[package]] -name = "gloo-dialogs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-events" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-file" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f9fecfe46b5dc3cc46f58e98ba580cc714f2c93860796d002eb3527a465ef49" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" dependencies = [ - "gloo-events", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-file" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7" -dependencies = [ - "futures-channel", - "gloo-events", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-render" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-storage" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480" -dependencies = [ - "gloo-utils 0.1.7", - "js-sys", - "serde", - "serde_json", - "thiserror 1.0.69", - "wasm-bindgen", - "web-sys", + "byteorder", ] [[package]] -name = "gloo-storage" -version = "0.3.0" +name = "generic-array" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ - "gloo-utils 0.2.0", - "js-sys", - "serde", - "serde_json", - "thiserror 1.0.69", - "wasm-bindgen", - "web-sys", + "typenum", + "version_check", + "zeroize", ] [[package]] -name = "gloo-timers" -version = "0.2.6" +name = "getopts" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", + "unicode-width", ] [[package]] -name = "gloo-timers" -version = "0.3.0" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ + "cfg-if", "js-sys", + "libc", + "wasi", "wasm-bindgen", ] [[package]] -name = "gloo-utils" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" -dependencies = [ - "js-sys", - "serde", - "serde_json", - "wasm-bindgen", - "web-sys", +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", ] [[package]] -name = "gloo-utils" -version = "0.2.0" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "js-sys", - "serde", - "serde_json", - "wasm-bindgen", - "web-sys", + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", ] [[package]] @@ -1560,11 +2192,33 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff 0.12.1", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ "bytes", "fnv", @@ -1572,7 +2226,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.8.0", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -1581,17 +2235,17 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.8" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.3.1", - "indexmap 2.8.0", + "http 1.4.0", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -1606,9 +2260,23 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -1624,9 +2292,15 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.3.9" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" @@ -1646,6 +2320,20 @@ dependencies = [ "digest", ] +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "http" version = "0.2.12" @@ -1659,15 +2347,25 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1675,7 +2373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.3.1", + "http 1.4.0", ] [[package]] @@ -1686,8 +2384,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.3.1", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1705,19 +2403,46 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.6.0" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" dependencies = [ "bytes", "futures-channel", + "futures-core", "futures-util", - "h2 0.4.8", - "http 1.3.1", - "http-body", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -1725,18 +2450,46 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.5" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", - "http 1.3.1", - "hyper", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", "hyper-util", - "rustls", + "rustls 0.23.37", + "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.8.1", + "hyper-util", + "pin-project-lite", + "tokio", "tower-service", ] @@ -1748,7 +2501,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "native-tls", "tokio", @@ -1758,33 +2511,40 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.10" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http 1.3.1", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.2", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", "windows-core", ] @@ -1800,21 +2560,22 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", + "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -1823,98 +2584,66 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ - "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", + "icu_locale_core", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] [[package]] -name = "icu_provider_macros" -version = "1.5.0" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ident_case" @@ -1924,9 +2653,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1935,9 +2664,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -1957,26 +2686,19 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] name = "indexmap" -version = "2.8.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.16.1", "serde", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", + "serde_core", ] [[package]] @@ -1993,15 +2715,25 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "isbn" @@ -2023,50 +2755,69 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.4" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d699bc6dfc879fb1bf9bdff0d4c56f0884fc6f0d0eb0fba397a6d00cd9a6b85e" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", "log", "portable-atomic", "portable-atomic-util", - "serde", + "serde_core", ] [[package]] name = "jiff-static" -version = "0.2.4" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d16e75759ee0aa64c57a56acbf43916987b20c77373cb7e808979e02b93c9f9" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "jobserver" -version = "0.1.32" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -2089,9 +2840,9 @@ dependencies = [ [[package]] name = "juniper" -version = "0.16.1" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943306315b1a7a03d27af9dfb0c288d9f4da8830c17df4bceb7d50a47da0982c" +checksum = "3478f4a8a2a1c7679944f5f4f08c60d6440f9970da481d79c8f8931201424403" dependencies = [ "async-trait", "auto_enums", @@ -2099,7 +2850,7 @@ dependencies = [ "fnv", "futures", "graphql-parser", - "indexmap 2.8.0", + "indexmap 2.13.0", "juniper_codegen", "serde", "smartstring", @@ -2116,7 +2867,7 @@ checksum = "760dbe46660494d469023d661e8d268f413b2cb68c999975dcc237407096a693" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", "url", ] @@ -2131,24 +2882,39 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.171" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "linux-raw-sys" -version = "0.9.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "local-channel" @@ -2169,19 +2935,33 @@ checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.26" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "marc" @@ -2192,17 +2972,47 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "migrations_internals" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd01039851e82f8799046eabbb354056283fb265c8ec0996af940f4e85a380ff" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" dependencies = [ "serde", "toml", @@ -2210,9 +3020,9 @@ dependencies = [ [[package]] name = "migrations_macros" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb161cc72176cb37aa47f1fc520d3ef02263d67d661f44f05d05a079e1237fd" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" dependencies = [ "migrations_internals", "proc-macro2", @@ -2237,30 +3047,37 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.8.5" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi", + "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -2273,6 +3090,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 = "nodrop" version = "0.1.14" @@ -2283,17 +3106,33 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" name = "num-bigint" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ + "lazy_static", + "libm", "num-integer", + "num-iter", "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", ] [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -2304,6 +3143,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2311,38 +3161,50 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] name = "num_cpus" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ "hermit-abi", "libc", ] [[package]] -name = "object" -version = "0.36.7" +name = "oauth2" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" dependencies = [ - "memchr", + "base64 0.13.1", + "chrono", + "getrandom 0.2.17", + "http 0.2.12", + "rand 0.8.5", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", ] [[package]] name = "once_cell" -version = "1.21.1" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] -name = "opaque-debug" -version = "0.3.1" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openapiv3-paper" @@ -2350,18 +3212,50 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f21aa89c0b45d63c9a4976b0de5dcf4e041defc2cd9720820f0012f0046a0bc" dependencies = [ - "indexmap 2.8.0", + "indexmap 2.13.0", + "serde", + "serde_json", +] + +[[package]] +name = "openidconnect" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47e80a9cfae4462dd29c41e987edd228971d6565553fbc14b8a11e666d91590" +dependencies = [ + "base64 0.13.1", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http 0.2.12", + "itertools 0.10.5", + "log", + "oauth2", + "p256 0.13.2", + "p384", + "rand 0.8.5", + "rsa", "serde", + "serde-value", + "serde_derive", "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", ] [[package]] name = "openssl" -version = "0.10.72" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -2378,20 +3272,20 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.107" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -2399,6 +3293,56 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa 0.14.8", + "elliptic-curve 0.12.3", + "sha2", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder", + "sha2", +] + [[package]] name = "paperclip" version = "0.9.5" @@ -2406,7 +3350,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa5b33308ca3f5902ccef8aa51f72dd71d6ee9f1c3cd04ac2e77eec33fe1da4f" dependencies = [ "anyhow", - "itertools", + "itertools 0.10.5", "once_cell", "openapiv3-paper", "paperclip-actix", @@ -2489,12 +3433,12 @@ dependencies = [ [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", - "parking_lot_core 0.9.10", + "parking_lot_core 0.9.12", ] [[package]] @@ -2513,32 +3457,97 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.10", + "redox_syscall 0.5.18", "smallvec", - "windows-targets 0.52.6", + "windows-link", +] + +[[package]] +name = "pbjson" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e6349fa080353f4a597daffd05cb81572a9c031a6d4fff7e504947496fcc68" +dependencies = [ + "base64 0.21.7", + "serde", +] + +[[package]] +name = "pbjson-build" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eea3058763d6e656105d1403cb04e0a41b7bbac6362d413e7c33be0c32279c9" +dependencies = [ + "heck 0.5.0", + "itertools 0.13.0", + "prost", + "prost-types", +] + +[[package]] +name = "pbjson-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e54e5e7bfb1652f95bc361d76f3c780d8e526b134b85417e774166ee941f0887" +dependencies = [ + "bytes", + "chrono", + "pbjson", + "pbjson-build", + "prost", + "prost-build", + "serde", ] [[package]] name = "pem" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64 0.22.1", - "serde", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", ] [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.13.0", +] + +[[package]] +name = "phf" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] [[package]] name = "phf" @@ -2547,7 +3556,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", ] [[package]] @@ -2556,7 +3595,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.5", ] @@ -2566,11 +3605,20 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", ] [[package]] @@ -2579,14 +3627,34 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher", + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -2595,38 +3663,66 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "pkg-config" -version = "0.3.32" +name = "pkcs1" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] [[package]] -name = "polyval" -version = "0.6.2" +name = "pkcs8" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", + "der 0.6.1", + "spki 0.6.0", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "portable-atomic" -version = "1.11.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2644,36 +3740,38 @@ dependencies = [ [[package]] name = "pq-sys" -version = "0.7.0" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b51d65ebe1cb1f40641b15abae017fed35ccdda46e3dab1ff8768f625a3222" +checksum = "574ddd6a267294433f140b02a726b0640c43cf7c6f717084684aaa3b285aba61" dependencies = [ "libc", + "pkg-config", "vcpkg", ] [[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "precomputed-hash" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "proc-macro-error-attr", "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", + "syn 2.0.117", ] [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "primeorder" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "proc-macro2", - "quote", - "version_check", + "elliptic-curve 0.13.8", ] [[package]] @@ -2682,45 +3780,131 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "pulldown-cmark" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.100", + "bitflags 2.11.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", ] [[package]] -name = "proc-macro2" -version = "1.0.94" +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quick-xml" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" dependencies = [ - "unicode-ident", + "memchr", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +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 = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "r2d2" @@ -2729,7 +3913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" dependencies = [ "log", - "parking_lot 0.12.3", + "parking_lot 0.12.5", "scheduled-thread-pool", ] @@ -2746,13 +3930,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", - "zerocopy", + "rand_core 0.9.5", ] [[package]] @@ -2772,7 +3955,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2781,23 +3964,23 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.4", ] [[package]] name = "redis" -version = "0.29.1" +version = "0.29.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034fb926579ff49d3fe58d288d5dcb580bf11e9bccd33224b45adebf0fd0c23" +checksum = "1bc42f3a12fd4408ce64d8efef67048a924e543bd35c6591c0447fda9054695f" dependencies = [ "arc-swap", "bytes", @@ -2808,7 +3991,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "ryu", - "socket2", + "socket2 0.5.10", "tokio", "tokio-util", "url", @@ -2825,18 +4008,38 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.10" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ - "bitflags 2.9.0", + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2846,9 +4049,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2857,70 +4060,107 @@ dependencies = [ [[package]] name = "regex-lite" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.12.14" +version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e327e510263980e231de548a33e63d34962d29ae61b467389a1a09627a254" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2 0.4.8", - "http 1.3.1", - "http-body", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-tls", "hyper-util", - "ipnet", "js-sys", "log", "mime", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", - "system-configuration", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tower", + "tower 0.5.3", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-registry", ] [[package]] name = "reqwest-middleware" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e8975513bd9a7a43aad01030e79b3498e05db14e9d945df6483e8cf9b8c4c4" +checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" dependencies = [ "anyhow", "async-trait", - "http 1.3.1", - "reqwest", + "http 1.4.0", + "reqwest 0.12.28", "serde", "thiserror 1.0.69", "tower-service", @@ -2935,11 +4175,11 @@ dependencies = [ "anyhow", "async-trait", "futures", - "getrandom 0.2.15", - "http 1.3.1", - "hyper", + "getrandom 0.2.17", + "http 1.4.0", + "hyper 1.8.1", "parking_lot 0.11.2", - "reqwest", + "reqwest 0.12.28", "reqwest-middleware", "retry-policies", "thiserror 1.0.69", @@ -2957,6 +4197,27 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2965,18 +4226,12 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] -[[package]] -name = "route-recognizer" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" - [[package]] name = "roxmltree" version = "0.14.1" @@ -2987,37 +4242,96 @@ dependencies = [ ] [[package]] -name = "rustc-demangle" -version = "0.1.24" +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustix" -version = "1.0.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7178faa4b75a30e269c71e61c353ce2748cf3d76f0c44c393f4e60abf49b825" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", ] [[package]] name = "rustls" -version = "0.23.25" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.9", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -3029,16 +4343,30 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.11.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] [[package]] name = "rustls-webpki" -version = "0.103.0" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa4eeac2588ffff23e9d7a7e9b3f971c5fb5b7ebc9452745e0c232c64f83b2f" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3046,23 +4374,23 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3071,14 +4399,32 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" dependencies = [ - "parking_lot 0.12.3", + "parking_lot 0.12.5", ] [[package]] -name = "scoped-tls-hkt" -version = "0.1.5" +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9603871ffe5df3ac39cb624790c296dbd47a400d202f56bf3e414045099524d" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] [[package]] name = "scoped_threadpool" @@ -3092,14 +4438,68 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90460b31bfe1fc07be8262e42c665ad97118d4585869de9345a84d501a9eaf0" +dependencies = [ + "ahash", + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "once_cell", + "selectors", + "tendril", +] + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct 0.1.1", + "der 0.6.1", + "generic-array", + "pkcs8 0.9.0", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.9.0", - "core-foundation", + "bitflags 2.11.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3107,73 +4507,121 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", ] +[[package]] +name = "selectors" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" +dependencies = [ + "bitflags 2.11.0", + "cssparser", + "derive_more 0.99.20", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.10.1", + "phf_codegen 0.10.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] [[package]] -name = "serde-wasm-bindgen" -version = "0.3.1" +name = "serde-value" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "618365e8e586c22123d692b72a7d791d5ee697817b65a218cdf12a98870af0f7" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" dependencies = [ - "fnv", - "js-sys", + "ordered-float", "serde", - "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] -name = "serde_spanned" -version = "0.6.8" +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3186,19 +4634,59 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.8.0", + "indexmap 2.13.0", "itoa", "ryu", "serde", "unsafe-libyaml", ] +[[package]] +name = "servo_arc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3212,9 +4700,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -3223,9 +4711,9 @@ dependencies = [ [[package]] name = "shell-words" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" @@ -3235,45 +4723,75 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "simple_asn1" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", ] [[package]] name = "siphasher" -version = "1.0.1" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smartstring" @@ -3288,19 +4806,61 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.8" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der 0.6.1", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -3308,6 +4868,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 0.12.5", + "phf_shared 0.11.3", + "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 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3322,11 +4907,11 @@ checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" [[package]] name = "strum" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.1", + "strum_macros 0.27.2", ] [[package]] @@ -3344,15 +4929,14 @@ dependencies = [ [[package]] name = "strum_macros" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "rustversion", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] @@ -3374,15 +4958,21 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.100" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3394,24 +4984,45 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" dependencies = [ - "bitflags 2.9.0", - "core-foundation", - "system-configuration-sys", + "core-foundation-sys", + "libc", ] [[package]] @@ -3426,15 +5037,26 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.19.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488960f40a3fd53d72c2a29a58722561dee8afdd175bd88e3db4677d7b2ba600" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.2", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.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]] @@ -3448,11 +5070,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.18", ] [[package]] @@ -3463,34 +5085,35 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "thoth" version = "0.13.16" dependencies = [ + "base64 0.22.1", "clap", "dialoguer", "dotenv", "lazy_static", "thoth-api", "thoth-api-server", - "thoth-app-server", "thoth-errors", "thoth-export-server", "tokio", + "zitadel", ] [[package]] @@ -3499,6 +5122,11 @@ version = "0.13.16" dependencies = [ "actix-web", "argon2rs", + "aws-config", + "aws-credential-types", + "aws-sdk-cloudfront", + "aws-sdk-s3", + "base64 0.22.1", "chrono", "deadpool-redis", "diesel", @@ -3506,19 +5134,25 @@ dependencies = [ "diesel-derive-newtype", "diesel_migrations", "dotenv", + "fs2", "futures", + "hex", "isbn", "jsonwebtoken", "juniper", "lazy_static", - "rand 0.9.0", + "log", + "pulldown-cmark", + "rand 0.9.2", "regex", + "scraper", "serde", "serde_json", - "strum 0.27.1", + "strum 0.27.2", "thoth-errors", "tokio", "uuid", + "zitadel", ] [[package]] @@ -3527,51 +5161,15 @@ version = "0.13.16" dependencies = [ "actix-cors", "actix-http", - "actix-identity", - "actix-session", "actix-web", + "base64 0.22.1", "env_logger", "futures-util", "log", "serde", - "serde_json", - "thoth-api", - "thoth-errors", -] - -[[package]] -name = "thoth-app" -version = "0.13.16" -dependencies = [ - "chrono", - "dotenv", - "gloo-storage 0.3.0", - "gloo-timers 0.3.0", - "reqwest", - "semver", - "serde", - "serde_json", - "thiserror 2.0.12", "thoth-api", "thoth-errors", - "uuid", - "wasm-bindgen", - "wasm-logger", - "web-sys", - "yew 0.19.3", - "yew-agent", - "yew-router", - "yewtil", -] - -[[package]] -name = "thoth-app-server" -version = "0.13.16" -dependencies = [ - "actix-cors", - "actix-web", - "dotenv", - "env_logger", + "zitadel", ] [[package]] @@ -3580,7 +5178,7 @@ version = "0.13.16" dependencies = [ "chrono", "graphql_client", - "reqwest", + "reqwest 0.12.28", "reqwest-middleware", "reqwest-retry", "serde", @@ -3602,15 +5200,15 @@ dependencies = [ "diesel", "juniper", "marc", - "phf", - "reqwest", + "phf 0.11.3", + "reqwest 0.12.28", "reqwest-middleware", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", + "tonic", "uuid", "xml-rs", - "yewtil", ] [[package]] @@ -3629,6 +5227,7 @@ dependencies = [ "log", "marc", "paperclip", + "quick-xml", "regex", "serde", "serde_json", @@ -3641,30 +5240,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.40" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d9c75b47bdff86fa3334a3db91356b8d7d86a9b839dab7d0bdc5c3d3a077618" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.21" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29aa485584182073ed57fd5004aa09c371f021325014694e432313345865fd04" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -3672,9 +5271,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -3682,31 +5281,30 @@ dependencies = [ [[package]] name = "tokio" -version = "1.44.2" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "backtrace", "bytes", "libc", "mio", - "parking_lot 0.12.3", + "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] @@ -3721,19 +5319,40 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.37", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ - "rustls", + "futures-core", + "pin-project-lite", "tokio", ] [[package]] name = "tokio-util" -version = "0.7.14" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -3744,53 +5363,132 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.20" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "serde", + "serde_core", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_parser", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ - "serde", + "serde_core", ] [[package]] -name = "toml_edit" -version = "0.22.24" +name = "toml_parser" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ - "indexmap 2.8.0", - "serde", - "serde_spanned", - "toml_datetime", "winnow", ] +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "rustls-native-certs", + "rustls-pemfile 2.2.0", + "socket2 0.5.10", + "tokio", + "tokio-rustls 0.26.4", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-types" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0081d8ee0847d01271392a5aebe960a4600f5d4da6c67648a6382a0940f8b367" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + [[package]] name = "tower" -version = "0.5.2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -3805,9 +5503,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -3817,20 +5515,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -3843,27 +5541,33 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -3871,16 +5575,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -3895,20 +5589,28 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", + "serde_derive", ] [[package]] -name = "utf16_iter" -version = "1.0.5" +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf8_iter" @@ -3924,13 +5626,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.16.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.4.2", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -3952,6 +5654,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "want" version = "0.3.1" @@ -3963,54 +5671,49 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen" -version = "0.2.100" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "serde", - "serde_json", - "wasm-bindgen-macro", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" +name = "wasm-bindgen" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.100", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -4019,9 +5722,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4029,35 +5732,46 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.100", - "wasm-bindgen-backend", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-logger" -version = "0.2.0" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "074649a66bb306c8f2068c9016395fa65d8e08d2affcbf95acf3c24c3ab19718" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "log", - "wasm-bindgen", - "web-sys", + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", ] [[package]] @@ -4075,16 +5789,34 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "winapi" version = "0.3.9" @@ -4109,48 +5841,83 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.52.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-targets 0.52.6", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "windows-link" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-registry" -version = "0.4.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ + "windows-link", "windows-result", "windows-strings", - "windows-targets 0.53.0", ] [[package]] name = "windows-result" -version = "0.3.1" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06374efe858fab7e4f881500e6e86ec8bc28f9462c47e5a9941a0142ad86b189" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" -version = "0.3.1" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4169,6 +5936,39 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4187,20 +5987,27 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.0" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4209,9 +6016,15 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" @@ -4221,9 +6034,15 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" @@ -4233,9 +6052,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -4245,9 +6064,15 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" @@ -4257,9 +6082,15 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" @@ -4269,9 +6100,15 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" @@ -4281,9 +6118,15 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" @@ -4293,198 +6136,138 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.4" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e97b544156e9bebe1a0ffbc03484fc1ffe3100cbce3ffb17eac35f7cdd7ab36" -dependencies = [ - "memchr", -] +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "winreg" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ - "bitflags 2.9.0", + "cfg-if", + "windows-sys 0.48.0", ] [[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - -[[package]] -name = "writeable" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" - -[[package]] -name = "xml-rs" -version = "0.8.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b940ebc25896e71dd073bad2dbaa2abfe97b0a391415e22ad1326d9c54e3c4" - -[[package]] -name = "xmlparser" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" - -[[package]] -name = "yew" -version = "0.18.0" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4d5154faef86dddd2eb333d4755ea5643787d20aca683e58759b0e53351409f" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "anyhow", - "anymap", - "bincode", - "cfg-if", - "cfg-match", - "console_error_panic_hook", - "gloo 0.2.1", - "http 0.2.12", - "indexmap 1.9.3", - "js-sys", - "log", - "ryu", - "serde", - "serde_json", - "slab", - "thiserror 1.0.69", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "yew-macro 0.18.0", + "wit-bindgen-rust-macro", ] [[package]] -name = "yew" -version = "0.19.3" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1ccb53e57d3f7d847338cf5758befa811cabe207df07f543c06f502f9998cd" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ - "console_error_panic_hook", - "gloo 0.4.2", - "gloo-utils 0.1.7", - "indexmap 1.9.3", - "js-sys", - "scoped-tls-hkt", - "slab", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "yew-macro 0.19.3", + "anyhow", + "heck 0.5.0", + "wit-parser", ] [[package]] -name = "yew-agent" -version = "0.1.0" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616700dc3851945658c44ba4477ede6b77c795462fbbb9b0ad9a8b6273a3ca77" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ - "anymap2", - "bincode", - "gloo-console", - "gloo-utils 0.1.7", - "js-sys", - "serde", - "slab", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "yew 0.19.3", + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] -name = "yew-macro" -version = "0.18.0" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6e23bfe3dc3933fbe9592d149c9985f3047d08c637a884b9344c21e56e092ef" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" dependencies = [ - "boolinator", - "lazy_static", + "anyhow", + "prettyplease", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] -name = "yew-macro" -version = "0.19.3" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fab79082b556d768d6e21811869c761893f0450e1d550a67892b9bce303b7bb" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ - "boolinator", - "lazy_static", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 1.0.109", + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "yew-router" -version = "0.16.0" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "155804f6f3aa309f596d5c3fa14486a94e7756f1edd7634569949e401d5099f2" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "gloo 0.4.2", - "gloo-utils 0.1.7", - "js-sys", - "route-recognizer", + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", "serde", - "serde-wasm-bindgen", - "serde_urlencoded", - "thiserror 1.0.69", - "wasm-bindgen", - "web-sys", - "yew 0.19.3", - "yew-router-macro", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] -name = "yew-router-macro" -version = "0.16.0" +name = "writeable" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39049d193b52eaad4ffc80916bf08806d142c90b5edcebd527644de438a7e19a" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] -name = "yewtil" -version = "0.4.0" +name = "xml-rs" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8543663ac49cd613df079282a1d8bdbdebdad6e02bac229f870fd4237b5d9aaa" -dependencies = [ - "log", - "serde", - "serde_json", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "yew 0.18.0", -] +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -4492,34 +6275,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.23" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.23" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] @@ -4539,21 +6322,32 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -4562,15 +6356,45 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", +] + +[[package]] +name = "zitadel" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168b66027ca4fd1aa3c529f1359a59f94495db612b57223bf933b2900df4e052" +dependencies = [ + "actix-web", + "base64-compat", + "custom_error", + "jsonwebtoken", + "openidconnect", + "pbjson-types", + "prost", + "prost-types", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_urlencoded", + "time", + "tokio", + "tonic", + "tonic-types", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zstd" version = "0.13.3" @@ -4582,18 +6406,18 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.3" +version = "7.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3051792fbdc2e1e143244dc28c60f73d8470e93f3f9cbd0ead44da5ed802722" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.14+zstd.1.5.7" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fb060d4926e4ac3a3ad15d864e99ceb5f343c6b34f5bd6d81ae6ed417311be5" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index deb07b10..8d12e9d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,16 +12,17 @@ readme = "README.md" maintenance = { status = "actively-developed" } [workspace] -members = ["thoth-api", "thoth-api-server", "thoth-app", "thoth-app-server", "thoth-client", "thoth-errors", "thoth-export-server"] +members = ["thoth-api", "thoth-api-server", "thoth-client", "thoth-errors", "thoth-export-server"] [dependencies] thoth-api = { version = "=0.13.16", path = "thoth-api", features = ["backend"] } thoth-api-server = { version = "=0.13.16", path = "thoth-api-server" } -thoth-app-server = { version = "=0.13.16", path = "thoth-app-server" } thoth-errors = { version = "=0.13.16", path = "thoth-errors" } thoth-export-server = { version = "=0.13.16", path = "thoth-export-server" } +base64 = "0.22.1" clap = { version = "4.5.32", features = ["cargo", "env"] } dialoguer = { version = "0.11.0", features = ["password"] } dotenv = "0.15.0" lazy_static = "1.5.0" tokio = { version = "1.44.1", features = ["rt", "rt-multi-thread", "macros"] } +zitadel = { version = "5.5.1", features = ["api", "interceptors"]} diff --git a/Dockerfile b/Dockerfile index 89a928a0..c5e51447 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,23 @@ -FROM rust:1.90.0 +FROM ghcr.io/thoth-pub/muslrust AS build -ARG THOTH_GRAPHQL_API=https://api.thoth.pub ARG THOTH_EXPORT_API=https://export.thoth.pub -ENV THOTH_GRAPHQL_API=${THOTH_GRAPHQL_API} ENV THOTH_EXPORT_API=${THOTH_EXPORT_API} -# Install build dependencies -RUN apt-get update && apt-get install -y \ - libssl-dev \ - pkg-config \ - && rm -rf /var/lib/apt/lists/* - # Get source COPY . . # Build Thoth for release from source RUN cargo build --release -# Move the binary to root for easier access -RUN mv target/release/thoth /thoth +FROM scratch + +# Get thoth binary +COPY --from=build \ + /volume/target/x86_64-unknown-linux-musl/release/thoth / + +# Get CA certificates +COPY --from=build \ + /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt # Expose thoth's default ports EXPOSE 8080 @@ -29,4 +28,4 @@ EXPOSE 8181 ENTRYPOINT ["/thoth"] # By default run `thoth init` (runs migrations and starts the server on port 8080) -CMD ["init"] +CMD ["init"] \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 8107eed2..00000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,59 +0,0 @@ -FROM rust - -ENV TRUNK_VERSION=0.21.9 - -ARG THOTH_GRAPHQL_API=http://localhost:8000 -ARG THOTH_EXPORT_API=http://localhost:8181 -ENV THOTH_GRAPHQL_API=${THOTH_GRAPHQL_API} -ENV THOTH_EXPORT_API=${THOTH_EXPORT_API} - -WORKDIR /usr/src/thoth - -# Expose thoth's default ports -EXPOSE 8080 -EXPOSE 8000 -EXPOSE 8181 - -# Install build dependencies for thoth-app -RUN rustup target add wasm32-unknown-unknown -RUN cargo install trunk --version ${TRUNK_VERSION} - -# Use dummy file to force cargo to install dependencies without compiling code. -# We need to get dummy lib files for all members of the workspace, and their cargo files, -# then we run wasm-pack and cargo build to download and compile all project dependencies. -RUN mkdir src -RUN echo "fn main() {}" > src/main.rs -COPY Cargo.lock . -COPY Cargo.toml . -COPY thoth-api/Cargo.toml thoth-api/Cargo.toml -COPY thoth-api-server/Cargo.toml thoth-api-server/Cargo.toml -COPY thoth-app/Cargo.toml thoth-app/Cargo.toml -COPY thoth-app-server/Cargo.toml thoth-app-server/Cargo.toml -COPY thoth-client/Cargo.toml thoth-client/Cargo.toml -COPY thoth-errors/Cargo.toml thoth-errors/Cargo.toml -COPY thoth-export-server/Cargo.toml thoth-export-server/Cargo.toml -RUN mkdir thoth-api/src thoth-api-server/src thoth-app/src \ - thoth-app-server/src thoth-client/src thoth-errors/src \ - thoth-export-server/src -RUN touch thoth-api/src/lib.rs thoth-api-server/src/lib.rs \ - thoth-app/src/lib.rs thoth-app-server/src/lib.rs thoth-client/src/lib.rs \ - thoth-errors/src/lib.rs thoth-export-server/src/lib.rs -RUN echo "fn main() {}" > thoth-client/build.rs -RUN echo "fn main() {}" > thoth-app-server/build.rs -RUN echo "fn main() {}" > thoth-export-server/build.rs -RUN cargo build -RUN rm -rf src thoth-api thoth-api-server thoth-app thoth-app-server thoth-client \ - thoth-errors thoth-export-server Cargo.toml Cargo.lock - -# Get the actual source -COPY . . - -# Change access and modified times of previously-defined-as-dummy files to let cargo know -# it needs to (re)compile these modules -RUN touch -a -m thoth-api/src/lib.rs thoth-api-server/src/lib.rs \ - thoth-app/src/lib.rs thoth-app-server/src/lib.rs thoth-client/src/lib.rs \ - thoth-errors/src/lib.rs thoth-export-server/src/lib.rs thoth-app-server/build.rs \ - thoth-export-server/build.rs - -# Build Thoth for debug -RUN cargo build diff --git a/Makefile b/Makefile index 6b0bf3bc..a073d2a3 100644 --- a/Makefile +++ b/Makefile @@ -1,65 +1,76 @@ .PHONY: \ - build-graphql-api \ - build-export-api \ - build-app \ - run-app \ + help \ + run-db \ + run-zitadel-db \ + run-redis \ + run-zitadel \ run-graphql-api \ run-export-api \ - watch-app \ - docker-dev \ - docker-dev-build \ - docker-dev-run \ - docker-dev-db \ - docker-dev-redis \ build \ test \ + check \ clippy \ format \ check-format \ - check \ check-all \ - -all: build-graphql-api build-export-api build-app -check-all: test check clippy check-format - -run-app: build-app - RUST_BACKTRACE=1 cargo run start app - -run-graphql-api: build-graphql-api + migration \ + coverage + +CARGO_VERSION := $(shell grep '^version' Cargo.toml | sed -E 's/version *= *"([^"]+)"/\1/') +MAJOR := $(word 1,$(subst ., ,$(CARGO_VERSION))) +MINOR := $(word 2,$(subst ., ,$(CARGO_VERSION))) + +DATE = $(shell date +"%Y%m%d") + +help: + @echo "Available targets:" + @echo " help Show this help" + @echo " run-db Start PostgreSQL (docker)" + @echo " run-zitadel-db Start Zitadel PostgreSQL (docker)" + @echo " run-redis Start Redis (docker)" + @echo " run-zitadel Start Zitadel (docker)" + @echo " run-graphql-api Run GraphQL API (cargo)" + @echo " run-export-api Run export API (cargo)" + @echo " build Build the workspace" + @echo " test Run tests" + @echo " coverage Run test coverage (cargo llvm-cov)" + @echo " check Run cargo check" + @echo " clippy Lint with cargo clippy" + @echo " format Format code with cargo fmt" + @echo " check-format Check formatting" + @echo " check-all Run tests, clippy, and formatting checks" + @echo " migration Create a database migration" + +run-db: + docker compose up db + +run-zitadel-db: + docker compose up zitadel-db + +run-redis: + docker compose up redis + +run-zitadel: + docker compose up zitadel + +run-graphql-api: build RUST_BACKTRACE=1 cargo run init -run-export-api: build-export-api +run-export-api: build RUST_BACKTRACE=1 cargo run start export-api -watch-app: - trunk serve thoth-app/index.html - -docker-dev: docker-dev-build docker-dev-run - -docker-dev-build: - docker compose -f docker-compose.dev.yml build - -docker-dev-run: - docker compose -f docker-compose.dev.yml up - -docker-dev-db: - docker compose -f docker-compose.dev.yml up db - -docker-dev-redis: - docker compose -f docker-compose.dev.yml up redis - build: cargo build -vv -build-graphql-api: build - -build-export-api: build - -build-app: build - test: cargo test --workspace +coverage: + cargo llvm-cov --workspace --all-features --html --output-dir ./coverage + +check: + cargo check --workspace + clippy: cargo clippy --all --all-targets --all-features -- -D warnings @@ -69,5 +80,13 @@ format: check-format: cargo fmt --all -- --check -check: - cargo check --workspace +check-all: test check clippy check-format + +migration: + @new_minor=$$(expr $(MINOR) + 1); \ + new_version="$(MAJOR).$$new_minor.0"; \ + dir="thoth-api/migrations/$(DATE)_v$$new_version"; \ + mkdir -p $$dir; \ + touch $$dir/up.sql; \ + touch $$dir/down.sql; + diff --git a/README.md b/README.md index 19f67fb3..58a1635e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ * A [GraphQL API](https://api.thoth.pub), implementing a data model specifically designed for OA books * A [REST API](https://export.thoth.pub) to export metadata in formats like ONIX, MARC, etc. -* A [WebAssembly GUI](https://thoth.pub) to manage metadata records. For more information about Thoth, its data and metadata formats, and more, see the repo's [wiki](https://github.com/thoth-pub/thoth/wiki). You can also use GraphiQL to [explore the GraphQL API](https://api.thoth.pub/graphiql) (click on "Docs" at the top right), or RapiDoc to [inspect the REST API](https://export.thoth.pub). @@ -30,8 +29,6 @@ For more information about Thoth, its data and metadata formats, and more, see t - [Rustup](https://rustup.rs/) - Stable Toolchain: `rustup default stable` -- [wasm-pack](https://rustwasm.github.io/docs/wasm-pack/introduction.html) -- [rollup](https://www.npmjs.com/package/rollup) - A PostgreSQL database (included in docker-compose.yml if ran using docker) - `libssl-dev` @@ -113,12 +110,6 @@ cargo run init cargo run start export-api ``` -#### GUI - -```sh -cargo run start app -``` - ### Building with docker The wasm APP needs to know the endpoint the API will be running at compile time, we must provide `THOTH_API` as a build argument to the docker daemon upon build: diff --git a/diesel.toml b/diesel.toml index 752265c1..abde98b4 100644 --- a/diesel.toml +++ b/diesel.toml @@ -14,7 +14,10 @@ custom_type_derives = [ "crate::model::language::Language_code", "crate::model::series::Series_type", "crate::model::price::Currency_code", - "crate::model::subject::Subject_type" - "crate::model::institution::Country_code" + "crate::model::subject::Subject_type", + "crate::model::institution::Country_code", "crate::model::work_relation::Relation_type" + "crate::model::contact::Contact_type" + "crate::model::publication::Accessibility_standard" + "crate::model::publication::Accessibility_exception" ] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index 245d7cff..00000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,56 +0,0 @@ -services: - db: - image: postgres:17 - container_name: "thoth_db" - ports: - - "5432:5432" - volumes: - - ./db/_data:/var/lib/postgresql/data - env_file: - - .env - - redis: - image: redis:alpine - container_name: "thoth_redis" - ports: - - "6379:6379" - - graphql-api: - build: - context: . - dockerfile: Dockerfile.dev - container_name: "thoth_graphql_api" - ports: - - "8000:8000" - command: ["cargo", "run", "init"] - env_file: - - .env - depends_on: - - db - - export-api: - build: - context: . - dockerfile: Dockerfile.dev - container_name: "thoth_export_api" - ports: - - "8181:8181" - command: ["cargo", "run", "start", "export-api"] - env_file: - - .env - depends_on: - - graphql-api - - app: - build: - context: . - dockerfile: Dockerfile.dev - container_name: "thoth_app" - ports: - - "8080:8080" - command: ["cargo", "run", "start", "app"] - env_file: - - .env - depends_on: - - graphql-api - - export-api diff --git a/docker-compose.yml b/docker-compose.yml index 1fba394c..e2b86094 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,47 +2,36 @@ services: db: image: postgres:17 container_name: "thoth_db" - restart: unless-stopped + ports: + - "5432:5432" volumes: - - db:/var/lib/postgresql/data - - /etc/localtime:/etc/localtime:ro + - ./db/_data:/var/lib/postgresql/data env_file: - .env + zitadel-db: + image: postgres:17 + container_name: "zitadel_db" + volumes: + - ./db/_zitadel:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: postgres + redis: image: redis:alpine container_name: "thoth_redis" - restart: unless-stopped - - graphql-api: - image: ghcr.io/thoth-pub/thoth - container_name: "thoth_graphql_api" - restart: unless-stopped - env_file: - - .env - depends_on: - - db + ports: + - "6379:6379" - export-api: - image: ghcr.io/thoth-pub/thoth - container_name: "thoth_export_api" - restart: unless-stopped - command: ["start", "export-api"] - env_file: - - .env - depends_on: - - graphql-api - - app: - image: ghcr.io/thoth-pub/thoth - container_name: "thoth_app" - restart: unless-stopped - command: ["start", "app"] + zitadel: + image: ghcr.io/zitadel/zitadel:v3.2.2 + command: 'start-from-init --masterkey "${ZITADEL_MASTERKEY}" --tlsMode disabled' + container_name: "zitadel" + ports: + - "8282:8080" env_file: - .env + volumes: + - ./machinekey:/machinekey depends_on: - - graphql-api - - export-api - -volumes: - db: + - zitadel-db diff --git a/src/bin/arguments/mod.rs b/src/bin/arguments/mod.rs index 53d75d51..23694810 100644 --- a/src/bin/arguments/mod.rs +++ b/src/bin/arguments/mod.rs @@ -42,37 +42,14 @@ pub fn port(default_value: &'static str, env_value: &'static str) -> Arg { .num_args(1) } -pub fn domain() -> Arg { - Arg::new("domain") - .short('d') - .long("domain") - .value_name("THOTH_DOMAIN") - .env("THOTH_DOMAIN") - .default_value("localhost") - .help("Authentication cookie domain") - .num_args(1) -} - pub fn key() -> Arg { Arg::new("key") .short('k') - .long("secret-key") - .value_name("SECRET") - .env("SECRET_KEY") - .help("Authentication cookie secret key") - .num_args(1) -} - -pub fn session() -> Arg { - Arg::new("duration") - .short('s') - .long("session-length") - .value_name("DURATION") - .env("SESSION_DURATION_SECONDS") - .default_value("3600") - .help("Authentication cookie session duration (seconds)") + .long("private-key") + .value_name("PRIVATE_KEY") + .env("PRIVATE_KEY") + .help("Thoth's GraphQL API zitadel private key (base64-encoded JSON key)") .num_args(1) - .value_parser(value_parser!(i64)) } pub fn gql_url() -> Arg { @@ -108,6 +85,27 @@ pub fn export_url() -> Arg { .num_args(1) } +pub fn zitadel_url() -> Arg { + Arg::new("zitadel-url") + .short('z') + .long("zitadel-url") + .value_name("ZITADEL_URL") + .env("ZITADEL_URL") + .default_value("http://localhost:8282") + .help("Zitadel's, public facing, root URL.") + .num_args(1) +} + +pub fn thoth_pat() -> Arg { + Arg::new("thoth-pat") + .short('P') + .long("thoth-pat") + .value_name("THOTH_PAT") + .env("THOTH_PAT") + .help("Thoth service account Personal Access Token (PAT)") + .num_args(1) +} + pub fn threads(env_value: &'static str) -> Arg { Arg::new("threads") .short('t') @@ -138,3 +136,30 @@ pub fn revert() -> Arg { .help("Revert all database migrations") .action(ArgAction::SetTrue) } + +pub fn aws_access_key_id() -> Arg { + Arg::new("aws-access-key-id") + .long("aws-access-key-id") + .value_name("AWS_ACCESS_KEY_ID") + .env("AWS_ACCESS_KEY_ID") + .help("AWS access key id") + .num_args(1) +} + +pub fn aws_secret_access_key() -> Arg { + Arg::new("aws-secret-access-key") + .long("aws-secret-access-key") + .value_name("AWS_SECRET_ACCESS_KEY") + .env("AWS_SECRET_ACCESS_KEY") + .help("AWS secret access key") + .num_args(1) +} + +pub fn aws_region() -> Arg { + Arg::new("aws-region") + .long("aws-region") + .value_name("AWS_REGION") + .env("AWS_REGION") + .help("AWS region for S3/CloudFront") + .num_args(1) +} diff --git a/src/bin/commands/account.rs b/src/bin/commands/account.rs index 629c637e..98c43141 100644 --- a/src/bin/commands/account.rs +++ b/src/bin/commands/account.rs @@ -110,7 +110,7 @@ fn password_input() -> ThothResult { fn is_admin_input(publisher_name: &str) -> ThothResult { Input::with_theme(&ColorfulTheme::default()) - .with_prompt(format!("Make user an admin of '{}'?", publisher_name)) + .with_prompt(format!("Make user an admin of '{publisher_name}'?")) .default(false) .interact_on(&Term::stdout()) .map_err(Into::into) diff --git a/src/bin/commands/cache.rs b/src/bin/commands/cache.rs index c9ff9c29..d59fd080 100644 --- a/src/bin/commands/cache.rs +++ b/src/bin/commands/cache.rs @@ -32,7 +32,7 @@ pub fn delete(arguments: &ArgMatches) -> ThothResult<()> { runtime.block_on(async { for index in chosen { let specification = ALL_SPECIFICATIONS.get(index).unwrap(); - let keys = scan_match(&pool, &format!("{}*", specification)).await?; + let keys = scan_match(&pool, &format!("{specification}*")).await?; for key in keys { del(&pool, &key).await?; } diff --git a/src/bin/commands/mod.rs b/src/bin/commands/mod.rs index f6a585c9..7b5a002a 100644 --- a/src/bin/commands/mod.rs +++ b/src/bin/commands/mod.rs @@ -3,18 +3,15 @@ use clap::Command; use lazy_static::lazy_static; use thoth::{ api::{ - db::{ - init_pool as init_pg_pool, revert_migrations as revert_db_migrations, - run_migrations as run_db_migrations, PgPool, - }, + db::{revert_migrations as revert_db_migrations, run_migrations as run_db_migrations}, redis::{init_pool as init_redis_pool, RedisPool}, }, errors::ThothResult, }; -pub(super) mod account; pub(super) mod cache; pub(super) mod start; +pub(super) mod zitadel; lazy_static! { pub(super) static ref INIT: Command = Command::new("init") @@ -25,9 +22,11 @@ lazy_static! { .arg(arguments::threads("GRAPHQL_API_THREADS")) .arg(arguments::keep_alive("GRAPHQL_API_KEEP_ALIVE")) .arg(arguments::gql_url()) - .arg(arguments::domain()) .arg(arguments::key()) - .arg(arguments::session()); + .arg(arguments::zitadel_url()) + .arg(arguments::aws_access_key_id()) + .arg(arguments::aws_secret_access_key()) + .arg(arguments::aws_region()); } lazy_static! { @@ -37,11 +36,6 @@ lazy_static! { .arg(arguments::revert()); } -fn get_pg_pool(arguments: &clap::ArgMatches) -> PgPool { - let database_url = arguments.get_one::("db").unwrap(); - init_pg_pool(database_url) -} - fn get_redis_pool(arguments: &clap::ArgMatches) -> RedisPool { let redis_url = arguments.get_one::("redis").unwrap(); init_redis_pool(redis_url) diff --git a/src/bin/commands/start.rs b/src/bin/commands/start.rs index 9ef2f3c8..c557c857 100644 --- a/src/bin/commands/start.rs +++ b/src/bin/commands/start.rs @@ -1,7 +1,7 @@ use crate::arguments; use clap::{ArgMatches, Command}; use lazy_static::lazy_static; -use thoth::{api_server, app_server, errors::ThothResult, export_server}; +use thoth::{api_server, errors::ThothResult, export_server}; lazy_static! { pub(crate) static ref COMMAND: Command = Command::new("start") @@ -17,17 +17,11 @@ lazy_static! { .arg(arguments::threads("GRAPHQL_API_THREADS")) .arg(arguments::keep_alive("GRAPHQL_API_KEEP_ALIVE")) .arg(arguments::gql_url()) - .arg(arguments::domain()) .arg(arguments::key()) - .arg(arguments::session()), - ) - .subcommand( - Command::new("app") - .about("Start the thoth client GUI") - .arg(arguments::host("APP_HOST")) - .arg(arguments::port("8080", "APP_PORT")) - .arg(arguments::threads("APP_THREADS")) - .arg(arguments::keep_alive("APP_KEEP_ALIVE")), + .arg(arguments::zitadel_url()) + .arg(arguments::aws_access_key_id()) + .arg(arguments::aws_secret_access_key()) + .arg(arguments::aws_region()), ) .subcommand( Command::new("export-api") @@ -49,9 +43,12 @@ pub fn graphql_api(arguments: &ArgMatches) -> ThothResult<()> { let threads = *arguments.get_one::("threads").unwrap(); let keep_alive = *arguments.get_one::("keep-alive").unwrap(); let url = arguments.get_one::("gql-url").unwrap().to_owned(); - let domain = arguments.get_one::("domain").unwrap().to_owned(); - let secret_str = arguments.get_one::("key").unwrap().to_owned(); - let session_duration = *arguments.get_one::("duration").unwrap(); + let private_key = arguments.get_one::("key").unwrap().to_owned(); + let zitadel_url = arguments + .get_one::("zitadel-url") + .unwrap() + .to_owned(); + api_server( database_url, host, @@ -59,21 +56,23 @@ pub fn graphql_api(arguments: &ArgMatches) -> ThothResult<()> { threads, keep_alive, url, - domain, - secret_str, - session_duration, + private_key, + zitadel_url, + arguments + .get_one::("aws-access-key-id") + .unwrap() + .to_owned(), + arguments + .get_one::("aws-secret-access-key") + .unwrap() + .to_owned(), + arguments + .get_one::("aws-region") + .unwrap() + .to_owned(), ) .map_err(|e| e.into()) } - -pub fn app(arguments: &ArgMatches) -> ThothResult<()> { - let host = arguments.get_one::("host").unwrap().to_owned(); - let port = arguments.get_one::("port").unwrap().to_owned(); - let threads = *arguments.get_one::("threads").unwrap(); - let keep_alive = *arguments.get_one::("keep-alive").unwrap(); - app_server(host, port, threads, keep_alive).map_err(|e| e.into()) -} - pub fn export_api(arguments: &ArgMatches) -> ThothResult<()> { let redis_url = arguments.get_one::("redis").unwrap().to_owned(); let host = arguments.get_one::("host").unwrap().to_owned(); diff --git a/src/bin/commands/zitadel.rs b/src/bin/commands/zitadel.rs new file mode 100644 index 00000000..2859ea84 --- /dev/null +++ b/src/bin/commands/zitadel.rs @@ -0,0 +1,158 @@ +use crate::arguments; +use base64::{engine::general_purpose, Engine as _}; +use clap::{ArgMatches, Command}; +use lazy_static::lazy_static; +use thoth::errors::{ThothError, ThothResult}; +use zitadel::api::{ + clients::ClientBuilder, + zitadel::app::v1::{ + ApiAuthMethodType, OidcAppType, OidcAuthMethodType, OidcGrantType, OidcResponseType, + OidcTokenType, OidcVersion, + }, + zitadel::authn::v1::KeyType, + zitadel::management::v1::{ + AddApiAppRequest, AddAppKeyRequest, AddOidcAppRequest, AddProjectRequest, + AddProjectRoleRequest, AddUserGrantRequest, + }, + zitadel::project::v1::PrivateLabelingSetting, + zitadel::user::v2::{ListUsersRequest, UserFieldName}, +}; + +lazy_static! { + pub(crate) static ref COMMAND: Command = Command::new("zitadel") + .about("Manage Zitadel workflows") + .arg(arguments::zitadel_url()) + .arg(arguments::thoth_pat()) + .subcommand_required(true) + .arg_required_else_help(true) + .subcommand(Command::new("setup").about("Intial setup of OIDC APPs in zitadel")); +} + +pub fn setup(arguments: &ArgMatches) -> ThothResult<()> { + let zitadel_url = arguments.get_one::("zitadel-url").unwrap(); + let pat = arguments.get_one::("thoth-pat").unwrap(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build()?; + + runtime.block_on(async { + let mut management_client = ClientBuilder::new(zitadel_url) + .with_access_token(pat) + .build_management_client() + .await?; + let mut user_client = ClientBuilder::new(zitadel_url) + .with_access_token(pat) + .build_user_client() + .await?; + + // Create Zitadel project + let project_name = "Thoth"; + let project = management_client + .add_project(AddProjectRequest { + name: project_name.to_string(), + project_role_assertion: false, + project_role_check: false, + has_project_check: false, + private_labeling_setting: PrivateLabelingSetting::EnforceProjectResourceOwnerPolicy + as i32, + }) + .await? + .into_inner(); + println!("\n✅ Created Zitadel project: {}", project_name); + + // Create project user roles + let roles = [ + ("SUPERUSER", "Superuser", "Superusers"), + ("PUBLISHER_ADMIN", "Publisher Admin", "Publisher admins"), + ("PUBLISHER_USER", "Publisher User", "Publisher users"), + ]; + for (role_key, display_name, group) in roles { + management_client + .add_project_role(AddProjectRoleRequest { + project_id: project.id.clone(), + role_key: role_key.to_string(), + display_name: display_name.to_string(), + group: group.to_string(), + }) + .await?; + println!("\n✅ Added project role: {}", role_key); + } + + // Assign SUPERUSER role to default accounts + let users = user_client + .list_users(ListUsersRequest { + query: None, + sorting_column: UserFieldName::CreationDate as i32, + queries: vec![], + }) + .await? + .into_inner() + .result; + for user in users { + management_client + .add_user_grant(AddUserGrantRequest { + user_id: user.user_id.clone(), + project_id: project.id.clone(), + project_grant_id: "".to_string(), + role_keys: vec!["SUPERUSER".to_string()], + }) + .await?; + println!("\n✅ Granted SUPERUSER role to user: {}", user.username); + } + + // Create Zitadel APPs for GraphQL API and APP + let graphql_api_name = "Thoth GraphQL API"; + let graphql_api = management_client + .add_api_app(AddApiAppRequest { + project_id: project.id.clone(), + name: graphql_api_name.to_string(), + auth_method_type: ApiAuthMethodType::PrivateKeyJwt as i32, + }) + .await? + .into_inner(); + println!("\n✅ Created API app: {}", graphql_api_name); + + let graphql_api_key = management_client + .add_app_key(AddAppKeyRequest { + project_id: project.id.clone(), + app_id: graphql_api.app_id, + r#type: KeyType::Json as i32, + expiration_date: None, + }) + .await? + .into_inner(); + let encoded_key = general_purpose::STANDARD.encode(&graphql_api_key.key_details); + println!("\n✅ {} application key generated.", graphql_api_name); + println!("👉 Please copy the following and add it to the `.env` file as `PRIVATE_KEY`:\n"); + println!("PRIVATE_KEY={}\n", encoded_key); + + let app_name = "Thoth APP"; + management_client + .add_oidc_app(AddOidcAppRequest { + project_id: project.id.clone(), + name: app_name.to_string(), + redirect_uris: vec!["http://localhost:8080/callback".to_string()], + response_types: vec![OidcResponseType::Code as i32], + grant_types: vec![OidcGrantType::AuthorizationCode as i32], + app_type: OidcAppType::UserAgent as i32, + auth_method_type: OidcAuthMethodType::None as i32, // PKCE + post_logout_redirect_uris: vec!["http://localhost:8080/logout".to_string()], + version: OidcVersion::OidcVersion10 as i32, + dev_mode: true, + access_token_type: OidcTokenType::Bearer as i32, + access_token_role_assertion: false, + id_token_role_assertion: false, + id_token_userinfo_assertion: false, + clock_skew: None, + additional_origins: vec!["http://localhost:8080".to_string()], + skip_native_app_success_page: false, + back_channel_logout_uri: "".to_string(), + login_version: None, + }) + .await?; + println!("\n✅ Created OIDC app: {}", app_name); + + Ok::<(), ThothError>(()) + }) +} diff --git a/src/bin/thoth.rs b/src/bin/thoth.rs index 42597884..6ee60f80 100644 --- a/src/bin/thoth.rs +++ b/src/bin/thoth.rs @@ -11,8 +11,8 @@ lazy_static::lazy_static! { .subcommand(commands::MIGRATE.clone()) .subcommand(commands::start::COMMAND.clone()) .subcommand(commands::INIT.clone()) - .subcommand(commands::account::COMMAND.clone()) - .subcommand(commands::cache::COMMAND.clone()); + .subcommand(commands::cache::COMMAND.clone()) + .subcommand(commands::zitadel::COMMAND.clone()); } fn main() -> thoth::errors::ThothResult<()> { @@ -22,7 +22,6 @@ fn main() -> thoth::errors::ThothResult<()> { match THOTH.clone().get_matches().subcommand() { Some(("start", start_arguments)) => match start_arguments.subcommand() { Some(("graphql-api", arguments)) => commands::start::graphql_api(arguments), - Some(("app", arguments)) => commands::start::app(arguments), Some(("export-api", arguments)) => commands::start::export_api(arguments), _ => unreachable!(), }, @@ -31,16 +30,14 @@ fn main() -> thoth::errors::ThothResult<()> { commands::run_migrations(arguments)?; commands::start::graphql_api(arguments) } - Some(("account", arguments)) => match arguments.subcommand() { - Some(("register", _)) => commands::account::register(arguments), - Some(("publishers", _)) => commands::account::publishers(arguments), - Some(("password", _)) => commands::account::password(arguments), - _ => unreachable!(), - }, Some(("cache", arguments)) => match arguments.subcommand() { Some(("delete", _)) => commands::cache::delete(arguments), _ => unreachable!(), }, + Some(("zitadel", arguments)) => match arguments.subcommand() { + Some(("setup", _)) => commands::zitadel::setup(arguments), + _ => unreachable!(), + }, _ => unreachable!(), } } diff --git a/src/lib.rs b/src/lib.rs index b0e60dc4..10b035ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,4 @@ pub use thoth_api as api; pub use thoth_api_server::start_server as api_server; -pub use thoth_app_server::start_server as app_server; pub use thoth_errors as errors; pub use thoth_export_server::{start_server as export_server, ALL_SPECIFICATIONS}; diff --git a/thoth-api-server/Cargo.toml b/thoth-api-server/Cargo.toml index dce4bdc0..cf3f4a75 100644 --- a/thoth-api-server/Cargo.toml +++ b/thoth-api-server/Cargo.toml @@ -14,10 +14,9 @@ thoth-errors = { version = "=0.13.16", path = "../thoth-errors" } actix-web = "4.10" actix-cors = "0.7.1" actix-http = "3.10.0" -actix-identity = "0.8.0" -actix-session = { version = "0.10.1", features = ["cookie-session"] } +base64 = "0.22.1" env_logger = "0.11.7" futures-util = "0.3.31" log = "0.4.26" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +zitadel = { version = "5.5.1", features = ["actix"]} diff --git a/thoth-api-server/src/graphiql.rs b/thoth-api-server/src/graphiql.rs index 185baecf..79636ebc 100644 --- a/thoth-api-server/src/graphiql.rs +++ b/thoth-api-server/src/graphiql.rs @@ -26,7 +26,9 @@ pub fn graphiql_source(graphql_endpoint_url: &str) -> String { # { books(order: {field: PUBLICATION_DATE, direction: ASC}) { - fullTitle + titles { + fullTitle + } doi publications { publicationType diff --git a/thoth-api-server/src/lib.rs b/thoth-api-server/src/lib.rs index 318faaf0..8f9967b5 100644 --- a/thoth-api-server/src/lib.rs +++ b/thoth-api-server/src/lib.rs @@ -4,28 +4,25 @@ mod logger; use std::{io, sync::Arc, time::Duration}; use actix_cors::Cors; -use actix_identity::{Identity, IdentityMiddleware}; -use actix_session::{config::PersistentSession, storage::CookieSessionStore, SessionMiddleware}; use actix_web::{ - cookie::{time::Duration as CookieDuration, Key}, - error, get, + get, http::header, middleware::Compress, post, web::{Data, Json}, - App, Error, HttpMessage, HttpRequest, HttpResponse, HttpServer, Result, + App, Error, HttpResponse, HttpServer, Result, }; +use base64::{engine::general_purpose, Engine as _}; use serde::Serialize; use thoth_api::{ - account::model::{AccountDetails, DecodedToken, LoginCredentials}, - account::service::{get_account, get_account_details, login}, db::{init_pool, PgPool}, - graphql::{ - model::{create_schema, Context, Schema}, - GraphQLRequest, - }, + graphql::{create_schema, Context, GraphQLRequest, Schema}, + storage::{create_cloudfront_client, create_s3_client, CloudFrontClient, S3Client}, +}; +use zitadel::{ + actix::introspection::{IntrospectedUser, IntrospectionConfigBuilder}, + credentials::Application, }; -use thoth_errors::ThothError; use crate::graphiql::graphiql_source; use crate::logger::{BodyLogger, Logger}; @@ -63,7 +60,7 @@ impl Default for ApiConfig { #[get("/")] async fn index(config: Data) -> HttpResponse { - HttpResponse::Ok().json(config.into_inner()) + HttpResponse::Ok().json(config.get_ref()) } #[get("/graphiql")] @@ -91,10 +88,17 @@ async fn graphql_schema(st: Data>) -> HttpResponse { async fn graphql( st: Data>, pool: Data, - token: DecodedToken, + s3_client: Data, + cloudfront_client: Data, + user: Option, data: Json, ) -> Result { - let ctx = Context::new(pool.into_inner(), token); + let ctx = Context::new( + pool.into_inner(), + user, + s3_client.into_inner(), + cloudfront_client.into_inner(), + ); let result = data.execute(&st, &ctx).await; match result.is_ok() { true => Ok(HttpResponse::Ok().json(result)), @@ -102,86 +106,6 @@ async fn graphql( } } -#[post("/account/login")] -async fn login_credentials( - request: HttpRequest, - payload: Json, - pool: Data, -) -> Result { - let r = payload.into_inner(); - - login(&r.email, &r.password, &pool) - .and_then(|account| { - account.issue_token(&pool)?; - let details = get_account_details(&account.email, &pool).unwrap(); - let user_string = serde_json::to_string(&details) - .map_err(|_| ThothError::InternalError("Serder error".into()))?; - Identity::login(&request.extensions(), user_string) - .map_err(|_| ThothError::InternalError("Failed to store session cookie".into()))?; - Ok(HttpResponse::Ok().json(details)) - }) - .map_err(error::ErrorUnauthorized) -} - -#[post("/account/token/renew")] -async fn login_session( - request: HttpRequest, - token: DecodedToken, - identity: Option, - pool: Data, -) -> Result { - let email = match identity { - Some(session) => { - let id = session.id().map_err(|_| ThothError::Unauthorised)?; - let details: AccountDetails = - serde_json::from_str(&id).map_err(|_| ThothError::Unauthorised)?; - details.email - } - None => { - token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let t = token.jwt.unwrap(); - t.sub - } - }; - - get_account(&email, &pool) - .and_then(|account| { - account.issue_token(&pool)?; - let details = get_account_details(&account.email, &pool).unwrap(); - let user_string = serde_json::to_string(&details) - .map_err(|_| ThothError::InternalError("Serder error".into()))?; - Identity::login(&request.extensions(), user_string) - .map_err(|_| ThothError::InternalError("Failed to store session cookie".into()))?; - Ok(HttpResponse::Ok().json(details)) - }) - .map_err(error::ErrorUnauthorized) -} - -#[get("/account")] -async fn account_details( - token: DecodedToken, - identity: Option, - pool: Data, -) -> Result { - let email = match identity { - Some(session) => { - let id = session.id().map_err(|_| ThothError::Unauthorised)?; - let details: AccountDetails = - serde_json::from_str(&id).map_err(|_| ThothError::Unauthorised)?; - details.email - } - None => { - token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let t = token.jwt.unwrap(); - t.sub - } - }; - - get_account_details(&email, &pool) - .map(|account_details| HttpResponse::Ok().json(account_details)) - .map_err(error::ErrorUnauthorized) -} - #[allow(clippy::too_many_arguments)] #[actix_web::main] pub async fn start_server( @@ -191,33 +115,35 @@ pub async fn start_server( threads: usize, keep_alive: u64, public_url: String, - domain: String, - secret_str: String, - session_duration: i64, + private_key: String, + zitadel_url: String, + aws_access_key_id: String, + aws_secret_access_key: String, + aws_region: String, ) -> io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + let decoded_private_key = general_purpose::STANDARD + .decode(&private_key) + .expect("Failed to base64-decode private key"); + let decoded_str = + std::str::from_utf8(&decoded_private_key).expect("Decoded key is not valid UTF-8"); + let auth = IntrospectionConfigBuilder::new(&zitadel_url) + .with_jwt_profile(Application::load_from_json(decoded_str).unwrap()) + .build() + .await + .unwrap(); + + let s3_client = create_s3_client(&aws_access_key_id, &aws_secret_access_key, &aws_region).await; + let cloudfront_client = + create_cloudfront_client(&aws_access_key_id, &aws_secret_access_key, &aws_region).await; + let pool = Data::new(init_pool(&database_url)); + HttpServer::new(move || { App::new() .wrap(Compress::default()) .wrap(Logger::default()) .wrap(BodyLogger) - .wrap(IdentityMiddleware::default()) - .wrap( - SessionMiddleware::builder( - CookieSessionStore::default(), - Key::from(secret_str.as_bytes()), - ) - .cookie_name("auth".to_string()) - .cookie_path("/".to_string()) - .cookie_domain(Some(domain.clone())) - .cookie_secure(domain.clone().ne("localhost")) // Authentication requires https unless running on localhost - .session_lifecycle( - PersistentSession::default() - .session_ttl(CookieDuration::seconds(session_duration)), - ) - .build(), - ) .wrap( Cors::default() .allowed_methods(vec!["GET", "POST", "OPTIONS"]) @@ -226,16 +152,16 @@ pub async fn start_server( .allowed_header(header::CONTENT_TYPE) .supports_credentials(), ) + .app_data(auth.clone()) .app_data(Data::new(ApiConfig::new(public_url.clone()))) - .app_data(Data::new(init_pool(&database_url))) + .app_data(pool.clone()) + .app_data(Data::new(s3_client.clone())) + .app_data(Data::new(cloudfront_client.clone())) .app_data(Data::new(Arc::new(create_schema()))) .service(index) .service(graphql_index) .service(graphql) .service(graphiql_interface) - .service(login_credentials) - .service(login_session) - .service(account_details) .service(graphql_schema) }) .workers(threads) diff --git a/thoth-api/Cargo.toml b/thoth-api/Cargo.toml index 0c60895c..601af33e 100644 --- a/thoth-api/Cargo.toml +++ b/thoth-api/Cargo.toml @@ -21,7 +21,14 @@ backend = [ "jsonwebtoken", "deadpool-redis", "rand", - "argon2rs" + "argon2rs", + "zitadel", + "aws-sdk-s3", + "aws-sdk-cloudfront", + "aws-config", + "aws-credential-types", + "base64", + "hex" ] [dependencies] @@ -40,12 +47,23 @@ futures = { version = "0.3.31", optional = true } jsonwebtoken = { version = "9.3.1", optional = true } juniper = { version = "0.16.1", features = ["chrono", "schema-language", "uuid"] } lazy_static = "1.5.0" +pulldown-cmark = "0.13.0" rand = { version = "0.9.0", optional = true } regex = "1.11.1" +scraper = "0.20.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" strum = { version = "0.27.1", features = ["derive"] } uuid = { version = "1.16.0", features = ["serde", "v4"] } +zitadel = { version = "5.5.1", features = ["actix"], optional = true } +aws-sdk-s3 = { version = "1", optional = true } +aws-sdk-cloudfront = { version = "1", optional = true } +aws-config = { version = "1", optional = true } +aws-credential-types = { version = "1", optional = true } +base64 = { version = "0.22", optional = true } +hex = { version = "0.4", optional = true } +log = "0.4.26" [dev-dependencies] -tokio = { version = "1.44", features = ["macros"] } +fs2 = "0.4.3" +tokio = { version = "1.44", features = ["macros", "rt"] } diff --git a/thoth-api/migrations/0.0.0_diesel_initial_setup/down.sql b/thoth-api/migrations/0.0.0_diesel_initial_setup/down.sql deleted file mode 100644 index a9f52609..00000000 --- a/thoth-api/migrations/0.0.0_diesel_initial_setup/down.sql +++ /dev/null @@ -1,6 +0,0 @@ --- This file was automatically created by Diesel to setup helper functions --- and other internal bookkeeping. This file is safe to edit, any future --- changes will be added to existing projects as new migrations. - -DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); -DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/thoth-api/migrations/0.0.0_diesel_initial_setup/up.sql b/thoth-api/migrations/0.0.0_diesel_initial_setup/up.sql deleted file mode 100644 index 3400c7c5..00000000 --- a/thoth-api/migrations/0.0.0_diesel_initial_setup/up.sql +++ /dev/null @@ -1,37 +0,0 @@ --- This file was automatically created by Diesel to setup helper functions --- and other internal bookkeeping. This file is safe to edit, any future --- changes will be added to existing projects as new migrations. - - - - --- Sets up a trigger for the given table to automatically set a column called --- `updated_at` whenever the row is modified (unless `updated_at` was included --- in the modified columns) --- --- # Example --- --- ```sql --- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); --- --- SELECT diesel_manage_updated_at('users'); --- ``` -CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ -BEGIN - EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s - FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD AND - NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at - ) THEN - NEW.updated_at := current_timestamp; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - diff --git a/thoth-api/migrations/0.1.0/down.sql b/thoth-api/migrations/0.1.0/down.sql deleted file mode 100644 index c833fe95..00000000 --- a/thoth-api/migrations/0.1.0/down.sql +++ /dev/null @@ -1,32 +0,0 @@ -DROP TABLE IF EXISTS funding; -DROP TABLE IF EXISTS funder; - -DROP TABLE IF EXISTS subject; -DROP TYPE IF EXISTS subject_type; - -DROP TABLE IF EXISTS price; -DROP TYPE IF EXISTS currency_code; - -DROP TABLE IF EXISTS publication; -DROP TYPE IF EXISTS publication_type; - -DROP TABLE IF EXISTS contribution; -DROP TYPE IF EXISTS contribution_type; -DROP TABLE IF EXISTS contributor; - -DROP TABLE IF EXISTS issue; -DROP TABLE IF EXISTS series; -DROP TYPE IF EXISTS series_type; - -DROP TABLE IF EXISTS language; -DROP TYPE IF EXISTS language_code; -DROP TYPE IF EXISTS language_relation; - -DROP TABLE IF EXISTS work; -DROP TYPE IF EXISTS work_type; -DROP TYPE IF EXISTS work_status; - -DROP TABLE IF EXISTS imprint; -DROP TABLE IF EXISTS publisher; - -DROP EXTENSION IF EXISTS "uuid-ossp"; diff --git a/thoth-api/migrations/0.1.0/up.sql b/thoth-api/migrations/0.1.0/up.sql deleted file mode 100644 index b9e56883..00000000 --- a/thoth-api/migrations/0.1.0/up.sql +++ /dev/null @@ -1,1037 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --------------------- Publisher -CREATE TABLE publisher ( - publisher_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - publisher_name TEXT NOT NULL CHECK (octet_length(publisher_name) >= 1), - publisher_shortname TEXT CHECK (octet_length(publisher_shortname) >= 1), - publisher_url TEXT CHECK (publisher_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)') -); --- case-insensitive UNIQ index on publisher_name -CREATE UNIQUE INDEX publisher_uniq_idx ON publisher(lower(publisher_name)); - -CREATE TABLE imprint ( - imprint_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - publisher_id UUID NOT NULL REFERENCES publisher(publisher_id) ON DELETE CASCADE, - imprint_name TEXT NOT NULL CHECK (octet_length(imprint_name) >= 1), - imprint_url TEXT CHECK (imprint_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)') -); --- case-insensitive UNIQ index on imprint_name -CREATE UNIQUE INDEX imprint_uniq_idx ON imprint(lower(imprint_name)); - --------------------- Work - -CREATE TYPE work_type AS ENUM ( - 'book-chapter', - 'monograph', - 'edited-book', - 'textbook', - 'journal-issue', - 'book-set' -); - --- ONIX Publishing status https://onix-codelists.io/codelist/64 -CREATE TYPE work_status AS ENUM ( - 'unspecified', - 'cancelled', - 'forthcoming', - 'postponed-indefinitely', - 'active', - 'no-longer-our-product', - 'out-of-stock-indefinitely', - 'out-of-print', - 'inactive', - 'unknown', - 'remaindered', - 'withdrawn-from-sale', - 'recalled' -); - -CREATE TABLE work ( - work_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - work_type work_type NOT NULL, - work_status work_status NOT NULL, - full_title TEXT NOT NULL CHECK (octet_length(full_title) >= 1), - title TEXT NOT NULL CHECK (octet_length(title) >= 1), - subtitle TEXT CHECK (octet_length(subtitle) >= 1), - reference TEXT CHECK (octet_length(reference) >= 1), - edition INTEGER NOT NULL CHECK (edition > 0), - imprint_id UUID NOT NULL REFERENCES imprint(imprint_id) ON DELETE CASCADE, - doi TEXT CHECK (doi ~* 'https:\/\/doi.org\/10.\d{4,9}\/[-._\;\(\)\/:a-zA-Z0-9]+$'), - publication_date DATE, - place TEXT CHECK (octet_length(reference) >= 1), - width INTEGER CHECK (width > 0), - height INTEGER CHECK (height > 0), - page_count INTEGER CHECK (page_count > 0), - page_breakdown TEXT CHECK(octet_length(page_breakdown) >=1), - image_count INTEGER CHECK (image_count >= 0), - table_count INTEGER CHECK (table_count >= 0), - audio_count INTEGER CHECK (audio_count >= 0), - video_count INTEGER CHECK (video_count >= 0), - license TEXT CHECK (license ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'), - copyright_holder TEXT NOT NULL CHECK (octet_length(copyright_holder) >= 1), - landing_page TEXT CHECK (landing_page ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'), - lccn TEXT CHECK (octet_length(lccn) >= 1), - oclc TEXT CHECK (octet_length(oclc) >= 1), - short_abstract TEXT CHECK (octet_length(short_abstract) >= 1), - long_abstract TEXT CHECK (octet_length(long_abstract) >= 1), - general_note TEXT CHECK (octet_length(general_note) >= 1), - toc TEXT CHECK (octet_length(toc) >= 1), - cover_url TEXT CHECK (cover_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'), - cover_caption TEXT CHECK (octet_length(cover_caption) >= 1) -); --- case-insensitive UNIQ index on doi -CREATE UNIQUE INDEX doi_uniq_idx ON work(lower(doi)); - --------------------- Language - -CREATE TYPE language_relation AS ENUM ( - 'original', - 'translated-from', - 'translated-into' -); - -CREATE TYPE language_code AS ENUM ( - 'aar', - 'abk', - 'ace', - 'ach', - 'ada', - 'ady', - 'afa', - 'afh', - 'afr', - 'ain', - 'aka', - 'akk', - 'alb', - 'ale', - 'alg', - 'alt', - 'amh', - 'ang', - 'anp', - 'apa', - 'ara', - 'arc', - 'arg', - 'arm', - 'arn', - 'arp', - 'art', - 'arw', - 'asm', - 'ast', - 'ath', - 'aus', - 'ava', - 'ave', - 'awa', - 'aym', - 'aze', - 'bad', - 'bai', - 'bak', - 'bal', - 'bam', - 'ban', - 'baq', - 'bas', - 'bat', - 'bej', - 'bel', - 'bem', - 'ben', - 'ber', - 'bho', - 'bih', - 'bik', - 'bin', - 'bis', - 'bla', - 'bnt', - 'bos', - 'bra', - 'bre', - 'btk', - 'bua', - 'bug', - 'bul', - 'bur', - 'byn', - 'cad', - 'cai', - 'car', - 'cat', - 'cau', - 'ceb', - 'cel', - 'cha', - 'chb', - 'che', - 'chg', - 'chi', - 'chk', - 'chm', - 'chn', - 'cho', - 'chp', - 'chr', - 'chu', - 'chv', - 'chy', - 'cmc', - 'cnr', - 'cop', - 'cor', - 'cos', - 'cpe', - 'cpf', - 'cpp', - 'cre', - 'crh', - 'crp', - 'csb', - 'cus', - 'cze', - 'dak', - 'dan', - 'dar', - 'day', - 'del', - 'den', - 'dgr', - 'din', - 'div', - 'doi', - 'dra', - 'dsb', - 'dua', - 'dum', - 'dut', - 'dyu', - 'dzo', - 'efi', - 'egy', - 'eka', - 'elx', - 'eng', - 'enm', - 'epo', - 'est', - 'ewe', - 'ewo', - 'fan', - 'fao', - 'fat', - 'fij', - 'fil', - 'fin', - 'fiu', - 'fon', - 'fre', - 'frm', - 'fro', - 'frr', - 'frs', - 'fry', - 'ful', - 'fur', - 'gaa', - 'gay', - 'gba', - 'gem', - 'geo', - 'ger', - 'gez', - 'gil', - 'gla', - 'gle', - 'glg', - 'glv', - 'gmh', - 'goh', - 'gon', - 'gor', - 'got', - 'grb', - 'grc', - 'gre', - 'grn', - 'gsw', - 'guj', - 'gwi', - 'hai', - 'hat', - 'hau', - 'haw', - 'heb', - 'her', - 'hil', - 'him', - 'hin', - 'hit', - 'hmn', - 'hmo', - 'hrv', - 'hsb', - 'hun', - 'hup', - 'iba', - 'ibo', - 'ice', - 'ido', - 'iii', - 'ijo', - 'iku', - 'ile', - 'ilo', - 'ina', - 'inc', - 'ind', - 'ine', - 'inh', - 'ipk', - 'ira', - 'iro', - 'ita', - 'jav', - 'jbo', - 'jpn', - 'jpr', - 'jrb', - 'kaa', - 'kab', - 'kac', - 'kal', - 'kam', - 'kan', - 'kar', - 'kas', - 'kau', - 'kaw', - 'kaz', - 'kbd', - 'kha', - 'khi', - 'khm', - 'kho', - 'kik', - 'kin', - 'kir', - 'kmb', - 'kok', - 'kom', - 'kon', - 'kor', - 'kos', - 'kpe', - 'krc', - 'krl', - 'kro', - 'kru', - 'kua', - 'kum', - 'kur', - 'kut', - 'lad', - 'lah', - 'lam', - 'lao', - 'lat', - 'lav', - 'lez', - 'lim', - 'lin', - 'lit', - 'lol', - 'loz', - 'ltz', - 'lua', - 'lub', - 'lug', - 'lui', - 'lun', - 'luo', - 'lus', - 'mac', - 'mad', - 'mag', - 'mah', - 'mai', - 'mak', - 'mal', - 'man', - 'mao', - 'map', - 'mar', - 'mas', - 'may', - 'mdf', - 'mdr', - 'men', - 'mga', - 'mic', - 'min', - 'mis', - 'mkh', - 'mlg', - 'mlt', - 'mnc', - 'mni', - 'mno', - 'moh', - 'mon', - 'mos', - 'mul', - 'mun', - 'mus', - 'mwl', - 'mwr', - 'myn', - 'myv', - 'nah', - 'nai', - 'nap', - 'nau', - 'nav', - 'nbl', - 'nde', - 'ndo', - 'nds', - 'nep', - 'new', - 'nia', - 'nic', - 'niu', - 'nno', - 'nob', - 'nog', - 'non', - 'nor', - 'nqo', - 'nso', - 'nub', - 'nwc', - 'nya', - 'nym', - 'nyn', - 'nyo', - 'nzi', - 'oci', - 'oji', - 'ori', - 'orm', - 'osa', - 'oss', - 'ota', - 'oto', - 'paa', - 'pag', - 'pal', - 'pam', - 'pan', - 'pap', - 'pau', - 'peo', - 'per', - 'phi', - 'phn', - 'pli', - 'pol', - 'pon', - 'por', - 'pra', - 'pro', - 'pus', - 'qaa', - 'que', - 'raj', - 'rap', - 'rar', - 'roa', - 'roh', - 'rom', - 'rum', - 'run', - 'rup', - 'rus', - 'sad', - 'sag', - 'sah', - 'sai', - 'sal', - 'sam', - 'san', - 'sas', - 'sat', - 'scn', - 'sco', - 'sel', - 'sem', - 'sga', - 'sgn', - 'shn', - 'sid', - 'sin', - 'sio', - 'sit', - 'sla', - 'slo', - 'slv', - 'sma', - 'sme', - 'smi', - 'smj', - 'smn', - 'smo', - 'sms', - 'sna', - 'snd', - 'snk', - 'sog', - 'som', - 'son', - 'sot', - 'spa', - 'srd', - 'srn', - 'srp', - 'srr', - 'ssa', - 'ssw', - 'suk', - 'sun', - 'sus', - 'sux', - 'swa', - 'swe', - 'syc', - 'syr', - 'tah', - 'tai', - 'tam', - 'tat', - 'tel', - 'tem', - 'ter', - 'tet', - 'tgk', - 'tgl', - 'tha', - 'tib', - 'tig', - 'tir', - 'tiv', - 'tkl', - 'tlh', - 'tli', - 'tmh', - 'tog', - 'ton', - 'tpi', - 'tsi', - 'tsn', - 'tso', - 'tuk', - 'tum', - 'tup', - 'tur', - 'tut', - 'tvl', - 'twi', - 'tyv', - 'udm', - 'uga', - 'uig', - 'ukr', - 'umb', - 'und', - 'urd', - 'uzb', - 'vai', - 'ven', - 'vie', - 'vol', - 'vot', - 'wak', - 'wal', - 'war', - 'was', - 'wel', - 'wen', - 'wln', - 'wol', - 'xal', - 'xho', - 'yao', - 'yap', - 'yid', - 'yor', - 'ypk', - 'zap', - 'zbl', - 'zen', - 'zgh', - 'zha', - 'znd', - 'zul', - 'zun', - 'zxx', - 'zza' -); - -CREATE TABLE language ( - language_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, - language_code language_code NOT NULL, - language_relation language_relation NOT NULL, - main_language BOOLEAN NOT NULL DEFAULT False -); - --- UNIQ index on combination of language and work -CREATE UNIQUE INDEX language_uniq_work_idx ON language(work_id, language_code); - --------------------- Series - -CREATE TYPE series_type AS ENUM ( - 'journal', - 'book-series' -); - -CREATE TABLE series ( - series_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - series_type series_type NOT NULL, - series_name TEXT NOT NULL CHECK (octet_length(series_name) >= 1), - issn_print TEXT NOT NULL CHECK (issn_print ~* '\d{4}\-\d{3}(\d|X)'), - issn_digital TEXT NOT NULL CHECK (issn_digital ~* '\d{4}\-\d{3}(\d|X)'), - series_url TEXT CHECK (series_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'), - imprint_id UUID NOT NULL REFERENCES imprint(imprint_id) ON DELETE CASCADE -); - --- UNIQ index on ISSNs -CREATE UNIQUE INDEX series_issn_print_idx ON series(issn_print); -CREATE UNIQUE INDEX series_issn_digital_idx ON series(issn_digital); - -CREATE TABLE issue ( - series_id UUID NOT NULL REFERENCES series(series_id) ON DELETE CASCADE, - work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, - issue_ordinal INTEGER NOT NULL CHECK (issue_ordinal > 0), - PRIMARY KEY (series_id, work_id) -); - --- UNIQ index on issue_ordinal and series_id -CREATE UNIQUE INDEX issue_uniq_ord_in_series_idx ON issue(series_id, issue_ordinal); - --------------------- Contributor - -CREATE TABLE contributor ( - contributor_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - first_name TEXT CHECK (octet_length(first_name) >= 1), - last_name TEXT NOT NULL CHECK (octet_length(last_name) >= 1), - full_name TEXT NOT NULL CHECK (octet_length(full_name) >= 1), - orcid TEXT CHECK (orcid ~* '0000-000(1-[5-9]|2-[0-9]|3-[0-4])\d{3}-\d{3}[\dX]'), - website TEXT CHECK (octet_length(website) >= 1) -); --- case-insensitive UNIQ index on orcid -CREATE UNIQUE INDEX orcid_uniq_idx ON contributor(lower(orcid)); - -CREATE TYPE contribution_type AS ENUM ( - 'author', - 'editor', - 'translator', - 'photographer', - 'ilustrator', - 'music-editor', - 'foreword-by', - 'introduction-by', - 'afterword-by', - 'preface-by' -); - -CREATE TABLE contribution ( - work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, - contributor_id UUID NOT NULL REFERENCES contributor(contributor_id) ON DELETE CASCADE, - contribution_type contribution_type NOT NULL, - main_contribution BOOLEAN NOT NULL DEFAULT False, - biography TEXT CHECK (octet_length(biography) >= 1), - institution TEXT CHECK (octet_length(institution) >= 1), - PRIMARY KEY (work_id, contributor_id, contribution_type) -); - --------------------- Publication - -CREATE TYPE publication_type AS ENUM ( - 'Paperback', - 'Hardback', - 'PDF', - 'HTML', - 'XML', - 'Epub', - 'Mobi' -); - -CREATE TABLE publication ( - publication_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - publication_type publication_type NOT NULL, - work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, - isbn TEXT CHECK (octet_length(isbn) = 17), - publication_url TEXT CHECK (publication_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)') -); - -CREATE INDEX publication_isbn_idx ON publication(isbn); - - --------------------- Price - -CREATE TYPE currency_code AS ENUM ( - 'adp', - 'aed', - 'afa', - 'afn', - 'alk', - 'all', - 'amd', - 'ang', - 'aoa', - 'aok', - 'aon', - 'aor', - 'ara', - 'arp', - 'ars', - 'ary', - 'ats', - 'aud', - 'awg', - 'aym', - 'azm', - 'azn', - 'bad', - 'bam', - 'bbd', - 'bdt', - 'bec', - 'bef', - 'bel', - 'bgj', - 'bgk', - 'bgl', - 'bgn', - 'bhd', - 'bif', - 'bmd', - 'bnd', - 'bob', - 'bop', - 'bov', - 'brb', - 'brc', - 'bre', - 'brl', - 'brn', - 'brr', - 'bsd', - 'btn', - 'buk', - 'bwp', - 'byb', - 'byn', - 'byr', - 'bzd', - 'cad', - 'cdf', - 'chc', - 'che', - 'chf', - 'chw', - 'clf', - 'clp', - 'cny', - 'cop', - 'cou', - 'crc', - 'csd', - 'csj', - 'csk', - 'cuc', - 'cup', - 'cve', - 'cyp', - 'czk', - 'ddm', - 'dem', - 'djf', - 'dkk', - 'dop', - 'dzd', - 'ecs', - 'ecv', - 'eek', - 'egp', - 'ern', - 'esa', - 'esb', - 'esp', - 'etb', - 'eur', - 'fim', - 'fjd', - 'fkp', - 'frf', - 'gbp', - 'gek', - 'gel', - 'ghc', - 'ghp', - 'ghs', - 'gip', - 'gmd', - 'gne', - 'gnf', - 'gns', - 'gqe', - 'grd', - 'gtq', - 'gwe', - 'gwp', - 'gyd', - 'hkd', - 'hnl', - 'hrd', - 'hrk', - 'htg', - 'huf', - 'idr', - 'iep', - 'ilp', - 'ilr', - 'ils', - 'inr', - 'iqd', - 'irr', - 'isj', - 'isk', - 'itl', - 'jmd', - 'jod', - 'jpy', - 'kes', - 'kgs', - 'khr', - 'kmf', - 'kpw', - 'krw', - 'kwd', - 'kyd', - 'kzt', - 'laj', - 'lak', - 'lbp', - 'lkr', - 'lrd', - 'lsl', - 'lsm', - 'ltl', - 'ltt', - 'luc', - 'luf', - 'lul', - 'lvl', - 'lvr', - 'lyd', - 'mad', - 'mdl', - 'mga', - 'mgf', - 'mkd', - 'mlf', - 'mmk', - 'mnt', - 'mop', - 'mro', - 'mru', - 'mtl', - 'mtp', - 'mur', - 'mvq', - 'mvr', - 'mwk', - 'mxn', - 'mxp', - 'mxv', - 'myr', - 'mze', - 'mzm', - 'mzn', - 'nad', - 'ngn', - 'nic', - 'nio', - 'nlg', - 'nok', - 'npr', - 'nzd', - 'omr', - 'pab', - 'peh', - 'pei', - 'pen', - 'pes', - 'pgk', - 'php', - 'pkr', - 'pln', - 'plz', - 'pte', - 'pyg', - 'qar', - 'rhd', - 'rok', - 'rol', - 'ron', - 'rsd', - 'rub', - 'rur', - 'rwf', - 'sar', - 'sbd', - 'scr', - 'sdd', - 'sdg', - 'sdp', - 'sek', - 'sgd', - 'shp', - 'sit', - 'skk', - 'sll', - 'sos', - 'srd', - 'srg', - 'ssp', - 'std', - 'stn', - 'sur', - 'svc', - 'syp', - 'szl', - 'thb', - 'tjr', - 'tjs', - 'tmm', - 'tmt', - 'tnd', - 'top', - 'tpe', - 'trl', - 'try', - 'ttd', - 'twd', - 'tzs', - 'uah', - 'uak', - 'ugs', - 'ugw', - 'ugx', - 'usd', - 'usn', - 'uss', - 'uyi', - 'uyn', - 'uyp', - 'uyu', - 'uyw', - 'uzs', - 'veb', - 'vef', - 'ves', - 'vnc', - 'vnd', - 'vuv', - 'wst', - 'xaf', - 'xag', - 'xau', - 'xba', - 'xbb', - 'xbc', - 'xbd', - 'xcd', - 'xdr', - 'xeu', - 'xfo', - 'xfu', - 'xof', - 'xpd', - 'xpf', - 'xpt', - 'xre', - 'xsu', - 'xts', - 'xua', - 'xxx', - 'ydd', - 'yer', - 'yud', - 'yum', - 'yun', - 'zal', - 'zar', - 'zmk', - 'zmw', - 'zrn', - 'zrz', - 'zwc', - 'zwd', - 'zwl', - 'zwn', - 'zwr' -); - -CREATE TABLE price ( - price_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - publication_id UUID NOT NULL REFERENCES publication(publication_id) ON DELETE CASCADE, - currency_code currency_code NOT NULL, - unit_price double precision NOT NULL -); - --------------------- Subject - -CREATE TYPE subject_type AS ENUM ( - 'bic', - 'bisac', - 'thema', - 'lcc', - 'custom', - 'keyword' -); - -CREATE TABLE subject ( - subject_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, - subject_type subject_type NOT NULL, - subject_code TEXT NOT NULL CHECK (octet_length(subject_code) >= 1), - subject_ordinal INTEGER NOT NULL CHECK (subject_ordinal > 0) -); - --------------------- Funder - -CREATE TABLE funder ( - funder_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - funder_name TEXT NOT NULL CHECK (octet_length(funder_name) >= 1), - funder_doi TEXT CHECK (funder_doi ~* 'https:\/\/doi.org\/10.\d{4,9}\/[-._\;\(\)\/:a-zA-Z0-9]+$') -); --- case-insensitive UNIQ index on funder_doi -CREATE UNIQUE INDEX funder_doi_uniq_idx ON funder(lower(funder_doi)); - -CREATE TABLE funding ( - funding_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, - funder_id UUID NOT NULL REFERENCES funder(funder_id) ON DELETE CASCADE, - program TEXT CHECK (octet_length(program) >= 1), - project_name TEXT CHECK (octet_length(project_name) >= 1), - project_shortname TEXT CHECK (octet_length(project_shortname) >= 1), - grant_number TEXT CHECK (octet_length(grant_number) >= 1), - jurisdiction TEXT CHECK (octet_length(jurisdiction) >= 1) -); diff --git a/thoth-api/migrations/0.10.0/down.sql b/thoth-api/migrations/0.10.0/down.sql deleted file mode 100644 index e581cc31..00000000 --- a/thoth-api/migrations/0.10.0/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE work - DROP COLUMN IF EXISTS bibliography_note; diff --git a/thoth-api/migrations/0.10.0/up.sql b/thoth-api/migrations/0.10.0/up.sql deleted file mode 100644 index 6e138725..00000000 --- a/thoth-api/migrations/0.10.0/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE work - ADD COLUMN IF NOT EXISTS bibliography_note TEXT CHECK (octet_length(bibliography_note) >= 1); diff --git a/thoth-api/migrations/0.11.3/down.sql b/thoth-api/migrations/0.11.3/down.sql deleted file mode 100644 index 6d21b774..00000000 --- a/thoth-api/migrations/0.11.3/down.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Reinstate earlier version of ORCID validation - -ALTER TABLE contributor - DROP CONSTRAINT contributor_orcid_check, - ADD CONSTRAINT contributor_orcid_check - CHECK (orcid ~ '^https:\/\/orcid\.org\/0000-000(1-[5-9]|2-[0-9]|3-[0-4])\d{3}-\d{3}[\dX]$'); diff --git a/thoth-api/migrations/0.11.3/up.sql b/thoth-api/migrations/0.11.3/up.sql deleted file mode 100644 index e3662ebc..00000000 --- a/thoth-api/migrations/0.11.3/up.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Make ORCID validation more permissive as the docs don't specify a strict pattern --- Should be kept in line with Orcid::FromStr, although regex syntax differs slightly - -ALTER TABLE contributor - DROP CONSTRAINT contributor_orcid_check, - ADD CONSTRAINT contributor_orcid_check - CHECK (orcid ~ '^https:\/\/orcid\.org\/\d{4}-\d{4}-\d{4}-\d{3}[\dX]$'); diff --git a/thoth-api/migrations/0.2.0/down.sql b/thoth-api/migrations/0.2.0/down.sql deleted file mode 100644 index 5dfb76bd..00000000 --- a/thoth-api/migrations/0.2.0/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE account; diff --git a/thoth-api/migrations/0.2.0/up.sql b/thoth-api/migrations/0.2.0/up.sql deleted file mode 100644 index 908a6c80..00000000 --- a/thoth-api/migrations/0.2.0/up.sql +++ /dev/null @@ -1,17 +0,0 @@ --------------------- Account -CREATE TABLE account ( - account_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name TEXT NOT NULL CHECK (octet_length(name) >= 1), - surname TEXT NOT NULL CHECK (octet_length(surname) >= 1), - email TEXT NOT NULL CHECK (octet_length(email) >= 1), - hash BYTEA NOT NULL, - salt TEXT NOT NULL CHECK (octet_length(salt) >= 1), - is_admin BOOLEAN NOT NULL DEFAULT False, - is_bot BOOLEAN NOT NULL DEFAULT False, - is_active BOOLEAN NOT NULL DEFAULT True, - registered TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, - token TEXT NULL CHECK (OCTET_LENGTH(token) >= 1) -); - --- case-insensitive UNIQ index on email -CREATE UNIQUE INDEX email_uniq_idx ON account(lower(email)); diff --git a/thoth-api/migrations/0.2.11/down.sql b/thoth-api/migrations/0.2.11/down.sql deleted file mode 100644 index c2456835..00000000 --- a/thoth-api/migrations/0.2.11/down.sql +++ /dev/null @@ -1,73 +0,0 @@ -DROP TRIGGER set_updated_at ON publisher; -ALTER TABLE publisher - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON imprint; -ALTER TABLE imprint - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON work; -ALTER TABLE work - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON language; -ALTER TABLE language - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON series; -ALTER TABLE series - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON issue; -ALTER TABLE issue - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON contributor; -ALTER TABLE contributor - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON contribution; -ALTER TABLE contribution - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON publication; -ALTER TABLE publication - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON price; -ALTER TABLE price - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON subject; -ALTER TABLE subject - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON funder; -ALTER TABLE funder - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON funding; -ALTER TABLE funding - DROP COLUMN created_at, - DROP COLUMN updated_at; - -DROP TRIGGER set_updated_at ON account; -ALTER TABLE account - RENAME COLUMN created_at TO registered; -ALTER TABLE account - ALTER COLUMN registered TYPE TIMESTAMP WITH TIME ZONE, - ALTER COLUMN registered SET NOT NULL, - ALTER COLUMN registered SET DEFAULT now(), - DROP COLUMN updated_at; diff --git a/thoth-api/migrations/0.2.11/up.sql b/thoth-api/migrations/0.2.11/up.sql deleted file mode 100644 index 7e5116f5..00000000 --- a/thoth-api/migrations/0.2.11/up.sql +++ /dev/null @@ -1,73 +0,0 @@ -ALTER TABLE publisher - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('publisher'); - -ALTER TABLE imprint - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('imprint'); - -ALTER TABLE work - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('work'); - -ALTER TABLE language - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('language'); - -ALTER TABLE series - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('series'); - -ALTER TABLE issue - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('issue'); - -ALTER TABLE contributor - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('contributor'); - -ALTER TABLE contribution - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('contribution'); - -ALTER TABLE publication - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('publication'); - -ALTER TABLE price - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('price'); - -ALTER TABLE subject - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('subject'); - -ALTER TABLE funder - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('funder'); - -ALTER TABLE funding - ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('funding'); - -ALTER TABLE account - RENAME COLUMN registered TO created_at; -ALTER TABLE account - ALTER COLUMN created_at TYPE TIMESTAMP, - ALTER COLUMN created_at SET NOT NULL, - ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP, - ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; -SELECT diesel_manage_updated_at('account'); diff --git a/thoth-api/migrations/0.3.0/down.sql b/thoth-api/migrations/0.3.0/down.sql deleted file mode 100644 index 03723c6d..00000000 --- a/thoth-api/migrations/0.3.0/down.sql +++ /dev/null @@ -1,23 +0,0 @@ -DROP TRIGGER set_updated_at ON publisher_account; -DROP TABLE publisher_account; - -ALTER TABLE account RENAME COLUMN is_superuser TO is_admin; - -ALTER TABLE contribution - DROP COLUMN first_name, - DROP COLUMN last_name, - DROP COLUMN full_name; - -DROP TABLE publisher_history; -DROP TABLE imprint_history; -DROP TABLE work_history; -DROP TABLE language_history; -DROP TABLE series_history; -DROP TABLE issue_history; -DROP TABLE contributor_history; -DROP TABLE contribution_history; -DROP TABLE publication_history; -DROP TABLE price_history; -DROP TABLE subject_history; -DROP TABLE funder_history; -DROP TABLE funding_history; diff --git a/thoth-api/migrations/0.3.0/up.sql b/thoth-api/migrations/0.3.0/up.sql deleted file mode 100644 index c63c43a4..00000000 --- a/thoth-api/migrations/0.3.0/up.sql +++ /dev/null @@ -1,139 +0,0 @@ -CREATE TABLE publisher_account ( - account_id UUID NOT NULL REFERENCES account(account_id) ON DELETE CASCADE, - publisher_id UUID NOT NULL REFERENCES publisher(publisher_id) ON DELETE CASCADE, - is_admin BOOLEAN NOT NULL DEFAULT False, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (account_id, publisher_id) -); -SELECT diesel_manage_updated_at('publisher_account'); - -ALTER TABLE account RENAME COLUMN is_admin TO is_superuser; - -ALTER TABLE contribution - ADD COLUMN first_name TEXT, - ADD COLUMN last_name TEXT, - ADD COLUMN full_name TEXT; - -UPDATE contribution - SET first_name = contributor.first_name, - last_name = contributor.last_name, - full_name = contributor.full_name - FROM contributor - WHERE contribution.contributor_id = contributor.contributor_id; - -ALTER TABLE contribution - ALTER COLUMN last_name SET NOT NULL, - ALTER COLUMN full_name SET NOT NULL, - ADD CONSTRAINT contribution_first_name_check CHECK (octet_length(first_name) >= 1), - ADD CONSTRAINT contribution_last_name_check CHECK (octet_length(last_name) >= 1), - ADD CONSTRAINT contribution_full_name_check CHECK (octet_length(full_name) >= 1); - -CREATE TABLE publisher_history ( - publisher_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - publisher_id UUID NOT NULL REFERENCES publisher(publisher_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE imprint_history ( - imprint_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - imprint_id UUID NOT NULL REFERENCES imprint(imprint_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE work_history ( - work_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE language_history ( - language_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - language_id UUID NOT NULL REFERENCES language(language_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE series_history ( - series_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - series_id UUID NOT NULL REFERENCES series(series_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE issue_history ( - issue_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - series_id UUID NOT NULL, - work_id UUID NOT NULL, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (series_id, work_id) REFERENCES issue(series_id, work_id) ON DELETE CASCADE -); - -CREATE TABLE contributor_history ( - contributor_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - contributor_id UUID NOT NULL REFERENCES contributor(contributor_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE contribution_history ( - contribution_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - work_id UUID NOT NULL, - contributor_id UUID NOT NULL, - contribution_type contribution_type NOT NULL, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (work_id, contributor_id, contribution_type) REFERENCES contribution(work_id, contributor_id, contribution_type) ON DELETE CASCADE -); - -CREATE TABLE publication_history ( - publication_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - publication_id UUID NOT NULL REFERENCES publication(publication_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE price_history ( - price_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - price_id UUID NOT NULL REFERENCES price(price_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE subject_history ( - subject_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - subject_id UUID NOT NULL REFERENCES subject(subject_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE funder_history ( - funder_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - funder_id UUID NOT NULL REFERENCES funder(funder_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE funding_history ( - funding_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - funding_id UUID NOT NULL REFERENCES funding(funding_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/thoth-api/migrations/0.3.5/down.sql b/thoth-api/migrations/0.3.5/down.sql deleted file mode 100644 index 0399274b..00000000 --- a/thoth-api/migrations/0.3.5/down.sql +++ /dev/null @@ -1,60 +0,0 @@ --- Convert Issue table to use composite key instead of single primary key - -ALTER TABLE issue_history - ADD COLUMN series_id UUID, - ADD COLUMN work_id UUID; - -UPDATE issue_history - SET series_id = issue.series_id, - work_id = issue.work_id - FROM issue - WHERE issue_history.issue_id = issue.issue_id; - -ALTER TABLE issue_history - DROP COLUMN issue_id, - ALTER COLUMN series_id SET NOT NULL, - ALTER COLUMN work_id SET NOT NULL; - -ALTER TABLE issue - DROP COLUMN issue_id, - ADD PRIMARY KEY (series_id, work_id), - -- Remove the manually-added constraint which will now be enforced by the composite key - DROP CONSTRAINT issue_series_id_work_id_uniq; - -ALTER TABLE issue_history - ADD CONSTRAINT issue_history_series_id_work_id_fkey - FOREIGN KEY (series_id, work_id) - REFERENCES issue(series_id, work_id) - ON DELETE CASCADE; - --- Convert Contribution table to use composite key instead of single primary key - -ALTER TABLE contribution_history - ADD COLUMN work_id UUID, - ADD COLUMN contributor_id UUID, - ADD COLUMN contribution_type contribution_type; - -UPDATE contribution_history - SET work_id = contribution.work_id, - contributor_id = contribution.contributor_id, - contribution_type = contribution.contribution_type - FROM contribution - WHERE contribution_history.contribution_id = contribution.contribution_id; - -ALTER TABLE contribution_history - DROP COLUMN contribution_id, - ALTER COLUMN work_id SET NOT NULL, - ALTER COLUMN contributor_id SET NOT NULL, - ALTER COLUMN contribution_type SET NOT NULL; - -ALTER TABLE contribution - DROP COLUMN contribution_id, - ADD PRIMARY KEY (work_id, contributor_id, contribution_type), - -- Remove the manually-added constraint which will now be enforced by the composite key - DROP CONSTRAINT contribution_work_id_contributor_id_contribution_type_uniq; - -ALTER TABLE contribution_history - ADD CONSTRAINT contribution_history_work_id_contributor_id_contribution_t_fkey - FOREIGN KEY (work_id, contributor_id, contribution_type) - REFERENCES contribution(work_id, contributor_id, contribution_type) - ON DELETE CASCADE; diff --git a/thoth-api/migrations/0.3.5/up.sql b/thoth-api/migrations/0.3.5/up.sql deleted file mode 100644 index f1e3a9aa..00000000 --- a/thoth-api/migrations/0.3.5/up.sql +++ /dev/null @@ -1,63 +0,0 @@ --- Convert Issue table to use single primary key instead of composite key - -ALTER TABLE issue - ADD COLUMN issue_id UUID NOT NULL DEFAULT uuid_generate_v4(); - -ALTER TABLE issue_history - ADD COLUMN issue_id UUID; - -UPDATE issue_history - SET issue_id = issue.issue_id - FROM issue - WHERE issue_history.series_id = issue.series_id - AND issue_history.work_id = issue.work_id; - -ALTER TABLE issue_history - DROP COLUMN series_id, - DROP COLUMN work_id, - ALTER COLUMN issue_id SET NOT NULL; - -ALTER TABLE issue - DROP CONSTRAINT issue_pkey, - ADD PRIMARY KEY (issue_id), - -- Retain the data constraint originally enforced by the composite key - ADD CONSTRAINT issue_series_id_work_id_uniq UNIQUE (series_id, work_id); - -ALTER TABLE issue_history - ADD CONSTRAINT issue_history_issue_id_fkey - FOREIGN KEY (issue_id) - REFERENCES issue(issue_id) - ON DELETE CASCADE; - --- Convert Contribution table to use single primary key instead of composite key - -ALTER TABLE contribution - ADD COLUMN contribution_id UUID NOT NULL DEFAULT uuid_generate_v4(); - -ALTER TABLE contribution_history - ADD COLUMN contribution_id UUID; - -UPDATE contribution_history - SET contribution_id = contribution.contribution_id - FROM contribution - WHERE contribution_history.work_id = contribution.work_id - AND contribution_history.contributor_id = contribution.contributor_id - AND contribution_history.contribution_type = contribution.contribution_type; - -ALTER TABLE contribution_history - DROP COLUMN work_id, - DROP COLUMN contributor_id, - DROP COLUMN contribution_type, - ALTER COLUMN contribution_id SET NOT NULL; - -ALTER TABLE contribution - DROP CONSTRAINT contribution_pkey, - ADD PRIMARY KEY (contribution_id), - -- Retain the data constraint originally enforced by the composite key - ADD CONSTRAINT contribution_work_id_contributor_id_contribution_type_uniq UNIQUE (work_id, contributor_id, contribution_type); - -ALTER TABLE contribution_history - ADD CONSTRAINT contribution_history_contribution_id_fkey - FOREIGN KEY (contribution_id) - REFERENCES contribution(contribution_id) - ON DELETE CASCADE; diff --git a/thoth-api/migrations/0.4.1/down.sql b/thoth-api/migrations/0.4.1/down.sql deleted file mode 100644 index 035922c9..00000000 --- a/thoth-api/migrations/0.4.1/down.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Reinstate earlier versions of ORCID and DOI validation - -ALTER TABLE contributor - DROP CONSTRAINT contributor_orcid_check, - ADD CONSTRAINT contributor_orcid_check - CHECK (orcid ~* '0000-000(1-[5-9]|2-[0-9]|3-[0-4])\d{3}-\d{3}[\dX]'); - -ALTER TABLE work - DROP CONSTRAINT work_doi_check, - ADD CONSTRAINT work_doi_check - CHECK (doi ~* 'https:\/\/doi.org\/10.\d{4,9}\/[-._\;\(\)\/:a-zA-Z0-9]+$'); - -ALTER TABLE funder - DROP CONSTRAINT funder_funder_doi_check, - ADD CONSTRAINT funder_funder_doi_check - CHECK (funder_doi ~* 'https:\/\/doi.org\/10.\d{4,9}\/[-._\;\(\)\/:a-zA-Z0-9]+$'); diff --git a/thoth-api/migrations/0.4.1/up.sql b/thoth-api/migrations/0.4.1/up.sql deleted file mode 100644 index 2eb361b0..00000000 --- a/thoth-api/migrations/0.4.1/up.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Improve validation of ORCID identifiers (include protocol/resource name, make case-sensitive) --- Should be kept in line with Orcid::FromStr, although regex syntax differs slightly - -ALTER TABLE contributor - DROP CONSTRAINT contributor_orcid_check, - ADD CONSTRAINT contributor_orcid_check - CHECK (orcid ~ '^https:\/\/orcid\.org\/0000-000(1-[5-9]|2-[0-9]|3-[0-4])\d{3}-\d{3}[\dX]$'); - --- Improve validation of DOI identifiers (add line start marker, escape periods, make case-sensitive) --- Should be kept in line with Orcid::FromStr, although regex syntax differs slightly --- (e.g. `;()/` need to be escaped here but not in Orcid::FromStr) - -ALTER TABLE work - DROP CONSTRAINT work_doi_check, - ADD CONSTRAINT work_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\/:a-zA-Z0-9]+$'); - -ALTER TABLE funder - DROP CONSTRAINT funder_funder_doi_check, - ADD CONSTRAINT funder_funder_doi_check - CHECK (funder_doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\/:a-zA-Z0-9]+$'); diff --git a/thoth-api/migrations/0.4.2/down.sql b/thoth-api/migrations/0.4.2/down.sql deleted file mode 100644 index 6e526384..00000000 --- a/thoth-api/migrations/0.4.2/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE contribution - DROP COLUMN contribution_ordinal; \ No newline at end of file diff --git a/thoth-api/migrations/0.4.2/up.sql b/thoth-api/migrations/0.4.2/up.sql deleted file mode 100644 index 2b6a4220..00000000 --- a/thoth-api/migrations/0.4.2/up.sql +++ /dev/null @@ -1,24 +0,0 @@ -ALTER TABLE contribution - ADD COLUMN contribution_ordinal INTEGER; - --- As a default, set the `contribution_ordinal` for existing records to reflect --- the order in which they were added (within separate groups for each work). --- We should be able to find this by sorting on the `created_at` timestamp, however, --- records created prior to the introduction of `created_at` in v0.2.11 may have --- identical default values for this field. Therefore, we perform a secondary --- sort on the system column `ctid`; although this value is subject to change and --- should not be relied upon, it should give a suitable rough ordering here. -UPDATE contribution - SET contribution_ordinal = c.rownum - FROM ( - SELECT - contribution_id, - row_number() OVER (PARTITION BY work_id ORDER BY created_at,ctid) AS rownum - FROM contribution - ) c - WHERE contribution.contribution_id = c.contribution_id; - -ALTER TABLE contribution - ALTER COLUMN contribution_ordinal SET NOT NULL, - ADD CONSTRAINT contribution_contribution_ordinal_check CHECK (contribution_ordinal > 0), - ADD CONSTRAINT contribution_contribution_ordinal_work_id_uniq UNIQUE (contribution_ordinal, work_id); \ No newline at end of file diff --git a/thoth-api/migrations/0.4.5/down.sql b/thoth-api/migrations/0.4.5/down.sql deleted file mode 100644 index 8a52d7b4..00000000 --- a/thoth-api/migrations/0.4.5/down.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE work - ALTER COLUMN width TYPE INTEGER, - ALTER COLUMN height TYPE INTEGER; diff --git a/thoth-api/migrations/0.4.5/up.sql b/thoth-api/migrations/0.4.5/up.sql deleted file mode 100644 index c81d1667..00000000 --- a/thoth-api/migrations/0.4.5/up.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE work - ALTER COLUMN width TYPE double precision, - ALTER COLUMN height TYPE double precision; diff --git a/thoth-api/migrations/0.5.0/down.sql b/thoth-api/migrations/0.5.0/down.sql deleted file mode 100644 index 8b6ab3bf..00000000 --- a/thoth-api/migrations/0.5.0/down.sql +++ /dev/null @@ -1,39 +0,0 @@ -ALTER TABLE publication - DROP CONSTRAINT publication_publication_type_work_id_uniq, - ADD COLUMN publication_url TEXT CHECK (publication_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'); - --- Migrate location URLs back into publication table as far as possible before dropping location table: --- set the landing_page or full_text_url of the canonical location as the main publication_url, --- then create duplicate publications to store all other location URLs (landing page/full text). --- Note this will create multiple identical publications if the same URL is re-used across location fields. -UPDATE publication - SET publication_url = location.landing_page - FROM location - WHERE publication.publication_id = location.publication_id - AND location.canonical - AND location.landing_page IS NOT NULL; -UPDATE publication - SET publication_url = location.full_text_url - FROM location - WHERE publication.publication_id = location.publication_id - AND location.canonical - AND location.full_text_url IS NOT NULL - AND location.landing_page IS NULL; -INSERT INTO publication(publication_type, work_id, publication_url) - SELECT publication.publication_type, publication.work_id, location.landing_page FROM publication, location - WHERE publication.publication_id = location.publication_id - AND location.landing_page IS NOT NULL - AND NOT location.canonical; -INSERT INTO publication(publication_type, work_id, publication_url) - SELECT publication.publication_type, publication.work_id, location.full_text_url FROM publication, location - WHERE publication.publication_id = location.publication_id - AND location.full_text_url IS NOT NULL - AND ( - NOT location.canonical - OR (location.canonical AND location.landing_page IS NOT NULL) - ); - -DROP TABLE location_history; -DROP TRIGGER set_updated_at ON location; -DROP TABLE location; -DROP TYPE IF EXISTS location_platform; diff --git a/thoth-api/migrations/0.5.0/up.sql b/thoth-api/migrations/0.5.0/up.sql deleted file mode 100644 index 9cbb0c11..00000000 --- a/thoth-api/migrations/0.5.0/up.sql +++ /dev/null @@ -1,57 +0,0 @@ -CREATE TYPE location_platform AS ENUM ( - 'Project MUSE', - 'OAPEN', - 'DOAB', - 'JSTOR', - 'EBSCO Host', - 'OCLC KB', - 'ProQuest KB', - 'ProQuest ExLibris', - 'EBSCO KB', - 'JISC KB', - 'Other' -); - -CREATE TABLE location ( - location_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - publication_id UUID NOT NULL REFERENCES publication(publication_id) ON DELETE CASCADE, - landing_page TEXT CHECK (landing_page ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'), - full_text_url TEXT CHECK (full_text_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'), - location_platform location_platform NOT NULL DEFAULT 'Other', - canonical BOOLEAN NOT NULL DEFAULT False, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- Location must contain at least one of landing_page or full_text_url - CONSTRAINT location_url_check CHECK (landing_page IS NOT NULL OR full_text_url IS NOT NULL) -); -SELECT diesel_manage_updated_at('location'); - --- Only allow one canonical location per publication -CREATE UNIQUE INDEX location_uniq_canonical_true_idx ON location(publication_id) - WHERE canonical; - --- Only allow one instance of each platform (except 'Other') per publication -CREATE UNIQUE INDEX location_uniq_platform_idx ON location(publication_id, location_platform) - WHERE NOT location_platform = 'Other'; - -CREATE TABLE location_history ( - location_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - location_id UUID NOT NULL REFERENCES location(location_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Create location entries for every existing publication_url (assume all are landing pages) --- If a publication has locations, exactly one of them must be canonical; --- this command will create at most one location per publication, so make them all canonical. -INSERT INTO location(publication_id, landing_page, canonical) - SELECT publication_id, publication_url, True FROM publication WHERE publication_url IS NOT NULL; - -ALTER TABLE publication - -- Only allow one publication of each type per work (existing data may breach this) - -- To check for records which breach this constraint: - -- `select * from publication a where (select count(*) from publication b where a.publication_type = b.publication_type and a.work_id = b.work_id) > 1 order by work_id, publication_type;` - ADD CONSTRAINT publication_publication_type_work_id_uniq UNIQUE (publication_type, work_id), - -- Remove publication_url column (all data should have been migrated to location table above) - DROP COLUMN publication_url; diff --git a/thoth-api/migrations/0.6.0/down.sql b/thoth-api/migrations/0.6.0/down.sql deleted file mode 100644 index 293b9311..00000000 --- a/thoth-api/migrations/0.6.0/down.sql +++ /dev/null @@ -1,37 +0,0 @@ -ALTER TABLE contribution - ADD COLUMN institution TEXT CHECK (octet_length(institution) >= 1); - --- Migrate affiliation information back into contribution table as far as possible --- before dropping affiliation table. Where a contribution has multiple affiliations, --- combine the institution names into a single semicolon-separated string. -UPDATE contribution - SET institution = subquery.institutions - FROM ( - SELECT affiliation.contribution_id, string_agg(institution_name, '; ') AS institutions - FROM institution, affiliation - WHERE affiliation.institution_id = institution.institution_id - GROUP BY affiliation.contribution_id - ) AS subquery - WHERE contribution.contribution_id = subquery.contribution_id; - -ALTER TABLE institution_history RENAME COLUMN institution_history_id TO funder_history_id; -ALTER TABLE institution_history RENAME COLUMN institution_id TO funder_id; - -ALTER TABLE institution_history RENAME TO funder_history; - -ALTER TABLE institution RENAME COLUMN institution_id TO funder_id; -ALTER TABLE institution RENAME COLUMN institution_name TO funder_name; -ALTER TABLE institution RENAME COLUMN institution_doi TO funder_doi; - -ALTER TABLE institution - DROP COLUMN ror, - DROP COLUMN country_code; - -ALTER TABLE institution RENAME TO funder; - -ALTER TABLE funding RENAME COLUMN institution_id TO funder_id; - -DROP TYPE IF EXISTS country_code; - -DROP TABLE affiliation_history; -DROP TABLE affiliation; diff --git a/thoth-api/migrations/0.6.0/up.sql b/thoth-api/migrations/0.6.0/up.sql deleted file mode 100644 index 925079ce..00000000 --- a/thoth-api/migrations/0.6.0/up.sql +++ /dev/null @@ -1,307 +0,0 @@ --- Order is alphabetical by name of country (see string equivalents in API enum) -CREATE TYPE country_code AS ENUM ( - 'afg', - 'ala', - 'alb', - 'dza', - 'asm', - 'and', - 'ago', - 'aia', - 'ata', - 'atg', - 'arg', - 'arm', - 'abw', - 'aus', - 'aut', - 'aze', - 'bhs', - 'bhr', - 'bgd', - 'brb', - 'blr', - 'bel', - 'blz', - 'ben', - 'bmu', - 'btn', - 'bol', - 'bes', - 'bih', - 'bwa', - 'bvt', - 'bra', - 'iot', - 'brn', - 'bgr', - 'bfa', - 'bdi', - 'cpv', - 'khm', - 'cmr', - 'can', - 'cym', - 'caf', - 'tcd', - 'chl', - 'chn', - 'cxr', - 'cck', - 'col', - 'com', - 'cok', - 'cri', - 'civ', - 'hrv', - 'cub', - 'cuw', - 'cyp', - 'cze', - 'cod', - 'dnk', - 'dji', - 'dma', - 'dom', - 'ecu', - 'egy', - 'slv', - 'gnq', - 'eri', - 'est', - 'swz', - 'eth', - 'flk', - 'fro', - 'fji', - 'fin', - 'fra', - 'guf', - 'pyf', - 'atf', - 'gab', - 'gmb', - 'geo', - 'deu', - 'gha', - 'gib', - 'grc', - 'grl', - 'grd', - 'glp', - 'gum', - 'gtm', - 'ggy', - 'gin', - 'gnb', - 'guy', - 'hti', - 'hmd', - 'hnd', - 'hkg', - 'hun', - 'isl', - 'ind', - 'idn', - 'irn', - 'irq', - 'irl', - 'imn', - 'isr', - 'ita', - 'jam', - 'jpn', - 'jey', - 'jor', - 'kaz', - 'ken', - 'kir', - 'kwt', - 'kgz', - 'lao', - 'lva', - 'lbn', - 'lso', - 'lbr', - 'lby', - 'lie', - 'ltu', - 'lux', - 'mac', - 'mdg', - 'mwi', - 'mys', - 'mdv', - 'mli', - 'mlt', - 'mhl', - 'mtq', - 'mrt', - 'mus', - 'myt', - 'mex', - 'fsm', - 'mda', - 'mco', - 'mng', - 'mne', - 'msr', - 'mar', - 'moz', - 'mmr', - 'nam', - 'nru', - 'npl', - 'nld', - 'ncl', - 'nzl', - 'nic', - 'ner', - 'nga', - 'niu', - 'nfk', - 'prk', - 'mkd', - 'mnp', - 'nor', - 'omn', - 'pak', - 'plw', - 'pse', - 'pan', - 'png', - 'pry', - 'per', - 'phl', - 'pcn', - 'pol', - 'prt', - 'pri', - 'qat', - 'cog', - 'reu', - 'rou', - 'rus', - 'rwa', - 'blm', - 'shn', - 'kna', - 'lca', - 'maf', - 'spm', - 'vct', - 'wsm', - 'smr', - 'stp', - 'sau', - 'sen', - 'srb', - 'syc', - 'sle', - 'sgp', - 'sxm', - 'svk', - 'svn', - 'slb', - 'som', - 'zaf', - 'sgs', - 'kor', - 'ssd', - 'esp', - 'lka', - 'sdn', - 'sur', - 'sjm', - 'swe', - 'che', - 'syr', - 'twn', - 'tjk', - 'tza', - 'tha', - 'tls', - 'tgo', - 'tkl', - 'ton', - 'tto', - 'tun', - 'tur', - 'tkm', - 'tca', - 'tuv', - 'uga', - 'ukr', - 'are', - 'gbr', - 'umi', - 'usa', - 'ury', - 'uzb', - 'vut', - 'vat', - 'ven', - 'vnm', - 'vgb', - 'vir', - 'wlf', - 'esh', - 'yem', - 'zmb', - 'zwe' -); - -ALTER TABLE funder RENAME TO institution; - -ALTER TABLE institution RENAME COLUMN funder_id TO institution_id; -ALTER TABLE institution RENAME COLUMN funder_name TO institution_name; -ALTER TABLE institution RENAME COLUMN funder_doi TO institution_doi; - -ALTER TABLE institution - ADD COLUMN ror TEXT CHECK (ror ~ '^https:\/\/ror\.org\/0[a-hjkmnp-z0-9]{6}\d{2}$'), - ADD COLUMN country_code country_code; - -ALTER TABLE funder_history RENAME TO institution_history; - -ALTER TABLE institution_history RENAME COLUMN funder_history_id TO institution_history_id; -ALTER TABLE institution_history RENAME COLUMN funder_id TO institution_id; - -ALTER TABLE funding RENAME COLUMN funder_id TO institution_id; - -CREATE TABLE affiliation ( - affiliation_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - contribution_id UUID NOT NULL REFERENCES contribution(contribution_id) ON DELETE CASCADE, - institution_id UUID NOT NULL REFERENCES institution(institution_id) ON DELETE CASCADE, - affiliation_ordinal INTEGER NOT NULL CHECK (affiliation_ordinal > 0), - position TEXT CHECK (octet_length(position) >= 1), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); -SELECT diesel_manage_updated_at('affiliation'); - --- UNIQ index on affiliation_ordinal and contribution_id -CREATE UNIQUE INDEX affiliation_uniq_ord_in_contribution_idx ON affiliation(contribution_id, affiliation_ordinal); - -CREATE TABLE affiliation_history ( - affiliation_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - affiliation_id UUID NOT NULL REFERENCES affiliation(affiliation_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Create institution entries for every existing contribution institution --- (unless an institution with that name already exists). -INSERT INTO institution(institution_name) - SELECT DISTINCT institution FROM contribution - WHERE institution IS NOT NULL - AND NOT EXISTS (SELECT * FROM institution WHERE institution_name = contribution.institution); - --- Create an affiliation linking the appropriate institution to each relevant contribution. --- (Each contribution will have a maximum of one institution, so all entries can have ordinal 1.) -INSERT INTO affiliation(contribution_id, institution_id, affiliation_ordinal) - SELECT contribution.contribution_id, institution.institution_id, 1 FROM contribution, institution - WHERE contribution.institution = institution.institution_name; - -ALTER TABLE contribution - DROP COLUMN institution; diff --git a/thoth-api/migrations/0.7.0/down.sql b/thoth-api/migrations/0.7.0/down.sql deleted file mode 100644 index 697f3d5e..00000000 --- a/thoth-api/migrations/0.7.0/down.sql +++ /dev/null @@ -1,28 +0,0 @@ -DROP TABLE work_relation_history; -DROP TRIGGER set_updated_at ON work_relation; -DROP TABLE work_relation; -DROP TYPE IF EXISTS relation_type; - -ALTER TABLE work - DROP CONSTRAINT work_non_chapter_no_first_page, - DROP CONSTRAINT work_non_chapter_no_last_page, - DROP CONSTRAINT work_non_chapter_no_page_interval, - DROP COLUMN first_page, - DROP COLUMN last_page, - DROP COLUMN page_interval, - DROP CONSTRAINT work_non_chapter_has_edition, - DROP CONSTRAINT work_chapter_no_edition, - DROP CONSTRAINT work_chapter_no_width, - DROP CONSTRAINT work_chapter_no_height, - DROP CONSTRAINT work_chapter_no_toc, - DROP CONSTRAINT work_chapter_no_lccn, - DROP CONSTRAINT work_chapter_no_oclc; - --- Set a default edition value for any chapter records before --- reintroducing the original blanket edition-not-null constraint. -UPDATE work - SET edition = 1 - WHERE work_type = 'book-chapter'; - -ALTER TABLE work - ALTER COLUMN edition SET NOT NULL; diff --git a/thoth-api/migrations/0.7.0/up.sql b/thoth-api/migrations/0.7.0/up.sql deleted file mode 100644 index 057586ec..00000000 --- a/thoth-api/migrations/0.7.0/up.sql +++ /dev/null @@ -1,79 +0,0 @@ -CREATE TYPE relation_type AS ENUM ( - 'replaces', - 'has-translation', - 'has-part', - 'has-child', - 'is-replaced-by', - 'is-translation-of', - 'is-part-of', - 'is-child-of' -); - -CREATE TABLE work_relation ( - work_relation_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - relator_work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, - related_work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, - relation_type relation_type NOT NULL, - relation_ordinal INTEGER NOT NULL CHECK (relation_ordinal > 0), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT work_relation_ids_check CHECK (relator_work_id != related_work_id), - CONSTRAINT work_relation_ordinal_type_uniq UNIQUE (relation_ordinal, relator_work_id, relation_type), - -- Two works cannot have more than one relationship. - CONSTRAINT work_relation_relator_related_uniq UNIQUE (relator_work_id, related_work_id), - -- Two records must exist for each relationship, one representing the 'active' relation_type - -- (e.g. 'has-child'), and one representing the 'passive' type (e.g. 'is-child-of'). - -- Ensure that each relator/related record has a corresponding related/relator record - -- (note we cannot verify that the relation_types themselves form a matching pair). - CONSTRAINT work_relation_active_passive_pair - FOREIGN KEY (relator_work_id, related_work_id) - REFERENCES work_relation (related_work_id, relator_work_id) - -- Allow transaction to complete before enforcing constraint - -- (so that pairs of records can be created/updated in tandem) - DEFERRABLE INITIALLY DEFERRED -); -SELECT diesel_manage_updated_at('work_relation'); - -CREATE TABLE work_relation_history ( - work_relation_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - work_relation_id UUID NOT NULL REFERENCES work_relation(work_relation_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -ALTER TABLE work - -- Restrict the original edition-not-null constraint to non-chapter work types. - ALTER COLUMN edition DROP NOT NULL, - ADD CONSTRAINT work_non_chapter_has_edition CHECK - (edition IS NOT NULL OR work_type = 'book-chapter'); - --- If any chapter records exist, clear any values from existing fields --- which are about to be newly constrained to null for chapters. -UPDATE work - SET edition = NULL, width = NULL, height = NULL, toc = NULL, lccn = NULL, oclc = NULL - WHERE work_type = 'book-chapter'; - -ALTER TABLE work - ADD CONSTRAINT work_chapter_no_edition CHECK - (edition IS NULL OR work_type <> 'book-chapter'), - ADD CONSTRAINT work_chapter_no_width CHECK - (width IS NULL OR work_type <> 'book-chapter'), - ADD CONSTRAINT work_chapter_no_height CHECK - (height IS NULL OR work_type <> 'book-chapter'), - ADD CONSTRAINT work_chapter_no_toc CHECK - (toc IS NULL OR work_type <> 'book-chapter'), - ADD CONSTRAINT work_chapter_no_lccn CHECK - (lccn IS NULL OR work_type <> 'book-chapter'), - ADD CONSTRAINT work_chapter_no_oclc CHECK - (oclc IS NULL OR work_type <> 'book-chapter'), - -- Create new chapter-only columns. - ADD COLUMN first_page TEXT CHECK (octet_length(first_page) >= 1), - ADD COLUMN last_page TEXT CHECK (octet_length(last_page) >= 1), - ADD COLUMN page_interval TEXT CHECK (octet_length(page_interval) >= 1), - ADD CONSTRAINT work_non_chapter_no_first_page CHECK - (first_page IS NULL OR work_type = 'book-chapter'), - ADD CONSTRAINT work_non_chapter_no_last_page CHECK - (last_page IS NULL OR work_type = 'book-chapter'), - ADD CONSTRAINT work_non_chapter_no_page_interval CHECK - (page_interval IS NULL OR work_type = 'book-chapter'); diff --git a/thoth-api/migrations/0.7.2/down.sql b/thoth-api/migrations/0.7.2/down.sql deleted file mode 100644 index a8cd1a3a..00000000 --- a/thoth-api/migrations/0.7.2/down.sql +++ /dev/null @@ -1,20 +0,0 @@ -ALTER TABLE series - DROP COLUMN series_description, - DROP COLUMN series_cfp_url; - --- We cannot drop individual enum values - we must drop the type and recreate it --- --- Delete publications with about-to-be-dropped types -DELETE FROM publication WHERE publication_type IN ('AZW3', 'DOCX', 'FictionBook'); -ALTER TABLE publication ALTER publication_type TYPE text; -DROP TYPE publication_type; -CREATE TYPE publication_type AS ENUM ( - 'Paperback', - 'Hardback', - 'PDF', - 'HTML', - 'XML', - 'Epub', - 'Mobi' -); -ALTER TABLE publication ALTER publication_type TYPE publication_type USING publication_type::publication_type; diff --git a/thoth-api/migrations/0.7.2/up.sql b/thoth-api/migrations/0.7.2/up.sql deleted file mode 100644 index 0a63bab4..00000000 --- a/thoth-api/migrations/0.7.2/up.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE series - -- Description of the series - ADD COLUMN series_description TEXT CHECK (octet_length(series_description) >= 1), - -- Call for proposals URL - ADD COLUMN series_cfp_url TEXT CHECK (series_cfp_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'); - -ALTER TYPE publication_type ADD VALUE IF NOT EXISTS 'AZW3'; -ALTER TYPE publication_type ADD VALUE IF NOT EXISTS 'DOCX'; -ALTER TYPE publication_type ADD VALUE IF NOT EXISTS 'FictionBook'; diff --git a/thoth-api/migrations/0.8.0/down.sql b/thoth-api/migrations/0.8.0/down.sql deleted file mode 100644 index 505e5809..00000000 --- a/thoth-api/migrations/0.8.0/down.sql +++ /dev/null @@ -1,65 +0,0 @@ -ALTER TABLE work - ADD COLUMN width double precision CHECK (width > 0.0), - ADD COLUMN height double precision CHECK (height > 0.0), - ADD CONSTRAINT work_chapter_no_width CHECK - (width IS NULL OR work_type <> 'book-chapter'), - ADD CONSTRAINT work_chapter_no_height CHECK - (height IS NULL OR work_type <> 'book-chapter'); - --- Migrate publication dimension information back into work table as far as possible --- (width/height in mm only) before dropping publication dimension columns. Where --- dimensions for both paperback and hardback are given, assume the paperback is canonical. -UPDATE work - SET width = publication.width_mm - FROM publication - WHERE work.work_type <> 'book-chapter' - AND work.work_id = publication.work_id - AND publication.width_mm IS NOT NULL - AND publication.publication_type = 'Paperback'; -UPDATE work - SET width = publication.width_mm - FROM publication - WHERE work.work_type <> 'book-chapter' - AND work.work_id = publication.work_id - AND work.width IS NULL - AND publication.width_mm IS NOT NULL - AND publication.publication_type = 'Hardback'; - -UPDATE work - SET height = publication.height_mm - FROM publication - WHERE work.work_type <> 'book-chapter' - AND work.work_id = publication.work_id - AND publication.height_mm IS NOT NULL - AND publication.publication_type = 'Paperback'; -UPDATE work - SET height = publication.height_mm - FROM publication - WHERE work.work_type <> 'book-chapter' - AND work.work_id = publication.work_id - AND work.height IS NULL - AND publication.height_mm IS NOT NULL - AND publication.publication_type = 'Hardback'; - -DROP TRIGGER publication_chapter_no_dimensions_check ON publication; - -ALTER TABLE publication - DROP CONSTRAINT publication_non_physical_no_dimensions, - DROP CONSTRAINT publication_weight_g_not_missing, - DROP CONSTRAINT publication_weight_oz_not_missing, - DROP CONSTRAINT publication_width_mm_not_missing, - DROP CONSTRAINT publication_width_in_not_missing, - DROP CONSTRAINT publication_height_mm_not_missing, - DROP CONSTRAINT publication_height_in_not_missing, - DROP CONSTRAINT publication_depth_mm_not_missing, - DROP CONSTRAINT publication_depth_in_not_missing, - DROP COLUMN weight_g, - DROP COLUMN weight_oz, - DROP COLUMN width_mm, - DROP COLUMN width_in, - DROP COLUMN height_mm, - DROP COLUMN height_in, - DROP COLUMN depth_mm, - DROP COLUMN depth_in; - -DROP FUNCTION IF EXISTS publication_chapter_no_dimensions(); diff --git a/thoth-api/migrations/0.8.0/up.sql b/thoth-api/migrations/0.8.0/up.sql deleted file mode 100644 index 3b523e67..00000000 --- a/thoth-api/migrations/0.8.0/up.sql +++ /dev/null @@ -1,88 +0,0 @@ -ALTER TABLE publication - ADD COLUMN width_mm double precision CHECK (width_mm > 0.0), - ADD COLUMN width_in double precision CHECK (width_in > 0.0), - ADD COLUMN height_mm double precision CHECK (height_mm > 0.0), - ADD COLUMN height_in double precision CHECK (height_in > 0.0), - ADD COLUMN depth_mm double precision CHECK (depth_mm > 0.0), - ADD COLUMN depth_in double precision CHECK (depth_in > 0.0), - ADD COLUMN weight_g double precision CHECK (weight_g > 0.0), - ADD COLUMN weight_oz double precision CHECK (weight_oz > 0.0), - ADD CONSTRAINT publication_non_physical_no_dimensions CHECK - ((width_mm IS NULL AND width_in IS NULL - AND height_mm IS NULL AND height_in IS NULL - AND depth_mm IS NULL AND depth_in IS NULL - AND weight_g IS NULL AND weight_oz IS NULL) - OR publication_type = 'Paperback' OR publication_type = 'Hardback'), - ADD CONSTRAINT publication_depth_mm_not_missing CHECK - (depth_mm IS NOT NULL OR depth_in IS NULL), - ADD CONSTRAINT publication_depth_in_not_missing CHECK - (depth_in IS NOT NULL OR depth_mm IS NULL), - ADD CONSTRAINT publication_weight_g_not_missing CHECK - (weight_g IS NOT NULL OR weight_oz IS NULL), - ADD CONSTRAINT publication_weight_oz_not_missing CHECK - (weight_oz IS NOT NULL OR weight_g IS NULL); - -CREATE OR REPLACE FUNCTION publication_chapter_no_dimensions() RETURNS trigger AS $$ -BEGIN - IF ( - (SELECT work_type FROM work WHERE work.work_id = NEW.work_id) = 'book-chapter' AND ( - NEW.width_mm IS NOT NULL OR - NEW.width_in IS NOT NULL OR - NEW.height_mm IS NOT NULL OR - NEW.height_in IS NOT NULL OR - NEW.depth_mm IS NOT NULL OR - NEW.depth_in IS NOT NULL OR - NEW.weight_g IS NOT NULL OR - NEW.weight_oz IS NOT NULL - ) - ) THEN - RAISE EXCEPTION 'Chapters cannot have dimensions (Width/Height/Depth/Weight)'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER publication_chapter_no_dimensions_check BEFORE INSERT OR UPDATE ON publication - FOR EACH ROW EXECUTE PROCEDURE publication_chapter_no_dimensions(); - --- Migrate work dimension information into publication table before dropping work --- width/height columns. Assume dimensions are same for paperback and hardback. -UPDATE publication - SET width_mm = work.width - FROM work - WHERE publication.work_id = work.work_id - AND work.width IS NOT NULL - AND (publication.publication_type = 'Paperback' OR publication.publication_type = 'Hardback'); - -UPDATE publication - SET height_mm = work.height - FROM work - WHERE publication.work_id = work.work_id - AND work.height IS NOT NULL - AND (publication.publication_type = 'Paperback' OR publication.publication_type = 'Hardback'); - --- Add imperial dimension information based on metric. Conversion logic used here --- replicates convert_length_from_to() function in thoth-api/src/model/mod.rs. -UPDATE publication - SET width_in = round((width_mm / 25.4)::numeric, 2) - WHERE width_mm IS NOT NULL; - -UPDATE publication - SET height_in = round((height_mm / 25.4)::numeric, 2) - WHERE height_mm IS NOT NULL; - -ALTER TABLE publication - ADD CONSTRAINT publication_width_mm_not_missing CHECK - (width_mm IS NOT NULL OR width_in IS NULL), - ADD CONSTRAINT publication_width_in_not_missing CHECK - (width_in IS NOT NULL OR width_mm IS NULL), - ADD CONSTRAINT publication_height_mm_not_missing CHECK - (height_mm IS NOT NULL OR height_in IS NULL), - ADD CONSTRAINT publication_height_in_not_missing CHECK - (height_in IS NOT NULL OR height_mm IS NULL); - -ALTER TABLE work - DROP CONSTRAINT work_chapter_no_width, - DROP CONSTRAINT work_chapter_no_height, - DROP COLUMN width, - DROP COLUMN height; diff --git a/thoth-api/migrations/0.8.11/down.sql b/thoth-api/migrations/0.8.11/down.sql deleted file mode 100644 index cd994d56..00000000 --- a/thoth-api/migrations/0.8.11/down.sql +++ /dev/null @@ -1,7 +0,0 @@ -ALTER TABLE work DROP CONSTRAINT work_place_check; -ALTER TABLE work ADD CONSTRAINT work_reference_check1 CHECK (octet_length(reference) >= 1); - -ALTER TABLE institution RENAME CONSTRAINT institution_pkey TO funder_pkey; -ALTER INDEX institution_doi_uniq_idx RENAME TO funder_doi_uniq_idx; -ALTER TABLE institution RENAME CONSTRAINT institution_institution_doi_check TO funder_funder_doi_check; -ALTER TABLE institution RENAME CONSTRAINT institution_institution_name_check TO funder_funder_name_check; diff --git a/thoth-api/migrations/0.8.11/up.sql b/thoth-api/migrations/0.8.11/up.sql deleted file mode 100644 index d783a904..00000000 --- a/thoth-api/migrations/0.8.11/up.sql +++ /dev/null @@ -1,7 +0,0 @@ -ALTER TABLE work DROP CONSTRAINT work_reference_check1; -ALTER TABLE work ADD CONSTRAINT work_place_check CHECK (octet_length(place) >= 1); - -ALTER TABLE institution RENAME CONSTRAINT funder_pkey TO institution_pkey; -ALTER INDEX funder_doi_uniq_idx RENAME TO institution_doi_uniq_idx; -ALTER TABLE institution RENAME CONSTRAINT funder_funder_doi_check TO institution_institution_doi_check; -ALTER TABLE institution RENAME CONSTRAINT funder_funder_name_check TO institution_institution_name_check; \ No newline at end of file diff --git a/thoth-api/migrations/0.8.3/down.sql b/thoth-api/migrations/0.8.3/down.sql deleted file mode 100644 index d03830b3..00000000 --- a/thoth-api/migrations/0.8.3/down.sql +++ /dev/null @@ -1,3 +0,0 @@ -DROP TRIGGER publication_location_canonical_urls_check ON publication; - -DROP FUNCTION IF EXISTS publication_location_canonical_urls(); diff --git a/thoth-api/migrations/0.8.3/up.sql b/thoth-api/migrations/0.8.3/up.sql deleted file mode 100644 index 702d024d..00000000 --- a/thoth-api/migrations/0.8.3/up.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE OR REPLACE FUNCTION publication_location_canonical_urls() RETURNS trigger AS $$ -BEGIN - IF ( - NEW.publication_type <> 'Hardback' AND - NEW.publication_type <> 'Paperback' AND - (SELECT COUNT(*) FROM location - WHERE location.publication_id = NEW.publication_id - AND location.canonical - AND (location.landing_page IS NULL OR location.full_text_url IS NULL) - ) > 0 - ) THEN - RAISE EXCEPTION 'Digital publications must have both Landing Page and Full Text URL in all their canonical locations'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER publication_location_canonical_urls_check BEFORE UPDATE ON publication - FOR EACH ROW EXECUTE PROCEDURE publication_location_canonical_urls(); diff --git a/thoth-api/migrations/0.8.5/down.sql b/thoth-api/migrations/0.8.5/down.sql deleted file mode 100644 index 5b68c1eb..00000000 --- a/thoth-api/migrations/0.8.5/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE price - DROP CONSTRAINT price_unit_price_check; diff --git a/thoth-api/migrations/0.8.5/up.sql b/thoth-api/migrations/0.8.5/up.sql deleted file mode 100644 index 1dec076a..00000000 --- a/thoth-api/migrations/0.8.5/up.sql +++ /dev/null @@ -1,4 +0,0 @@ -DELETE FROM price WHERE unit_price = 0.0; - -ALTER TABLE price - ADD CONSTRAINT price_unit_price_check CHECK (unit_price > 0.0); diff --git a/thoth-api/migrations/0.8.8/down.sql b/thoth-api/migrations/0.8.8/down.sql deleted file mode 100644 index 5092cac0..00000000 --- a/thoth-api/migrations/0.8.8/down.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE work - ALTER COLUMN copyright_holder SET NOT NULL; - -UPDATE work SET page_interval = REPLACE(page_interval, '–', '-'); \ No newline at end of file diff --git a/thoth-api/migrations/0.8.8/up.sql b/thoth-api/migrations/0.8.8/up.sql deleted file mode 100644 index b6ae4a9e..00000000 --- a/thoth-api/migrations/0.8.8/up.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE work - ALTER COLUMN copyright_holder DROP NOT NULL; - -UPDATE work SET page_interval = REPLACE(page_interval, '-', '–'); \ No newline at end of file diff --git a/thoth-api/migrations/0.8.9/down.sql b/thoth-api/migrations/0.8.9/down.sql deleted file mode 100644 index 6bc8d589..00000000 --- a/thoth-api/migrations/0.8.9/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TYPE contribution_type RENAME VALUE 'illustrator' TO 'ilustrator'; diff --git a/thoth-api/migrations/0.8.9/up.sql b/thoth-api/migrations/0.8.9/up.sql deleted file mode 100644 index a34a60c9..00000000 --- a/thoth-api/migrations/0.8.9/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TYPE contribution_type RENAME VALUE 'ilustrator' TO 'illustrator'; diff --git a/thoth-api/migrations/0.9.0/down.sql b/thoth-api/migrations/0.9.0/down.sql deleted file mode 100644 index d7f5e0cc..00000000 --- a/thoth-api/migrations/0.9.0/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE reference_history; -DROP TABLE reference; \ No newline at end of file diff --git a/thoth-api/migrations/0.9.0/up.sql b/thoth-api/migrations/0.9.0/up.sql deleted file mode 100644 index 27e591a2..00000000 --- a/thoth-api/migrations/0.9.0/up.sql +++ /dev/null @@ -1,43 +0,0 @@ -CREATE TABLE reference ( - reference_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, - reference_ordinal INTEGER NOT NULL CHECK (reference_ordinal > 0), - doi TEXT CHECK (doi ~* '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\/:a-zA-Z0-9]+$'), - unstructured_citation TEXT CHECK (octet_length(unstructured_citation) >= 1), - issn TEXT CHECK (issn ~* '\d{4}\-\d{3}(\d|X)'), - isbn TEXT CHECK (octet_length(isbn) = 17), - journal_title TEXT CHECK (octet_length(journal_title) >= 1), - article_title TEXT CHECK (octet_length(article_title) >= 1), - series_title TEXT CHECK (octet_length(series_title) >= 1), - volume_title TEXT CHECK (octet_length(volume_title) >= 1), - edition INTEGER CHECK (edition > 0), - author TEXT CHECK (octet_length(author) >= 1), - volume TEXT CHECK (octet_length(volume) >= 1), - issue TEXT CHECK (octet_length(issue) >= 1), - first_page TEXT CHECK (octet_length(first_page) >= 1), - component_number TEXT CHECK (octet_length(component_number) >= 1), - standard_designator TEXT CHECK (octet_length(standard_designator) >= 1), - standards_body_name TEXT CHECK (octet_length(standards_body_name) >= 1), - standards_body_acronym TEXT CHECK (octet_length(standards_body_acronym) >= 1), - url TEXT CHECK (url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'), - publication_date DATE, - retrieval_date DATE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT reference_reference_ordinal_work_id_uniq UNIQUE (work_id, reference_ordinal), - CONSTRAINT reference_doi_andor_unstructured_citation CHECK - (doi IS NOT NULL OR unstructured_citation IS NOT NULL), - CONSTRAINT reference_standard_citation_required_fields CHECK - ((standard_designator IS NOT NULL AND standards_body_name IS NOT NULL AND standards_body_acronym IS NOT NULL) - OR - (standard_designator IS NULL AND standards_body_name IS NULL AND standards_body_acronym IS NULL)) -); -SELECT diesel_manage_updated_at('reference'); - -CREATE TABLE reference_history ( - reference_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - reference_id UUID NOT NULL REFERENCES reference(reference_id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES account(account_id), - data JSONB NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/thoth-api/migrations/0.9.16/down.sql b/thoth-api/migrations/0.9.16/down.sql deleted file mode 100644 index 29364727..00000000 --- a/thoth-api/migrations/0.9.16/down.sql +++ /dev/null @@ -1,64 +0,0 @@ -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON contribution; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON funding; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON issue; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON language; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON publication; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON reference; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON subject; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON work_relation; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON affiliation; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON location; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON price; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON contributor; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON institution; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON publisher; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON series; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON work; - -DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON imprint; - -DROP FUNCTION IF EXISTS work_updated_at_with_relations(); - -DROP FUNCTION IF EXISTS work_relation_work_updated_at_with_relations(); - -DROP FUNCTION IF EXISTS affiliation_work_updated_at_with_relations(); - -DROP FUNCTION IF EXISTS location_work_updated_at_with_relations(); - -DROP FUNCTION IF EXISTS price_work_updated_at_with_relations(); - -DROP FUNCTION IF EXISTS contributor_work_updated_at_with_relations(); - -DROP FUNCTION IF EXISTS institution_work_updated_at_with_relations(); - -DROP FUNCTION IF EXISTS publisher_work_updated_at_with_relations(); - -DROP FUNCTION IF EXISTS series_work_updated_at_with_relations(); - -DROP FUNCTION IF EXISTS work_work_updated_at_with_relations(); - -DROP FUNCTION IF EXISTS imprint_work_updated_at_with_relations(); - -ALTER TABLE work - DROP COLUMN updated_at_with_relations; - -DROP TRIGGER IF EXISTS set_updated_at ON work; - -DROP FUNCTION IF EXISTS work_set_updated_at(); - -SELECT diesel_manage_updated_at('work'); diff --git a/thoth-api/migrations/0.9.16/up.sql b/thoth-api/migrations/0.9.16/up.sql deleted file mode 100644 index 05e86700..00000000 --- a/thoth-api/migrations/0.9.16/up.sql +++ /dev/null @@ -1,298 +0,0 @@ --- Add work table field to track when the work or any of its relations was last updated. - -ALTER TABLE work - ADD COLUMN updated_at_with_relations TIMESTAMP NULL; - --- Amend existing trigger which sets updated_at value on work table --- to avoid setting updated_at when updated_at_with_relations changes. - -CREATE OR REPLACE FUNCTION work_set_updated_at() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD AND - NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at AND - NEW.updated_at_with_relations IS NOT DISTINCT FROM OLD.updated_at_with_relations - ) THEN - NEW.updated_at := current_timestamp; - NEW.updated_at_with_relations := current_timestamp; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS set_updated_at ON work; - -CREATE TRIGGER set_updated_at BEFORE UPDATE ON work - FOR EACH ROW EXECUTE PROCEDURE work_set_updated_at(); - --- Obtain current last relation update timestamp for all existing works. -WITH update_times AS -( - SELECT w.work_id, GREATEST( - w.updated_at, c.updated_at, f.updated_at, i.updated_at, iu.updated_at, l.updated_at, p.updated_at, - r.updated_at, s.updated_at, wr.updated_at, a.updated_at, lo.updated_at, pr.updated_at, - co.updated_at, inf.updated_at, ina.updated_at, pu.updated_at, se.updated_at, wo.updated_at - ) last_updated - FROM work w - LEFT JOIN contribution c USING (work_id) - LEFT JOIN funding f USING (work_id) - LEFT JOIN imprint i USING (imprint_id) - LEFT JOIN issue iu USING (work_id) - LEFT JOIN language l USING (work_id) - LEFT JOIN publication p USING (work_id) - LEFT JOIN reference r USING (work_id) - LEFT JOIN subject s USING (work_id) - LEFT JOIN work_relation wr ON w.work_id = wr.relator_work_id - LEFT JOIN affiliation a ON c.contribution_id = a.contribution_id - LEFT JOIN location lo ON p.publication_id = lo.publication_id - LEFT JOIN price pr ON p.publication_id = pr.publication_id - LEFT JOIN contributor co ON c.contributor_id = co.contributor_id - LEFT JOIN institution inf ON f.institution_id = inf.institution_id - LEFT JOIN institution ina ON a.institution_id = ina.institution_id - LEFT JOIN publisher pu ON i.publisher_id = pu.publisher_id - LEFT JOIN series se ON iu.series_id = se.series_id - LEFT JOIN work wo ON wr.related_work_id = wo.work_id - GROUP BY w.work_id, last_updated -) -UPDATE work - SET updated_at_with_relations = update_times.last_updated - FROM update_times - WHERE work.work_id = update_times.work_id; - -ALTER TABLE work - ALTER COLUMN updated_at_with_relations SET NOT NULL, - ALTER COLUMN updated_at_with_relations SET DEFAULT CURRENT_TIMESTAMP; - --- Add triggers to update this field whenever a relation is created, updated or deleted. - -CREATE OR REPLACE FUNCTION work_updated_at_with_relations() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD - ) THEN - UPDATE work - SET updated_at_with_relations = current_timestamp - WHERE work_id = OLD.work_id OR work_id = NEW.work_id; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR UPDATE OR DELETE ON contribution - FOR EACH ROW EXECUTE PROCEDURE work_updated_at_with_relations(); - -CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR UPDATE OR DELETE ON funding - FOR EACH ROW EXECUTE PROCEDURE work_updated_at_with_relations(); - -CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR UPDATE OR DELETE ON issue - FOR EACH ROW EXECUTE PROCEDURE work_updated_at_with_relations(); - -CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR UPDATE OR DELETE ON language - FOR EACH ROW EXECUTE PROCEDURE work_updated_at_with_relations(); - -CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR UPDATE OR DELETE ON publication - FOR EACH ROW EXECUTE PROCEDURE work_updated_at_with_relations(); - -CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR UPDATE OR DELETE ON reference - FOR EACH ROW EXECUTE PROCEDURE work_updated_at_with_relations(); - -CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR UPDATE OR DELETE ON subject - FOR EACH ROW EXECUTE PROCEDURE work_updated_at_with_relations(); - -CREATE OR REPLACE FUNCTION work_relation_work_updated_at_with_relations() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD - ) THEN - UPDATE work - SET updated_at_with_relations = current_timestamp - WHERE work_id = OLD.relator_work_id OR work_id = NEW.relator_work_id - OR work_id = OLD.related_work_id OR work_id = NEW.related_work_id; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR UPDATE OR DELETE ON work_relation - FOR EACH ROW EXECUTE PROCEDURE work_relation_work_updated_at_with_relations(); - --- The following tables all reference tables which reference the work table. --- As they are at the end of this chain of references, any creation, update or --- deletion on them should also be marked as an update on the 'grandparent' work. -CREATE OR REPLACE FUNCTION affiliation_work_updated_at_with_relations() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD - ) THEN - UPDATE work - SET updated_at_with_relations = current_timestamp - FROM contribution - WHERE work.work_id = contribution.work_id AND contribution.contribution_id = OLD.contribution_id - OR work.work_id = contribution.work_id AND contribution.contribution_id = NEW.contribution_id; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR UPDATE OR DELETE ON affiliation - FOR EACH ROW EXECUTE PROCEDURE affiliation_work_updated_at_with_relations(); - -CREATE OR REPLACE FUNCTION location_work_updated_at_with_relations() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD - ) THEN - UPDATE work - SET updated_at_with_relations = current_timestamp - FROM publication - WHERE work.work_id = publication.work_id AND publication.publication_id = OLD.publication_id - OR work.work_id = publication.work_id AND publication.publication_id = NEW.publication_id; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR UPDATE OR DELETE ON location - FOR EACH ROW EXECUTE PROCEDURE location_work_updated_at_with_relations(); - -CREATE OR REPLACE FUNCTION price_work_updated_at_with_relations() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD - ) THEN - UPDATE work - SET updated_at_with_relations = current_timestamp - FROM publication - WHERE work.work_id = publication.work_id AND publication.publication_id = OLD.publication_id - OR work.work_id = publication.work_id AND publication.publication_id = NEW.publication_id; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR UPDATE OR DELETE ON price - FOR EACH ROW EXECUTE PROCEDURE price_work_updated_at_with_relations(); - -CREATE OR REPLACE FUNCTION contributor_work_updated_at_with_relations() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD - ) THEN - UPDATE work - SET updated_at_with_relations = current_timestamp - FROM contribution - -- No need to check OLD.contributor_id, as this will be the same as NEW.contributor_id in all relevant cases - -- (contributor_id can't be changed on contributors which are referenced by existing contributions) - WHERE work.work_id = contribution.work_id AND contribution.contributor_id = NEW.contributor_id; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - --- Deleting a contributor will also delete its contributions, setting updated_at_with_relations where relevant. --- Adding a contributor will not affect any existing works, because no contributions will reference it yet. -CREATE TRIGGER set_work_updated_at_with_relations AFTER UPDATE ON contributor - FOR EACH ROW EXECUTE PROCEDURE contributor_work_updated_at_with_relations(); - -CREATE OR REPLACE FUNCTION institution_work_updated_at_with_relations() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD - ) THEN - -- Same as contributor above (but can be connected to work via two different tables) - -- Use two separate UPDATE statements as this is much faster than combining the WHERE clauses - -- using OR (in tests, this caused several seconds' delay when saving institution updates) - UPDATE work - SET updated_at_with_relations = current_timestamp - FROM funding - WHERE work.work_id = funding.work_id AND funding.institution_id = NEW.institution_id; - UPDATE work - SET updated_at_with_relations = current_timestamp - FROM affiliation, contribution - WHERE work.work_id = contribution.work_id AND contribution.contribution_id = affiliation.contribution_id AND affiliation.institution_id = NEW.institution_id; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - --- Same as contributor above -CREATE TRIGGER set_work_updated_at_with_relations AFTER UPDATE ON institution - FOR EACH ROW EXECUTE PROCEDURE institution_work_updated_at_with_relations(); - -CREATE OR REPLACE FUNCTION publisher_work_updated_at_with_relations() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD - ) THEN - UPDATE work - SET updated_at_with_relations = current_timestamp - FROM imprint - -- Same as contributor above - WHERE work.imprint_id = imprint.imprint_id AND imprint.publisher_id = NEW.publisher_id; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - --- Same as contributor above -CREATE TRIGGER set_work_updated_at_with_relations AFTER UPDATE ON publisher - FOR EACH ROW EXECUTE PROCEDURE publisher_work_updated_at_with_relations(); - -CREATE OR REPLACE FUNCTION series_work_updated_at_with_relations() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD - ) THEN - UPDATE work - SET updated_at_with_relations = current_timestamp - FROM issue - -- Same as contributor above (note that although series is also connected to work - -- via the imprint_id, changes to a series don't affect its imprint) - WHERE work.work_id = issue.work_id AND issue.series_id = NEW.series_id; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - --- Same as contributor above -CREATE TRIGGER set_work_updated_at_with_relations AFTER UPDATE ON series - FOR EACH ROW EXECUTE PROCEDURE series_work_updated_at_with_relations(); - --- Works can be related to each other via the work_relation table, with a relationship similar --- to contributor above (a newly-created work won't have any references yet, etc) -CREATE OR REPLACE FUNCTION work_work_updated_at_with_relations() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD - ) THEN - UPDATE work - SET updated_at_with_relations = current_timestamp - FROM work_relation - -- The positions of relator/related IDs in this statement don't matter, as - -- every work_relation record has a mirrored record with relator/related IDs swapped - WHERE work.work_id = work_relation.relator_work_id AND work_relation.related_work_id = NEW.work_id; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER set_work_updated_at_with_relations AFTER UPDATE ON work - FOR EACH ROW EXECUTE PROCEDURE work_work_updated_at_with_relations(); - --- Imprint relationship is similar to contributor, although the tables are directly adjacent; --- new imprints won't be referenced by works yet, and deleting an imprint also deletes its works -CREATE OR REPLACE FUNCTION imprint_work_updated_at_with_relations() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD - ) THEN - UPDATE work - SET updated_at_with_relations = current_timestamp - WHERE imprint_id = NEW.imprint_id; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER set_work_updated_at_with_relations AFTER UPDATE ON imprint - FOR EACH ROW EXECUTE PROCEDURE imprint_work_updated_at_with_relations(); diff --git a/thoth-api/migrations/0.9.2/down.sql b/thoth-api/migrations/0.9.2/down.sql deleted file mode 100644 index c9c92dc7..00000000 --- a/thoth-api/migrations/0.9.2/down.sql +++ /dev/null @@ -1,24 +0,0 @@ --- We cannot drop individual enum values - we must drop the type and recreate it --- --- Delete contributions with about-to-be-dropped types -DELETE FROM contribution WHERE contribution_type IN ( - 'software-by', - 'research-by', - 'contributions-by', - 'indexer' -); -ALTER TABLE contribution ALTER contribution_type TYPE text; -DROP TYPE contribution_type; -CREATE TYPE contribution_type AS ENUM ( - 'author', - 'editor', - 'translator', - 'photographer', - 'illustrator', - 'music-editor', - 'foreword-by', - 'introduction-by', - 'afterword-by', - 'preface-by' -); -ALTER TABLE contribution ALTER contribution_type TYPE contribution_type USING contribution_type::contribution_type; \ No newline at end of file diff --git a/thoth-api/migrations/0.9.2/up.sql b/thoth-api/migrations/0.9.2/up.sql deleted file mode 100644 index bebcbbaf..00000000 --- a/thoth-api/migrations/0.9.2/up.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TYPE contribution_type ADD VALUE IF NOT EXISTS 'software-by'; -ALTER TYPE contribution_type ADD VALUE IF NOT EXISTS 'research-by'; -ALTER TYPE contribution_type ADD VALUE IF NOT EXISTS 'contributions-by'; -ALTER TYPE contribution_type ADD VALUE IF NOT EXISTS 'indexer'; \ No newline at end of file diff --git a/thoth-api/migrations/0.9.6/down.sql b/thoth-api/migrations/0.9.6/down.sql deleted file mode 100644 index f502a6aa..00000000 --- a/thoth-api/migrations/0.9.6/down.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TABLE work DROP CONSTRAINT work_doi_check; -ALTER TABLE work ADD CONSTRAINT work_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\/:a-zA-Z0-9]+$'); - -ALTER TABLE reference DROP CONSTRAINT reference_doi_check; -ALTER TABLE reference ADD CONSTRAINT reference_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\/:a-zA-Z0-9]+$'); - -ALTER TABLE institution DROP CONSTRAINT institution_institution_doi_check; -ALTER TABLE institution ADD CONSTRAINT institution_institution_doi_check - CHECK (institution_doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\/:a-zA-Z0-9]+$'); diff --git a/thoth-api/migrations/0.9.6/up.sql b/thoth-api/migrations/0.9.6/up.sql deleted file mode 100644 index b9297c0c..00000000 --- a/thoth-api/migrations/0.9.6/up.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TABLE work DROP CONSTRAINT work_doi_check; -ALTER TABLE work ADD CONSTRAINT work_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\[\]\/:a-zA-Z0-9]+$'); - -ALTER TABLE reference DROP CONSTRAINT reference_doi_check; -ALTER TABLE reference ADD CONSTRAINT reference_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\[\]\/:a-zA-Z0-9]+$'); - -ALTER TABLE institution DROP CONSTRAINT institution_institution_doi_check; -ALTER TABLE institution ADD CONSTRAINT institution_institution_doi_check - CHECK (institution_doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\[\]\/:a-zA-Z0-9]+$'); diff --git a/thoth-api/migrations/20250000_v1.0.0/down.sql b/thoth-api/migrations/20250000_v1.0.0/down.sql new file mode 100644 index 00000000..2ad0e1a9 --- /dev/null +++ b/thoth-api/migrations/20250000_v1.0.0/down.sql @@ -0,0 +1,72 @@ +-- Drop tables +DROP TABLE IF EXISTS public.work_relation_history CASCADE; +DROP TABLE IF EXISTS public.work_relation CASCADE; +DROP TABLE IF EXISTS public.work_history CASCADE; +DROP TABLE IF EXISTS public.work CASCADE; +DROP TABLE IF EXISTS public.subject_history CASCADE; +DROP TABLE IF EXISTS public.subject CASCADE; +DROP TABLE IF EXISTS public.series_history CASCADE; +DROP TABLE IF EXISTS public.series CASCADE; +DROP TABLE IF EXISTS public.reference_history CASCADE; +DROP TABLE IF EXISTS public.reference CASCADE; +DROP TABLE IF EXISTS public.publisher_history CASCADE; +DROP TABLE IF EXISTS public.publisher_account CASCADE; +DROP TABLE IF EXISTS public.publisher CASCADE; +DROP TABLE IF EXISTS public.publication_history CASCADE; +DROP TABLE IF EXISTS public.publication CASCADE; +DROP TABLE IF EXISTS public.price_history CASCADE; +DROP TABLE IF EXISTS public.price CASCADE; +DROP TABLE IF EXISTS public.location_history CASCADE; +DROP TABLE IF EXISTS public.location CASCADE; +DROP TABLE IF EXISTS public.language_history CASCADE; +DROP TABLE IF EXISTS public.language CASCADE; +DROP TABLE IF EXISTS public.issue_history CASCADE; +DROP TABLE IF EXISTS public.issue CASCADE; +DROP TABLE IF EXISTS public.institution_history CASCADE; +DROP TABLE IF EXISTS public.institution CASCADE; +DROP TABLE IF EXISTS public.imprint_history CASCADE; +DROP TABLE IF EXISTS public.imprint CASCADE; +DROP TABLE IF EXISTS public.funding_history CASCADE; +DROP TABLE IF EXISTS public.funding CASCADE; +DROP TABLE IF EXISTS public.contributor_history CASCADE; +DROP TABLE IF EXISTS public.contributor CASCADE; +DROP TABLE IF EXISTS public.contribution_history CASCADE; +DROP TABLE IF EXISTS public.contribution CASCADE; +DROP TABLE IF EXISTS public.affiliation_history CASCADE; +DROP TABLE IF EXISTS public.affiliation CASCADE; +DROP TABLE IF EXISTS public.account CASCADE; + +-- Drop functions +DROP FUNCTION IF EXISTS public.affiliation_work_updated_at_with_relations() CASCADE; +DROP FUNCTION IF EXISTS public.contributor_work_updated_at_with_relations() CASCADE; +DROP FUNCTION IF EXISTS public.imprint_work_updated_at_with_relations() CASCADE; +DROP FUNCTION IF EXISTS public.institution_work_updated_at_with_relations() CASCADE; +DROP FUNCTION IF EXISTS public.location_work_updated_at_with_relations() CASCADE; +DROP FUNCTION IF EXISTS public.price_work_updated_at_with_relations() CASCADE; +DROP FUNCTION IF EXISTS public.publisher_work_updated_at_with_relations() CASCADE; +DROP FUNCTION IF EXISTS public.series_work_updated_at_with_relations() CASCADE; +DROP FUNCTION IF EXISTS public.work_relation_work_updated_at_with_relations() CASCADE; +DROP FUNCTION IF EXISTS public.work_work_updated_at_with_relations() CASCADE; +DROP FUNCTION IF EXISTS public.work_updated_at_with_relations() CASCADE; +DROP FUNCTION IF EXISTS public.work_set_updated_at() CASCADE; +DROP FUNCTION IF EXISTS public.publication_chapter_no_dimensions() CASCADE; +DROP FUNCTION IF EXISTS public.publication_location_canonical_urls() CASCADE; +DROP FUNCTION IF EXISTS public.diesel_set_updated_at() CASCADE; +DROP FUNCTION IF EXISTS public.diesel_manage_updated_at(regclass) CASCADE; + +-- Drop enum types +DROP TYPE IF EXISTS public.work_type; +DROP TYPE IF EXISTS public.work_status; +DROP TYPE IF EXISTS public.subject_type; +DROP TYPE IF EXISTS public.series_type; +DROP TYPE IF EXISTS public.relation_type; +DROP TYPE IF EXISTS public.publication_type; +DROP TYPE IF EXISTS public.location_platform; +DROP TYPE IF EXISTS public.language_relation; +DROP TYPE IF EXISTS public.language_code; +DROP TYPE IF EXISTS public.currency_code; +DROP TYPE IF EXISTS public.country_code; +DROP TYPE IF EXISTS public.contribution_type; + +-- Drop extension +DROP EXTENSION IF EXISTS "uuid-ossp" CASCADE; diff --git a/thoth-api/migrations/20250000_v1.0.0/up.sql b/thoth-api/migrations/20250000_v1.0.0/up.sql new file mode 100644 index 00000000..45211227 --- /dev/null +++ b/thoth-api/migrations/20250000_v1.0.0/up.sql @@ -0,0 +1,3882 @@ +-- +-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; + + +-- +-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)'; + + +-- +-- Name: contribution_type; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.contribution_type AS ENUM ( + 'author', + 'editor', + 'translator', + 'photographer', + 'illustrator', + 'music-editor', + 'foreword-by', + 'introduction-by', + 'afterword-by', + 'preface-by', + 'software-by', + 'research-by', + 'contributions-by', + 'indexer' +); + + +-- +-- Name: country_code; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.country_code AS ENUM ( + 'afg', + 'ala', + 'alb', + 'dza', + 'asm', + 'and', + 'ago', + 'aia', + 'ata', + 'atg', + 'arg', + 'arm', + 'abw', + 'aus', + 'aut', + 'aze', + 'bhs', + 'bhr', + 'bgd', + 'brb', + 'blr', + 'bel', + 'blz', + 'ben', + 'bmu', + 'btn', + 'bol', + 'bes', + 'bih', + 'bwa', + 'bvt', + 'bra', + 'iot', + 'brn', + 'bgr', + 'bfa', + 'bdi', + 'cpv', + 'khm', + 'cmr', + 'can', + 'cym', + 'caf', + 'tcd', + 'chl', + 'chn', + 'cxr', + 'cck', + 'col', + 'com', + 'cok', + 'cri', + 'civ', + 'hrv', + 'cub', + 'cuw', + 'cyp', + 'cze', + 'cod', + 'dnk', + 'dji', + 'dma', + 'dom', + 'ecu', + 'egy', + 'slv', + 'gnq', + 'eri', + 'est', + 'swz', + 'eth', + 'flk', + 'fro', + 'fji', + 'fin', + 'fra', + 'guf', + 'pyf', + 'atf', + 'gab', + 'gmb', + 'geo', + 'deu', + 'gha', + 'gib', + 'grc', + 'grl', + 'grd', + 'glp', + 'gum', + 'gtm', + 'ggy', + 'gin', + 'gnb', + 'guy', + 'hti', + 'hmd', + 'hnd', + 'hkg', + 'hun', + 'isl', + 'ind', + 'idn', + 'irn', + 'irq', + 'irl', + 'imn', + 'isr', + 'ita', + 'jam', + 'jpn', + 'jey', + 'jor', + 'kaz', + 'ken', + 'kir', + 'kwt', + 'kgz', + 'lao', + 'lva', + 'lbn', + 'lso', + 'lbr', + 'lby', + 'lie', + 'ltu', + 'lux', + 'mac', + 'mdg', + 'mwi', + 'mys', + 'mdv', + 'mli', + 'mlt', + 'mhl', + 'mtq', + 'mrt', + 'mus', + 'myt', + 'mex', + 'fsm', + 'mda', + 'mco', + 'mng', + 'mne', + 'msr', + 'mar', + 'moz', + 'mmr', + 'nam', + 'nru', + 'npl', + 'nld', + 'ncl', + 'nzl', + 'nic', + 'ner', + 'nga', + 'niu', + 'nfk', + 'prk', + 'mkd', + 'mnp', + 'nor', + 'omn', + 'pak', + 'plw', + 'pse', + 'pan', + 'png', + 'pry', + 'per', + 'phl', + 'pcn', + 'pol', + 'prt', + 'pri', + 'qat', + 'cog', + 'reu', + 'rou', + 'rus', + 'rwa', + 'blm', + 'shn', + 'kna', + 'lca', + 'maf', + 'spm', + 'vct', + 'wsm', + 'smr', + 'stp', + 'sau', + 'sen', + 'srb', + 'syc', + 'sle', + 'sgp', + 'sxm', + 'svk', + 'svn', + 'slb', + 'som', + 'zaf', + 'sgs', + 'kor', + 'ssd', + 'esp', + 'lka', + 'sdn', + 'sur', + 'sjm', + 'swe', + 'che', + 'syr', + 'twn', + 'tjk', + 'tza', + 'tha', + 'tls', + 'tgo', + 'tkl', + 'ton', + 'tto', + 'tun', + 'tur', + 'tkm', + 'tca', + 'tuv', + 'uga', + 'ukr', + 'are', + 'gbr', + 'umi', + 'usa', + 'ury', + 'uzb', + 'vut', + 'vat', + 'ven', + 'vnm', + 'vgb', + 'vir', + 'wlf', + 'esh', + 'yem', + 'zmb', + 'zwe' +); + + +-- +-- Name: currency_code; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.currency_code AS ENUM ( + 'adp', + 'aed', + 'afa', + 'afn', + 'alk', + 'all', + 'amd', + 'ang', + 'aoa', + 'aok', + 'aon', + 'aor', + 'ara', + 'arp', + 'ars', + 'ary', + 'ats', + 'aud', + 'awg', + 'aym', + 'azm', + 'azn', + 'bad', + 'bam', + 'bbd', + 'bdt', + 'bec', + 'bef', + 'bel', + 'bgj', + 'bgk', + 'bgl', + 'bgn', + 'bhd', + 'bif', + 'bmd', + 'bnd', + 'bob', + 'bop', + 'bov', + 'brb', + 'brc', + 'bre', + 'brl', + 'brn', + 'brr', + 'bsd', + 'btn', + 'buk', + 'bwp', + 'byb', + 'byn', + 'byr', + 'bzd', + 'cad', + 'cdf', + 'chc', + 'che', + 'chf', + 'chw', + 'clf', + 'clp', + 'cny', + 'cop', + 'cou', + 'crc', + 'csd', + 'csj', + 'csk', + 'cuc', + 'cup', + 'cve', + 'cyp', + 'czk', + 'ddm', + 'dem', + 'djf', + 'dkk', + 'dop', + 'dzd', + 'ecs', + 'ecv', + 'eek', + 'egp', + 'ern', + 'esa', + 'esb', + 'esp', + 'etb', + 'eur', + 'fim', + 'fjd', + 'fkp', + 'frf', + 'gbp', + 'gek', + 'gel', + 'ghc', + 'ghp', + 'ghs', + 'gip', + 'gmd', + 'gne', + 'gnf', + 'gns', + 'gqe', + 'grd', + 'gtq', + 'gwe', + 'gwp', + 'gyd', + 'hkd', + 'hnl', + 'hrd', + 'hrk', + 'htg', + 'huf', + 'idr', + 'iep', + 'ilp', + 'ilr', + 'ils', + 'inr', + 'iqd', + 'irr', + 'isj', + 'isk', + 'itl', + 'jmd', + 'jod', + 'jpy', + 'kes', + 'kgs', + 'khr', + 'kmf', + 'kpw', + 'krw', + 'kwd', + 'kyd', + 'kzt', + 'laj', + 'lak', + 'lbp', + 'lkr', + 'lrd', + 'lsl', + 'lsm', + 'ltl', + 'ltt', + 'luc', + 'luf', + 'lul', + 'lvl', + 'lvr', + 'lyd', + 'mad', + 'mdl', + 'mga', + 'mgf', + 'mkd', + 'mlf', + 'mmk', + 'mnt', + 'mop', + 'mro', + 'mru', + 'mtl', + 'mtp', + 'mur', + 'mvq', + 'mvr', + 'mwk', + 'mxn', + 'mxp', + 'mxv', + 'myr', + 'mze', + 'mzm', + 'mzn', + 'nad', + 'ngn', + 'nic', + 'nio', + 'nlg', + 'nok', + 'npr', + 'nzd', + 'omr', + 'pab', + 'peh', + 'pei', + 'pen', + 'pes', + 'pgk', + 'php', + 'pkr', + 'pln', + 'plz', + 'pte', + 'pyg', + 'qar', + 'rhd', + 'rok', + 'rol', + 'ron', + 'rsd', + 'rub', + 'rur', + 'rwf', + 'sar', + 'sbd', + 'scr', + 'sdd', + 'sdg', + 'sdp', + 'sek', + 'sgd', + 'shp', + 'sit', + 'skk', + 'sll', + 'sos', + 'srd', + 'srg', + 'ssp', + 'std', + 'stn', + 'sur', + 'svc', + 'syp', + 'szl', + 'thb', + 'tjr', + 'tjs', + 'tmm', + 'tmt', + 'tnd', + 'top', + 'tpe', + 'trl', + 'try', + 'ttd', + 'twd', + 'tzs', + 'uah', + 'uak', + 'ugs', + 'ugw', + 'ugx', + 'usd', + 'usn', + 'uss', + 'uyi', + 'uyn', + 'uyp', + 'uyu', + 'uyw', + 'uzs', + 'veb', + 'vef', + 'ves', + 'vnc', + 'vnd', + 'vuv', + 'wst', + 'xaf', + 'xag', + 'xau', + 'xba', + 'xbb', + 'xbc', + 'xbd', + 'xcd', + 'xdr', + 'xeu', + 'xfo', + 'xfu', + 'xof', + 'xpd', + 'xpf', + 'xpt', + 'xre', + 'xsu', + 'xts', + 'xua', + 'xxx', + 'ydd', + 'yer', + 'yud', + 'yum', + 'yun', + 'zal', + 'zar', + 'zmk', + 'zmw', + 'zrn', + 'zrz', + 'zwc', + 'zwd', + 'zwl', + 'zwn', + 'zwr' +); + + +-- +-- Name: language_code; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.language_code AS ENUM ( + 'aar', + 'abk', + 'ace', + 'ach', + 'ada', + 'ady', + 'afa', + 'afh', + 'afr', + 'ain', + 'aka', + 'akk', + 'alb', + 'ale', + 'alg', + 'alt', + 'amh', + 'ang', + 'anp', + 'apa', + 'ara', + 'arc', + 'arg', + 'arm', + 'arn', + 'arp', + 'art', + 'arw', + 'asm', + 'ast', + 'ath', + 'aus', + 'ava', + 'ave', + 'awa', + 'aym', + 'aze', + 'bad', + 'bai', + 'bak', + 'bal', + 'bam', + 'ban', + 'baq', + 'bas', + 'bat', + 'bej', + 'bel', + 'bem', + 'ben', + 'ber', + 'bho', + 'bih', + 'bik', + 'bin', + 'bis', + 'bla', + 'bnt', + 'bos', + 'bra', + 'bre', + 'btk', + 'bua', + 'bug', + 'bul', + 'bur', + 'byn', + 'cad', + 'cai', + 'car', + 'cat', + 'cau', + 'ceb', + 'cel', + 'cha', + 'chb', + 'che', + 'chg', + 'chi', + 'chk', + 'chm', + 'chn', + 'cho', + 'chp', + 'chr', + 'chu', + 'chv', + 'chy', + 'cmc', + 'cnr', + 'cop', + 'cor', + 'cos', + 'cpe', + 'cpf', + 'cpp', + 'cre', + 'crh', + 'crp', + 'csb', + 'cus', + 'cze', + 'dak', + 'dan', + 'dar', + 'day', + 'del', + 'den', + 'dgr', + 'din', + 'div', + 'doi', + 'dra', + 'dsb', + 'dua', + 'dum', + 'dut', + 'dyu', + 'dzo', + 'efi', + 'egy', + 'eka', + 'elx', + 'eng', + 'enm', + 'epo', + 'est', + 'ewe', + 'ewo', + 'fan', + 'fao', + 'fat', + 'fij', + 'fil', + 'fin', + 'fiu', + 'fon', + 'fre', + 'frm', + 'fro', + 'frr', + 'frs', + 'fry', + 'ful', + 'fur', + 'gaa', + 'gay', + 'gba', + 'gem', + 'geo', + 'ger', + 'gez', + 'gil', + 'gla', + 'gle', + 'glg', + 'glv', + 'gmh', + 'goh', + 'gon', + 'gor', + 'got', + 'grb', + 'grc', + 'gre', + 'grn', + 'gsw', + 'guj', + 'gwi', + 'hai', + 'hat', + 'hau', + 'haw', + 'heb', + 'her', + 'hil', + 'him', + 'hin', + 'hit', + 'hmn', + 'hmo', + 'hrv', + 'hsb', + 'hun', + 'hup', + 'iba', + 'ibo', + 'ice', + 'ido', + 'iii', + 'ijo', + 'iku', + 'ile', + 'ilo', + 'ina', + 'inc', + 'ind', + 'ine', + 'inh', + 'ipk', + 'ira', + 'iro', + 'ita', + 'jav', + 'jbo', + 'jpn', + 'jpr', + 'jrb', + 'kaa', + 'kab', + 'kac', + 'kal', + 'kam', + 'kan', + 'kar', + 'kas', + 'kau', + 'kaw', + 'kaz', + 'kbd', + 'kha', + 'khi', + 'khm', + 'kho', + 'kik', + 'kin', + 'kir', + 'kmb', + 'kok', + 'kom', + 'kon', + 'kor', + 'kos', + 'kpe', + 'krc', + 'krl', + 'kro', + 'kru', + 'kua', + 'kum', + 'kur', + 'kut', + 'lad', + 'lah', + 'lam', + 'lao', + 'lat', + 'lav', + 'lez', + 'lim', + 'lin', + 'lit', + 'lol', + 'loz', + 'ltz', + 'lua', + 'lub', + 'lug', + 'lui', + 'lun', + 'luo', + 'lus', + 'mac', + 'mad', + 'mag', + 'mah', + 'mai', + 'mak', + 'mal', + 'man', + 'mao', + 'map', + 'mar', + 'mas', + 'may', + 'mdf', + 'mdr', + 'men', + 'mga', + 'mic', + 'min', + 'mis', + 'mkh', + 'mlg', + 'mlt', + 'mnc', + 'mni', + 'mno', + 'moh', + 'mon', + 'mos', + 'mul', + 'mun', + 'mus', + 'mwl', + 'mwr', + 'myn', + 'myv', + 'nah', + 'nai', + 'nap', + 'nau', + 'nav', + 'nbl', + 'nde', + 'ndo', + 'nds', + 'nep', + 'new', + 'nia', + 'nic', + 'niu', + 'nno', + 'nob', + 'nog', + 'non', + 'nor', + 'nqo', + 'nso', + 'nub', + 'nwc', + 'nya', + 'nym', + 'nyn', + 'nyo', + 'nzi', + 'oci', + 'oji', + 'ori', + 'orm', + 'osa', + 'oss', + 'ota', + 'oto', + 'paa', + 'pag', + 'pal', + 'pam', + 'pan', + 'pap', + 'pau', + 'peo', + 'per', + 'phi', + 'phn', + 'pli', + 'pol', + 'pon', + 'por', + 'pra', + 'pro', + 'pus', + 'qaa', + 'que', + 'raj', + 'rap', + 'rar', + 'roa', + 'roh', + 'rom', + 'rum', + 'run', + 'rup', + 'rus', + 'sad', + 'sag', + 'sah', + 'sai', + 'sal', + 'sam', + 'san', + 'sas', + 'sat', + 'scn', + 'sco', + 'sel', + 'sem', + 'sga', + 'sgn', + 'shn', + 'sid', + 'sin', + 'sio', + 'sit', + 'sla', + 'slo', + 'slv', + 'sma', + 'sme', + 'smi', + 'smj', + 'smn', + 'smo', + 'sms', + 'sna', + 'snd', + 'snk', + 'sog', + 'som', + 'son', + 'sot', + 'spa', + 'srd', + 'srn', + 'srp', + 'srr', + 'ssa', + 'ssw', + 'suk', + 'sun', + 'sus', + 'sux', + 'swa', + 'swe', + 'syc', + 'syr', + 'tah', + 'tai', + 'tam', + 'tat', + 'tel', + 'tem', + 'ter', + 'tet', + 'tgk', + 'tgl', + 'tha', + 'tib', + 'tig', + 'tir', + 'tiv', + 'tkl', + 'tlh', + 'tli', + 'tmh', + 'tog', + 'ton', + 'tpi', + 'tsi', + 'tsn', + 'tso', + 'tuk', + 'tum', + 'tup', + 'tur', + 'tut', + 'tvl', + 'twi', + 'tyv', + 'udm', + 'uga', + 'uig', + 'ukr', + 'umb', + 'und', + 'urd', + 'uzb', + 'vai', + 'ven', + 'vie', + 'vol', + 'vot', + 'wak', + 'wal', + 'war', + 'was', + 'wel', + 'wen', + 'wln', + 'wol', + 'xal', + 'xho', + 'yao', + 'yap', + 'yid', + 'yor', + 'ypk', + 'zap', + 'zbl', + 'zen', + 'zgh', + 'zha', + 'znd', + 'zul', + 'zun', + 'zxx', + 'zza' +); + + +-- +-- Name: language_relation; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.language_relation AS ENUM ( + 'original', + 'translated-from', + 'translated-into' +); + + +-- +-- Name: location_platform; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.location_platform AS ENUM ( + 'Project MUSE', + 'OAPEN', + 'DOAB', + 'JSTOR', + 'EBSCO Host', + 'OCLC KB', + 'ProQuest KB', + 'ProQuest ExLibris', + 'EBSCO KB', + 'JISC KB', + 'Other', + 'Google Books', + 'Internet Archive', + 'ScienceOpen', + 'SciELO Books', + 'Publisher Website', + 'Zenodo', + 'Thoth' +); + + +-- +-- Name: publication_type; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.publication_type AS ENUM ( + 'Paperback', + 'Hardback', + 'PDF', + 'HTML', + 'XML', + 'Epub', + 'Mobi', + 'AZW3', + 'DOCX', + 'FictionBook', + 'MP3', + 'WAV' +); + + +-- +-- Name: relation_type; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.relation_type AS ENUM ( + 'replaces', + 'has-translation', + 'has-part', + 'has-child', + 'is-replaced-by', + 'is-translation-of', + 'is-part-of', + 'is-child-of' +); + + +-- +-- Name: series_type; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.series_type AS ENUM ( + 'journal', + 'book-series' +); + + +-- +-- Name: subject_type; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.subject_type AS ENUM ( + 'bic', + 'bisac', + 'thema', + 'lcc', + 'custom', + 'keyword' +); + + +-- +-- Name: work_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.work_status AS ENUM ( + 'cancelled', + 'forthcoming', + 'postponed-indefinitely', + 'active', + 'withdrawn', + 'superseded' +); + + +-- +-- Name: work_type; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.work_type AS ENUM ( + 'book-chapter', + 'monograph', + 'edited-book', + 'textbook', + 'journal-issue', + 'book-set' +); + + +-- +-- Name: affiliation_work_updated_at_with_relations(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.affiliation_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM contribution + WHERE work.work_id = contribution.work_id AND contribution.contribution_id = OLD.contribution_id + OR work.work_id = contribution.work_id AND contribution.contribution_id = NEW.contribution_id; + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: contributor_work_updated_at_with_relations(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.contributor_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM contribution + -- No need to check OLD.contributor_id, as this will be the same as NEW.contributor_id in all relevant cases + -- (contributor_id can't be changed on contributors which are referenced by existing contributions) + WHERE work.work_id = contribution.work_id AND contribution.contributor_id = NEW.contributor_id; + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: diesel_manage_updated_at(regclass); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.diesel_manage_updated_at(_tbl regclass) RETURNS void + LANGUAGE plpgsql + AS $$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$; + + +-- +-- Name: diesel_set_updated_at(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.diesel_set_updated_at() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$; + + +-- +-- Name: imprint_work_updated_at_with_relations(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.imprint_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + WHERE imprint_id = NEW.imprint_id; + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: institution_work_updated_at_with_relations(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.institution_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + -- Same as contributor above (but can be connected to work via two different tables) + -- Use two separate UPDATE statements as this is much faster than combining the WHERE clauses + -- using OR (in tests, this caused several seconds' delay when saving institution updates) + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM funding + WHERE work.work_id = funding.work_id AND funding.institution_id = NEW.institution_id; + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM affiliation, contribution + WHERE work.work_id = contribution.work_id AND contribution.contribution_id = affiliation.contribution_id AND affiliation.institution_id = NEW.institution_id; + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: location_work_updated_at_with_relations(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.location_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM publication + WHERE work.work_id = publication.work_id AND publication.publication_id = OLD.publication_id + OR work.work_id = publication.work_id AND publication.publication_id = NEW.publication_id; + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: price_work_updated_at_with_relations(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.price_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM publication + WHERE work.work_id = publication.work_id AND publication.publication_id = OLD.publication_id + OR work.work_id = publication.work_id AND publication.publication_id = NEW.publication_id; + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: publication_chapter_no_dimensions(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.publication_chapter_no_dimensions() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + (SELECT work_type FROM work WHERE work.work_id = NEW.work_id) = 'book-chapter' AND ( + NEW.width_mm IS NOT NULL OR + NEW.width_in IS NOT NULL OR + NEW.height_mm IS NOT NULL OR + NEW.height_in IS NOT NULL OR + NEW.depth_mm IS NOT NULL OR + NEW.depth_in IS NOT NULL OR + NEW.weight_g IS NOT NULL OR + NEW.weight_oz IS NOT NULL + ) + ) THEN + RAISE EXCEPTION 'Chapters cannot have dimensions (Width/Height/Depth/Weight)'; + END IF; + RETURN NEW; +END; +$$; + + +-- +-- Name: publication_location_canonical_urls(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.publication_location_canonical_urls() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW.publication_type <> 'Hardback' AND + NEW.publication_type <> 'Paperback' AND + (SELECT COUNT(*) FROM location + WHERE location.publication_id = NEW.publication_id + AND location.canonical + AND (location.landing_page IS NULL OR location.full_text_url IS NULL) + ) > 0 + ) THEN + RAISE EXCEPTION 'Digital publications must have both Landing Page and Full Text URL in all their canonical locations'; + END IF; + RETURN NEW; +END; +$$; + + +-- +-- Name: publisher_work_updated_at_with_relations(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.publisher_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM imprint + -- Same as contributor above + WHERE work.imprint_id = imprint.imprint_id AND imprint.publisher_id = NEW.publisher_id; + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: series_work_updated_at_with_relations(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.series_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM issue + -- Same as contributor above (note that although series is also connected to work + -- via the imprint_id, changes to a series don't affect its imprint) + WHERE work.work_id = issue.work_id AND issue.series_id = NEW.series_id; + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: work_relation_work_updated_at_with_relations(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.work_relation_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + WHERE work_id = OLD.relator_work_id OR work_id = NEW.relator_work_id + OR work_id = OLD.related_work_id OR work_id = NEW.related_work_id; + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: work_set_updated_at(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.work_set_updated_at() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at AND + NEW.updated_at_with_relations IS NOT DISTINCT FROM OLD.updated_at_with_relations + ) THEN + NEW.updated_at := current_timestamp; + NEW.updated_at_with_relations := current_timestamp; + END IF; + RETURN NEW; +END; +$$; + + +-- +-- Name: work_updated_at_with_relations(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + WHERE work_id = OLD.work_id OR work_id = NEW.work_id; + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: work_work_updated_at_with_relations(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.work_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM work_relation + -- The positions of relator/related IDs in this statement don't matter, as + -- every work_relation record has a mirrored record with relator/related IDs swapped + WHERE work.work_id = work_relation.relator_work_id AND work_relation.related_work_id = NEW.work_id; + END IF; + RETURN NULL; +END; +$$; + + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: account; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.account ( + account_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + name text NOT NULL, + surname text NOT NULL, + email text NOT NULL, + hash bytea NOT NULL, + salt text NOT NULL, + is_superuser boolean DEFAULT false NOT NULL, + is_bot boolean DEFAULT false NOT NULL, + is_active boolean DEFAULT true NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + token text, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT account_email_check CHECK ((octet_length(email) >= 1)), + CONSTRAINT account_name_check CHECK ((octet_length(name) >= 1)), + CONSTRAINT account_salt_check CHECK ((octet_length(salt) >= 1)), + CONSTRAINT account_surname_check CHECK ((octet_length(surname) >= 1)), + CONSTRAINT account_token_check CHECK ((octet_length(token) >= 1)) +); + + +-- +-- Name: affiliation; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.affiliation ( + affiliation_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + contribution_id uuid NOT NULL, + institution_id uuid NOT NULL, + affiliation_ordinal integer NOT NULL, + "position" text, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT affiliation_affiliation_ordinal_check CHECK ((affiliation_ordinal > 0)), + CONSTRAINT affiliation_position_check CHECK ((octet_length("position") >= 1)) +); + + +-- +-- Name: affiliation_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.affiliation_history ( + affiliation_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + affiliation_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: contribution; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.contribution ( + work_id uuid NOT NULL, + contributor_id uuid NOT NULL, + contribution_type public.contribution_type NOT NULL, + main_contribution boolean DEFAULT true NOT NULL, + biography text, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + first_name text, + last_name text NOT NULL, + full_name text NOT NULL, + contribution_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + contribution_ordinal integer NOT NULL, + CONSTRAINT contribution_biography_check CHECK ((octet_length(biography) >= 1)), + CONSTRAINT contribution_contribution_ordinal_check CHECK ((contribution_ordinal > 0)), + CONSTRAINT contribution_first_name_check CHECK ((octet_length(first_name) >= 1)), + CONSTRAINT contribution_full_name_check CHECK ((octet_length(full_name) >= 1)), + CONSTRAINT contribution_last_name_check CHECK ((octet_length(last_name) >= 1)) +); + + +-- +-- Name: contribution_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.contribution_history ( + contribution_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + contribution_id uuid NOT NULL +); + + +-- +-- Name: contributor; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.contributor ( + contributor_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + first_name text, + last_name text NOT NULL, + full_name text NOT NULL, + orcid text, + website text, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT contributor_first_name_check CHECK ((octet_length(first_name) >= 1)), + CONSTRAINT contributor_full_name_check CHECK ((octet_length(full_name) >= 1)), + CONSTRAINT contributor_last_name_check CHECK ((octet_length(last_name) >= 1)), + CONSTRAINT contributor_orcid_check CHECK ((orcid ~ '^https:\/\/orcid\.org\/\d{4}-\d{4}-\d{4}-\d{3}[\dX]$'::text)), + CONSTRAINT contributor_website_check CHECK ((octet_length(website) >= 1)) +); + + +-- +-- Name: contributor_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.contributor_history ( + contributor_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + contributor_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: funding; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.funding ( + funding_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + work_id uuid NOT NULL, + institution_id uuid NOT NULL, + program text, + project_name text, + project_shortname text, + grant_number text, + jurisdiction text, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT funding_grant_number_check CHECK ((octet_length(grant_number) >= 1)), + CONSTRAINT funding_jurisdiction_check CHECK ((octet_length(jurisdiction) >= 1)), + CONSTRAINT funding_program_check CHECK ((octet_length(program) >= 1)), + CONSTRAINT funding_project_name_check CHECK ((octet_length(project_name) >= 1)), + CONSTRAINT funding_project_shortname_check CHECK ((octet_length(project_shortname) >= 1)) +); + + +-- +-- Name: funding_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.funding_history ( + funding_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + funding_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: imprint; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.imprint ( + imprint_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + publisher_id uuid NOT NULL, + imprint_name text NOT NULL, + imprint_url text, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + crossmark_doi text, + CONSTRAINT imprint_crossmark_doi_check CHECK ((crossmark_doi ~* 'https:\/\/doi.org\/10.\d{4,9}\/[-._\;\(\)\/:a-zA-Z0-9]+$'::text)), + CONSTRAINT imprint_imprint_name_check CHECK ((octet_length(imprint_name) >= 1)), + CONSTRAINT imprint_imprint_url_check CHECK ((imprint_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'::text)) +); + + +-- +-- Name: imprint_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.imprint_history ( + imprint_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + imprint_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: institution; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.institution ( + institution_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + institution_name text NOT NULL, + institution_doi text, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + ror text, + country_code public.country_code, + CONSTRAINT institution_institution_doi_check CHECK ((institution_doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._;()\/:a-zA-Z0-9<>+[\]]+$'::text)), + CONSTRAINT institution_institution_name_check CHECK ((octet_length(institution_name) >= 1)), + CONSTRAINT institution_ror_check CHECK ((ror ~ '^https:\/\/ror\.org\/0[a-hjkmnp-z0-9]{6}\d{2}$'::text)) +); + + +-- +-- Name: institution_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.institution_history ( + institution_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + institution_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: issue; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.issue ( + series_id uuid NOT NULL, + work_id uuid NOT NULL, + issue_ordinal integer NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + issue_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + CONSTRAINT issue_issue_ordinal_check CHECK ((issue_ordinal > 0)) +); + + +-- +-- Name: issue_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.issue_history ( + issue_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + issue_id uuid NOT NULL +); + + +-- +-- Name: language; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.language ( + language_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + work_id uuid NOT NULL, + language_code public.language_code NOT NULL, + language_relation public.language_relation NOT NULL, + main_language boolean DEFAULT false NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: language_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.language_history ( + language_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + language_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: location; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.location ( + location_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + publication_id uuid NOT NULL, + landing_page text, + full_text_url text, + location_platform public.location_platform DEFAULT 'Other'::public.location_platform NOT NULL, + canonical boolean DEFAULT false NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT location_full_text_url_check CHECK ((full_text_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'::text)), + CONSTRAINT location_landing_page_check CHECK ((landing_page ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'::text)), + CONSTRAINT location_url_check CHECK (((landing_page IS NOT NULL) OR (full_text_url IS NOT NULL))) +); + + +-- +-- Name: location_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.location_history ( + location_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + location_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: price; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.price ( + price_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + publication_id uuid NOT NULL, + currency_code public.currency_code NOT NULL, + unit_price double precision NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT price_unit_price_check CHECK ((unit_price > (0.0)::double precision)) +); + + +-- +-- Name: price_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.price_history ( + price_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + price_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: publication; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.publication ( + publication_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + publication_type public.publication_type NOT NULL, + work_id uuid NOT NULL, + isbn text, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + width_mm double precision, + width_in double precision, + height_mm double precision, + height_in double precision, + depth_mm double precision, + depth_in double precision, + weight_g double precision, + weight_oz double precision, + CONSTRAINT publication_depth_in_check CHECK ((depth_in > (0.0)::double precision)), + CONSTRAINT publication_depth_in_not_missing CHECK (((depth_in IS NOT NULL) OR (depth_mm IS NULL))), + CONSTRAINT publication_depth_mm_check CHECK ((depth_mm > (0.0)::double precision)), + CONSTRAINT publication_depth_mm_not_missing CHECK (((depth_mm IS NOT NULL) OR (depth_in IS NULL))), + CONSTRAINT publication_height_in_check CHECK ((height_in > (0.0)::double precision)), + CONSTRAINT publication_height_in_not_missing CHECK (((height_in IS NOT NULL) OR (height_mm IS NULL))), + CONSTRAINT publication_height_mm_check CHECK ((height_mm > (0.0)::double precision)), + CONSTRAINT publication_height_mm_not_missing CHECK (((height_mm IS NOT NULL) OR (height_in IS NULL))), + CONSTRAINT publication_isbn_check CHECK ((octet_length(isbn) = 17)), + CONSTRAINT publication_non_physical_no_dimensions CHECK ((((width_mm IS NULL) AND (width_in IS NULL) AND (height_mm IS NULL) AND (height_in IS NULL) AND (depth_mm IS NULL) AND (depth_in IS NULL) AND (weight_g IS NULL) AND (weight_oz IS NULL)) OR (publication_type = 'Paperback'::public.publication_type) OR (publication_type = 'Hardback'::public.publication_type))), + CONSTRAINT publication_weight_g_check CHECK ((weight_g > (0.0)::double precision)), + CONSTRAINT publication_weight_g_not_missing CHECK (((weight_g IS NOT NULL) OR (weight_oz IS NULL))), + CONSTRAINT publication_weight_oz_check CHECK ((weight_oz > (0.0)::double precision)), + CONSTRAINT publication_weight_oz_not_missing CHECK (((weight_oz IS NOT NULL) OR (weight_g IS NULL))), + CONSTRAINT publication_width_in_check CHECK ((width_in > (0.0)::double precision)), + CONSTRAINT publication_width_in_not_missing CHECK (((width_in IS NOT NULL) OR (width_mm IS NULL))), + CONSTRAINT publication_width_mm_check CHECK ((width_mm > (0.0)::double precision)), + CONSTRAINT publication_width_mm_not_missing CHECK (((width_mm IS NOT NULL) OR (width_in IS NULL))) +); + + +-- +-- Name: publication_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.publication_history ( + publication_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + publication_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: publisher; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.publisher ( + publisher_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + publisher_name text NOT NULL, + publisher_shortname text, + publisher_url text, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT publisher_publisher_name_check CHECK ((octet_length(publisher_name) >= 1)), + CONSTRAINT publisher_publisher_shortname_check CHECK ((octet_length(publisher_shortname) >= 1)), + CONSTRAINT publisher_publisher_url_check CHECK ((publisher_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'::text)) +); + + +-- +-- Name: publisher_account; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.publisher_account ( + account_id uuid NOT NULL, + publisher_id uuid NOT NULL, + is_admin boolean DEFAULT false NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: publisher_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.publisher_history ( + publisher_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + publisher_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: reference; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.reference ( + reference_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + work_id uuid NOT NULL, + reference_ordinal integer NOT NULL, + doi text, + unstructured_citation text, + issn text, + isbn text, + journal_title text, + article_title text, + series_title text, + volume_title text, + edition integer, + author text, + volume text, + issue text, + first_page text, + component_number text, + standard_designator text, + standards_body_name text, + standards_body_acronym text, + url text, + publication_date date, + retrieval_date date, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT reference_article_title_check CHECK ((octet_length(article_title) >= 1)), + CONSTRAINT reference_author_check CHECK ((octet_length(author) >= 1)), + CONSTRAINT reference_component_number_check CHECK ((octet_length(component_number) >= 1)), + CONSTRAINT reference_doi_andor_unstructured_citation CHECK (((doi IS NOT NULL) OR (unstructured_citation IS NOT NULL))), + CONSTRAINT reference_doi_check CHECK ((doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._;()\/:a-zA-Z0-9<>+[\]]+$'::text)), + CONSTRAINT reference_edition_check CHECK ((edition > 0)), + CONSTRAINT reference_first_page_check CHECK ((octet_length(first_page) >= 1)), + CONSTRAINT reference_isbn_check CHECK ((octet_length(isbn) = 17)), + CONSTRAINT reference_issn_check CHECK ((issn ~* '\d{4}\-\d{3}(\d|X)'::text)), + CONSTRAINT reference_issue_check CHECK ((octet_length(issue) >= 1)), + CONSTRAINT reference_journal_title_check CHECK ((octet_length(journal_title) >= 1)), + CONSTRAINT reference_reference_ordinal_check CHECK ((reference_ordinal > 0)), + CONSTRAINT reference_series_title_check CHECK ((octet_length(series_title) >= 1)), + CONSTRAINT reference_standard_citation_required_fields CHECK ((((standard_designator IS NOT NULL) AND (standards_body_name IS NOT NULL) AND (standards_body_acronym IS NOT NULL)) OR ((standard_designator IS NULL) AND (standards_body_name IS NULL) AND (standards_body_acronym IS NULL)))), + CONSTRAINT reference_standard_designator_check CHECK ((octet_length(standard_designator) >= 1)), + CONSTRAINT reference_standards_body_acronym_check CHECK ((octet_length(standards_body_acronym) >= 1)), + CONSTRAINT reference_standards_body_name_check CHECK ((octet_length(standards_body_name) >= 1)), + CONSTRAINT reference_unstructured_citation_check CHECK ((octet_length(unstructured_citation) >= 1)), + CONSTRAINT reference_url_check CHECK ((url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'::text)), + CONSTRAINT reference_volume_check CHECK ((octet_length(volume) >= 1)), + CONSTRAINT reference_volume_title_check CHECK ((octet_length(volume_title) >= 1)) +); + + +-- +-- Name: reference_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.reference_history ( + reference_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + reference_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: series; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.series ( + series_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + series_type public.series_type NOT NULL, + series_name text NOT NULL, + issn_print text, + issn_digital text, + series_url text, + imprint_id uuid NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + series_description text, + series_cfp_url text, + CONSTRAINT series_issn_digital_check CHECK ((issn_digital ~* '\d{4}\-\d{3}(\d|X)'::text)), + CONSTRAINT series_issn_print_check CHECK ((issn_print ~* '\d{4}\-\d{3}(\d|X)'::text)), + CONSTRAINT series_series_cfp_url_check CHECK ((series_cfp_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'::text)), + CONSTRAINT series_series_description_check CHECK ((octet_length(series_description) >= 1)), + CONSTRAINT series_series_name_check CHECK ((octet_length(series_name) >= 1)), + CONSTRAINT series_series_url_check CHECK ((series_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'::text)) +); + + +-- +-- Name: series_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.series_history ( + series_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + series_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: subject; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.subject ( + subject_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + work_id uuid NOT NULL, + subject_type public.subject_type NOT NULL, + subject_code text NOT NULL, + subject_ordinal integer NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT subject_subject_code_check CHECK ((octet_length(subject_code) >= 1)), + CONSTRAINT subject_subject_ordinal_check CHECK ((subject_ordinal > 0)) +); + + +-- +-- Name: subject_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.subject_history ( + subject_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + subject_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: work; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.work ( + work_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + work_type public.work_type NOT NULL, + work_status public.work_status NOT NULL, + full_title text NOT NULL, + title text NOT NULL, + subtitle text, + reference text, + edition integer, + imprint_id uuid NOT NULL, + doi text, + publication_date date, + place text, + page_count integer, + page_breakdown text, + image_count integer, + table_count integer, + audio_count integer, + video_count integer, + license text, + copyright_holder text, + landing_page text, + lccn text, + oclc text, + short_abstract text, + long_abstract text, + general_note text, + toc text, + cover_url text, + cover_caption text, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + first_page text, + last_page text, + page_interval text, + updated_at_with_relations timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + bibliography_note text, + withdrawn_date date, + CONSTRAINT work_active_publication_date_check CHECK ((((work_status = ANY (ARRAY['active'::public.work_status, 'withdrawn'::public.work_status, 'superseded'::public.work_status])) AND (publication_date IS NOT NULL)) OR (work_status <> ALL (ARRAY['active'::public.work_status, 'withdrawn'::public.work_status, 'superseded'::public.work_status])))), + CONSTRAINT work_active_withdrawn_date_check CHECK (((work_status = 'withdrawn'::public.work_status) OR (work_status = 'superseded'::public.work_status) OR ((work_status <> ALL (ARRAY['withdrawn'::public.work_status, 'superseded'::public.work_status])) AND (withdrawn_date IS NULL)))), + CONSTRAINT work_audio_count_check CHECK ((audio_count >= 0)), + CONSTRAINT work_bibliography_note_check CHECK ((octet_length(bibliography_note) >= 1)), + CONSTRAINT work_chapter_no_edition CHECK (((edition IS NULL) OR (work_type <> 'book-chapter'::public.work_type))), + CONSTRAINT work_chapter_no_lccn CHECK (((lccn IS NULL) OR (work_type <> 'book-chapter'::public.work_type))), + CONSTRAINT work_chapter_no_oclc CHECK (((oclc IS NULL) OR (work_type <> 'book-chapter'::public.work_type))), + CONSTRAINT work_chapter_no_toc CHECK (((toc IS NULL) OR (work_type <> 'book-chapter'::public.work_type))), + CONSTRAINT work_copyright_holder_check CHECK ((octet_length(copyright_holder) >= 1)), + CONSTRAINT work_cover_caption_check CHECK ((octet_length(cover_caption) >= 1)), + CONSTRAINT work_cover_url_check CHECK ((cover_url ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'::text)), + CONSTRAINT work_doi_check CHECK ((doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._;()\/:a-zA-Z0-9<>+[\]]+$'::text)), + CONSTRAINT work_edition_check CHECK ((edition > 0)), + CONSTRAINT work_first_page_check CHECK ((octet_length(first_page) >= 1)), + CONSTRAINT work_full_title_check CHECK ((octet_length(full_title) >= 1)), + CONSTRAINT work_general_note_check CHECK ((octet_length(general_note) >= 1)), + CONSTRAINT work_image_count_check CHECK ((image_count >= 0)), + CONSTRAINT work_inactive_no_withdrawn_date_check CHECK (((((work_status = 'withdrawn'::public.work_status) OR (work_status = 'superseded'::public.work_status)) AND (withdrawn_date IS NOT NULL)) OR (work_status <> ALL (ARRAY['withdrawn'::public.work_status, 'superseded'::public.work_status])))), + CONSTRAINT work_landing_page_check CHECK ((landing_page ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'::text)), + CONSTRAINT work_last_page_check CHECK ((octet_length(last_page) >= 1)), + CONSTRAINT work_lccn_check CHECK ((octet_length(lccn) >= 1)), + CONSTRAINT work_license_check CHECK ((license ~* '^[^:]*:\/\/(?:[^\/:]*:[^\/@]*@)?(?:[^\/:.]*\.)+([^:\/]+)'::text)), + CONSTRAINT work_long_abstract_check CHECK ((octet_length(long_abstract) >= 1)), + CONSTRAINT work_non_chapter_has_edition CHECK (((edition IS NOT NULL) OR (work_type = 'book-chapter'::public.work_type))), + CONSTRAINT work_non_chapter_no_first_page CHECK (((first_page IS NULL) OR (work_type = 'book-chapter'::public.work_type))), + CONSTRAINT work_non_chapter_no_last_page CHECK (((last_page IS NULL) OR (work_type = 'book-chapter'::public.work_type))), + CONSTRAINT work_non_chapter_no_page_interval CHECK (((page_interval IS NULL) OR (work_type = 'book-chapter'::public.work_type))), + CONSTRAINT work_oclc_check CHECK ((octet_length(oclc) >= 1)), + CONSTRAINT work_page_breakdown_check CHECK ((octet_length(page_breakdown) >= 1)), + CONSTRAINT work_page_count_check CHECK ((page_count > 0)), + CONSTRAINT work_page_interval_check CHECK ((octet_length(page_interval) >= 1)), + CONSTRAINT work_place_check CHECK ((octet_length(place) >= 1)), + CONSTRAINT work_reference_check CHECK ((octet_length(reference) >= 1)), + CONSTRAINT work_short_abstract_check CHECK ((octet_length(short_abstract) >= 1)), + CONSTRAINT work_subtitle_check CHECK ((octet_length(subtitle) >= 1)), + CONSTRAINT work_table_count_check CHECK ((table_count >= 0)), + CONSTRAINT work_title_check CHECK ((octet_length(title) >= 1)), + CONSTRAINT work_toc_check CHECK ((octet_length(toc) >= 1)), + CONSTRAINT work_video_count_check CHECK ((video_count >= 0)), + CONSTRAINT work_withdrawn_date_after_publication_date_check CHECK (((withdrawn_date IS NULL) OR (publication_date < withdrawn_date))) +); + + +-- +-- Name: work_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.work_history ( + work_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + work_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: work_relation; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.work_relation ( + work_relation_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + relator_work_id uuid NOT NULL, + related_work_id uuid NOT NULL, + relation_type public.relation_type NOT NULL, + relation_ordinal integer NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT work_relation_ids_check CHECK ((relator_work_id <> related_work_id)), + CONSTRAINT work_relation_relation_ordinal_check CHECK ((relation_ordinal > 0)) +); + + +-- +-- Name: work_relation_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.work_relation_history ( + work_relation_history_id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + work_relation_id uuid NOT NULL, + account_id uuid NOT NULL, + data jsonb NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: account account_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.account + ADD CONSTRAINT account_pkey PRIMARY KEY (account_id); + + +-- +-- Name: affiliation_history affiliation_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.affiliation_history + ADD CONSTRAINT affiliation_history_pkey PRIMARY KEY (affiliation_history_id); + + +-- +-- Name: affiliation affiliation_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.affiliation + ADD CONSTRAINT affiliation_pkey PRIMARY KEY (affiliation_id); + + +-- +-- Name: contribution contribution_contribution_ordinal_work_id_uniq; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contribution + ADD CONSTRAINT contribution_contribution_ordinal_work_id_uniq UNIQUE (contribution_ordinal, work_id); + + +-- +-- Name: contribution_history contribution_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contribution_history + ADD CONSTRAINT contribution_history_pkey PRIMARY KEY (contribution_history_id); + + +-- +-- Name: contribution contribution_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contribution + ADD CONSTRAINT contribution_pkey PRIMARY KEY (contribution_id); + + +-- +-- Name: contribution contribution_work_id_contributor_id_contribution_type_uniq; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contribution + ADD CONSTRAINT contribution_work_id_contributor_id_contribution_type_uniq UNIQUE (work_id, contributor_id, contribution_type); + + +-- +-- Name: contributor_history contributor_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contributor_history + ADD CONSTRAINT contributor_history_pkey PRIMARY KEY (contributor_history_id); + + +-- +-- Name: contributor contributor_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contributor + ADD CONSTRAINT contributor_pkey PRIMARY KEY (contributor_id); + + +-- +-- Name: institution_history funder_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.institution_history + ADD CONSTRAINT funder_history_pkey PRIMARY KEY (institution_history_id); + + +-- +-- Name: funding_history funding_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.funding_history + ADD CONSTRAINT funding_history_pkey PRIMARY KEY (funding_history_id); + + +-- +-- Name: funding funding_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.funding + ADD CONSTRAINT funding_pkey PRIMARY KEY (funding_id); + + +-- +-- Name: imprint_history imprint_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.imprint_history + ADD CONSTRAINT imprint_history_pkey PRIMARY KEY (imprint_history_id); + + +-- +-- Name: imprint imprint_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.imprint + ADD CONSTRAINT imprint_pkey PRIMARY KEY (imprint_id); + + +-- +-- Name: institution institution_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.institution + ADD CONSTRAINT institution_pkey PRIMARY KEY (institution_id); + + +-- +-- Name: issue_history issue_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.issue_history + ADD CONSTRAINT issue_history_pkey PRIMARY KEY (issue_history_id); + + +-- +-- Name: issue issue_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.issue + ADD CONSTRAINT issue_pkey PRIMARY KEY (issue_id); + + +-- +-- Name: issue issue_series_id_work_id_uniq; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.issue + ADD CONSTRAINT issue_series_id_work_id_uniq UNIQUE (series_id, work_id); + + +-- +-- Name: language_history language_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.language_history + ADD CONSTRAINT language_history_pkey PRIMARY KEY (language_history_id); + + +-- +-- Name: language language_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.language + ADD CONSTRAINT language_pkey PRIMARY KEY (language_id); + + +-- +-- Name: location_history location_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.location_history + ADD CONSTRAINT location_history_pkey PRIMARY KEY (location_history_id); + + +-- +-- Name: location location_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.location + ADD CONSTRAINT location_pkey PRIMARY KEY (location_id); + + +-- +-- Name: price_history price_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.price_history + ADD CONSTRAINT price_history_pkey PRIMARY KEY (price_history_id); + + +-- +-- Name: price price_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.price + ADD CONSTRAINT price_pkey PRIMARY KEY (price_id); + + +-- +-- Name: price price_publication_id_currency_code_uniq; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.price + ADD CONSTRAINT price_publication_id_currency_code_uniq UNIQUE (publication_id, currency_code); + + +-- +-- Name: publication_history publication_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publication_history + ADD CONSTRAINT publication_history_pkey PRIMARY KEY (publication_history_id); + + +-- +-- Name: publication publication_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publication + ADD CONSTRAINT publication_pkey PRIMARY KEY (publication_id); + + +-- +-- Name: publication publication_publication_type_work_id_uniq; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publication + ADD CONSTRAINT publication_publication_type_work_id_uniq UNIQUE (publication_type, work_id); + + +-- +-- Name: publisher_account publisher_account_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publisher_account + ADD CONSTRAINT publisher_account_pkey PRIMARY KEY (account_id, publisher_id); + + +-- +-- Name: publisher_history publisher_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publisher_history + ADD CONSTRAINT publisher_history_pkey PRIMARY KEY (publisher_history_id); + + +-- +-- Name: publisher publisher_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publisher + ADD CONSTRAINT publisher_pkey PRIMARY KEY (publisher_id); + + +-- +-- Name: reference_history reference_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reference_history + ADD CONSTRAINT reference_history_pkey PRIMARY KEY (reference_history_id); + + +-- +-- Name: reference reference_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reference + ADD CONSTRAINT reference_pkey PRIMARY KEY (reference_id); + + +-- +-- Name: reference reference_reference_ordinal_work_id_uniq; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reference + ADD CONSTRAINT reference_reference_ordinal_work_id_uniq UNIQUE (work_id, reference_ordinal); + + +-- +-- Name: series_history series_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.series_history + ADD CONSTRAINT series_history_pkey PRIMARY KEY (series_history_id); + + +-- +-- Name: series series_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.series + ADD CONSTRAINT series_pkey PRIMARY KEY (series_id); + + +-- +-- Name: subject_history subject_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subject_history + ADD CONSTRAINT subject_history_pkey PRIMARY KEY (subject_history_id); + + +-- +-- Name: subject subject_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subject + ADD CONSTRAINT subject_pkey PRIMARY KEY (subject_id); + + +-- +-- Name: work_history work_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work_history + ADD CONSTRAINT work_history_pkey PRIMARY KEY (work_history_id); + + +-- +-- Name: work work_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work + ADD CONSTRAINT work_pkey PRIMARY KEY (work_id); + + +-- +-- Name: work_relation_history work_relation_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work_relation_history + ADD CONSTRAINT work_relation_history_pkey PRIMARY KEY (work_relation_history_id); + + +-- +-- Name: work_relation work_relation_ordinal_type_uniq; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work_relation + ADD CONSTRAINT work_relation_ordinal_type_uniq UNIQUE (relation_ordinal, relator_work_id, relation_type); + + +-- +-- Name: work_relation work_relation_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work_relation + ADD CONSTRAINT work_relation_pkey PRIMARY KEY (work_relation_id); + + +-- +-- Name: work_relation work_relation_relator_related_uniq; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work_relation + ADD CONSTRAINT work_relation_relator_related_uniq UNIQUE (relator_work_id, related_work_id); + + +-- +-- Name: affiliation_uniq_ord_in_contribution_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX affiliation_uniq_ord_in_contribution_idx ON public.affiliation USING btree (contribution_id, affiliation_ordinal); + + +-- +-- Name: doi_uniq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX doi_uniq_idx ON public.work USING btree (lower(doi)); + + +-- +-- Name: email_uniq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_uniq_idx ON public.account USING btree (lower(email)); + + +-- +-- Name: idx_account_email; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_account_email ON public.account USING btree (email); + + +-- +-- Name: idx_affiliation_contribution_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_affiliation_contribution_id ON public.affiliation USING btree (contribution_id); + + +-- +-- Name: idx_affiliation_ordinal_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_affiliation_ordinal_asc ON public.affiliation USING btree (affiliation_ordinal, contribution_id); + + +-- +-- Name: idx_contribution_contributor_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_contribution_contributor_id ON public.contribution USING btree (contributor_id); + + +-- +-- Name: idx_contribution_ordinal_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_contribution_ordinal_asc ON public.contribution USING btree (contribution_ordinal, work_id); + + +-- +-- Name: idx_contribution_work_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_contribution_work_id ON public.contribution USING btree (work_id); + + +-- +-- Name: idx_contributor_full_name; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_contributor_full_name ON public.contributor USING btree (full_name); + + +-- +-- Name: idx_contributor_last_name; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_contributor_last_name ON public.contributor USING btree (last_name); + + +-- +-- Name: idx_contributor_orcid; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_contributor_orcid ON public.contributor USING btree (orcid); + + +-- +-- Name: idx_funding_program; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_funding_program ON public.funding USING btree (program); + + +-- +-- Name: idx_funding_work_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_funding_work_id ON public.funding USING btree (work_id); + + +-- +-- Name: idx_imprint_imprint_name; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_imprint_imprint_name ON public.imprint USING btree (imprint_name); + + +-- +-- Name: idx_imprint_imprint_url; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_imprint_imprint_url ON public.imprint USING btree (imprint_url); + + +-- +-- Name: idx_imprint_publisher_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_imprint_publisher_id ON public.imprint USING btree (publisher_id); + + +-- +-- Name: idx_institution_institution_doi; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_institution_institution_doi ON public.institution USING btree (institution_doi); + + +-- +-- Name: idx_institution_institution_name; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_institution_institution_name ON public.institution USING btree (institution_name); + + +-- +-- Name: idx_institution_ror; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_institution_ror ON public.institution USING btree (ror); + + +-- +-- Name: idx_issue_ordinal_series_id_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_issue_ordinal_series_id_asc ON public.issue USING btree (issue_ordinal, series_id); + + +-- +-- Name: idx_issue_ordinal_work_id_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_issue_ordinal_work_id_asc ON public.issue USING btree (issue_ordinal, work_id); + + +-- +-- Name: idx_language_language_code_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_language_language_code_asc ON public.language USING btree (language_code, work_id); + + +-- +-- Name: idx_location_location_platform_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_location_location_platform_asc ON public.location USING btree (location_platform, publication_id); + + +-- +-- Name: idx_price_currency_code_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_price_currency_code_asc ON public.price USING btree (currency_code, publication_id); + + +-- +-- Name: idx_publication_isbn; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_publication_isbn ON public.publication USING btree (isbn); + + +-- +-- Name: idx_publication_publication_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_publication_publication_type ON public.publication USING btree (publication_type); + + +-- +-- Name: idx_publication_work_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_publication_work_id ON public.publication USING btree (work_id); + + +-- +-- Name: idx_publisher_account_account_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_publisher_account_account_id ON public.publisher_account USING btree (account_id); + + +-- +-- Name: idx_publisher_publisher_name; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_publisher_publisher_name ON public.publisher USING btree (publisher_name); + + +-- +-- Name: idx_publisher_publisher_shortname; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_publisher_publisher_shortname ON public.publisher USING btree (publisher_shortname); + + +-- +-- Name: idx_reference_article_title; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_article_title ON public.reference USING btree (article_title); + + +-- +-- Name: idx_reference_author_substr; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_author_substr ON public.reference USING btree ("substring"(author, 1, 255)); + + +-- +-- Name: idx_reference_doi; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_doi ON public.reference USING btree (doi); + + +-- +-- Name: idx_reference_isbn; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_isbn ON public.reference USING btree (isbn); + + +-- +-- Name: idx_reference_issn; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_issn ON public.reference USING btree (issn); + + +-- +-- Name: idx_reference_journal_title; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_journal_title ON public.reference USING btree (journal_title); + + +-- +-- Name: idx_reference_series_title; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_series_title ON public.reference USING btree (series_title); + + +-- +-- Name: idx_reference_standard_designator; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_standard_designator ON public.reference USING btree (standard_designator); + + +-- +-- Name: idx_reference_standards_body_acronym; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_standards_body_acronym ON public.reference USING btree (standards_body_acronym); + + +-- +-- Name: idx_reference_standards_body_name; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_standards_body_name ON public.reference USING btree (standards_body_name); + + +-- +-- Name: idx_reference_unstructured_citation; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_unstructured_citation ON public.reference USING btree (unstructured_citation); + + +-- +-- Name: idx_reference_volume_title; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_volume_title ON public.reference USING btree (volume_title); + + +-- +-- Name: idx_reference_work_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reference_work_id ON public.reference USING btree (work_id); + + +-- +-- Name: idx_series_imprint_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_series_imprint_id ON public.series USING btree (imprint_id); + + +-- +-- Name: idx_series_issn_digital; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_series_issn_digital ON public.series USING btree (issn_digital); + + +-- +-- Name: idx_series_issn_print; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_series_issn_print ON public.series USING btree (issn_print); + + +-- +-- Name: idx_series_series_description; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_series_series_description ON public.series USING btree (series_description); + + +-- +-- Name: idx_series_series_name; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_series_series_name ON public.series USING btree (series_name); + + +-- +-- Name: idx_series_series_url; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_series_series_url ON public.series USING btree (series_url); + + +-- +-- Name: idx_subject_subject_code_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subject_subject_code_asc ON public.subject USING btree (subject_code, work_id); + + +-- +-- Name: idx_subject_subject_ordinal_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subject_subject_ordinal_asc ON public.subject USING btree (subject_ordinal, work_id); + + +-- +-- Name: idx_work_books_pub_date_desc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_books_pub_date_desc ON public.work USING btree (publication_date DESC) WHERE ((work_type = ANY (ARRAY['monograph'::public.work_type, 'edited-book'::public.work_type, 'textbook'::public.work_type])) AND (work_status = 'active'::public.work_status)); + + +-- +-- Name: idx_work_doi; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_doi ON public.work USING btree (doi); + + +-- +-- Name: idx_work_full_title_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_full_title_asc ON public.work USING btree (full_title, work_id); + + +-- +-- Name: idx_work_imprint_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_imprint_id ON public.work USING btree (imprint_id); + + +-- +-- Name: idx_work_landing_page; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_landing_page ON public.work USING btree (landing_page); + + +-- +-- Name: idx_work_long_abstract_substr; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_long_abstract_substr ON public.work USING btree ("substring"(long_abstract, 1, 255)); + + +-- +-- Name: idx_work_publication_date_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_publication_date_asc ON public.work USING btree (publication_date, work_id); + + +-- +-- Name: idx_work_publication_date_desc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_publication_date_desc ON public.work USING btree (publication_date DESC, work_id); + + +-- +-- Name: idx_work_reference; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_reference ON public.work USING btree (reference); + + +-- +-- Name: idx_work_relation_relation_ordinal_related_relation_type_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_relation_relation_ordinal_related_relation_type_asc ON public.work_relation USING btree (relation_ordinal, related_work_id, relation_type); + + +-- +-- Name: idx_work_relation_relation_ordinal_relator_relation_type_asc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_relation_relation_ordinal_relator_relation_type_asc ON public.work_relation USING btree (relation_ordinal, relator_work_id, relation_type); + + +-- +-- Name: idx_work_short_abstract_substr; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_short_abstract_substr ON public.work USING btree ("substring"(short_abstract, 1, 255)); + + +-- +-- Name: idx_work_type_status_pub_date_desc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_type_status_pub_date_desc ON public.work USING btree (work_type, work_status, publication_date DESC); + + +-- +-- Name: idx_work_updated_at_with_relations_desc; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_work_updated_at_with_relations_desc ON public.work USING btree (updated_at_with_relations DESC, work_id); + + +-- +-- Name: imprint_uniq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX imprint_uniq_idx ON public.imprint USING btree (lower(imprint_name)); + + +-- +-- Name: institution_doi_uniq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX institution_doi_uniq_idx ON public.institution USING btree (lower(institution_doi)); + + +-- +-- Name: issue_uniq_ord_in_series_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX issue_uniq_ord_in_series_idx ON public.issue USING btree (series_id, issue_ordinal); + + +-- +-- Name: language_uniq_work_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX language_uniq_work_idx ON public.language USING btree (work_id, language_code); + + +-- +-- Name: location_uniq_canonical_true_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX location_uniq_canonical_true_idx ON public.location USING btree (publication_id) WHERE canonical; + + +-- +-- Name: location_uniq_platform_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX location_uniq_platform_idx ON public.location USING btree (publication_id, location_platform) WHERE (NOT (location_platform = 'Other'::public.location_platform)); + + +-- +-- Name: orcid_uniq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX orcid_uniq_idx ON public.contributor USING btree (lower(orcid)); + + +-- +-- Name: publication_isbn_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX publication_isbn_idx ON public.publication USING btree (isbn); + + +-- +-- Name: publisher_uniq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX publisher_uniq_idx ON public.publisher USING btree (lower(publisher_name)); + + +-- +-- Name: series_issn_digital_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX series_issn_digital_idx ON public.series USING btree (issn_digital); + + +-- +-- Name: series_issn_print_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX series_issn_print_idx ON public.series USING btree (issn_print); + + +-- +-- Name: publication publication_chapter_no_dimensions_check; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER publication_chapter_no_dimensions_check BEFORE INSERT OR UPDATE ON public.publication FOR EACH ROW EXECUTE FUNCTION public.publication_chapter_no_dimensions(); + + +-- +-- Name: publication publication_location_canonical_urls_check; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER publication_location_canonical_urls_check BEFORE UPDATE ON public.publication FOR EACH ROW EXECUTE FUNCTION public.publication_location_canonical_urls(); + + +-- +-- Name: account set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.account FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: affiliation set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.affiliation FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: contribution set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.contribution FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: contributor set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.contributor FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: funding set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.funding FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: imprint set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.imprint FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: institution set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.institution FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: issue set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.issue FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: language set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.language FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: location set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.location FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: price set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.price FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: publication set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.publication FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: publisher set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.publisher FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: publisher_account set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.publisher_account FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: reference set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.reference FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: series set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.series FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: subject set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.subject FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: work set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.work FOR EACH ROW EXECUTE FUNCTION public.work_set_updated_at(); + + +-- +-- Name: work_relation set_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.work_relation FOR EACH ROW EXECUTE FUNCTION public.diesel_set_updated_at(); + + +-- +-- Name: affiliation set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.affiliation FOR EACH ROW EXECUTE FUNCTION public.affiliation_work_updated_at_with_relations(); + + +-- +-- Name: contribution set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.contribution FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); + + +-- +-- Name: contributor set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER UPDATE ON public.contributor FOR EACH ROW EXECUTE FUNCTION public.contributor_work_updated_at_with_relations(); + + +-- +-- Name: funding set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.funding FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); + + +-- +-- Name: imprint set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER UPDATE ON public.imprint FOR EACH ROW EXECUTE FUNCTION public.imprint_work_updated_at_with_relations(); + + +-- +-- Name: institution set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER UPDATE ON public.institution FOR EACH ROW EXECUTE FUNCTION public.institution_work_updated_at_with_relations(); + + +-- +-- Name: issue set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.issue FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); + + +-- +-- Name: language set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.language FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); + + +-- +-- Name: location set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.location FOR EACH ROW EXECUTE FUNCTION public.location_work_updated_at_with_relations(); + + +-- +-- Name: price set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.price FOR EACH ROW EXECUTE FUNCTION public.price_work_updated_at_with_relations(); + + +-- +-- Name: publication set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.publication FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); + + +-- +-- Name: publisher set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER UPDATE ON public.publisher FOR EACH ROW EXECUTE FUNCTION public.publisher_work_updated_at_with_relations(); + + +-- +-- Name: reference set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.reference FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); + + +-- +-- Name: series set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER UPDATE ON public.series FOR EACH ROW EXECUTE FUNCTION public.series_work_updated_at_with_relations(); + + +-- +-- Name: subject set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.subject FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); + + +-- +-- Name: work set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER UPDATE ON public.work FOR EACH ROW EXECUTE FUNCTION public.work_work_updated_at_with_relations(); + + +-- +-- Name: work_relation set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.work_relation FOR EACH ROW EXECUTE FUNCTION public.work_relation_work_updated_at_with_relations(); + + +-- +-- Name: affiliation affiliation_contribution_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.affiliation + ADD CONSTRAINT affiliation_contribution_id_fkey FOREIGN KEY (contribution_id) REFERENCES public.contribution(contribution_id) ON DELETE CASCADE; + + +-- +-- Name: affiliation_history affiliation_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.affiliation_history + ADD CONSTRAINT affiliation_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: affiliation_history affiliation_history_affiliation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.affiliation_history + ADD CONSTRAINT affiliation_history_affiliation_id_fkey FOREIGN KEY (affiliation_id) REFERENCES public.affiliation(affiliation_id) ON DELETE CASCADE; + + +-- +-- Name: affiliation affiliation_institution_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.affiliation + ADD CONSTRAINT affiliation_institution_id_fkey FOREIGN KEY (institution_id) REFERENCES public.institution(institution_id) ON DELETE CASCADE; + + +-- +-- Name: contribution contribution_contributor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contribution + ADD CONSTRAINT contribution_contributor_id_fkey FOREIGN KEY (contributor_id) REFERENCES public.contributor(contributor_id) ON DELETE CASCADE; + + +-- +-- Name: contribution_history contribution_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contribution_history + ADD CONSTRAINT contribution_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: contribution_history contribution_history_contribution_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contribution_history + ADD CONSTRAINT contribution_history_contribution_id_fkey FOREIGN KEY (contribution_id) REFERENCES public.contribution(contribution_id) ON DELETE CASCADE; + + +-- +-- Name: contribution contribution_work_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contribution + ADD CONSTRAINT contribution_work_id_fkey FOREIGN KEY (work_id) REFERENCES public.work(work_id) ON DELETE CASCADE; + + +-- +-- Name: contributor_history contributor_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contributor_history + ADD CONSTRAINT contributor_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: contributor_history contributor_history_contributor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contributor_history + ADD CONSTRAINT contributor_history_contributor_id_fkey FOREIGN KEY (contributor_id) REFERENCES public.contributor(contributor_id) ON DELETE CASCADE; + + +-- +-- Name: institution_history funder_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.institution_history + ADD CONSTRAINT funder_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: institution_history funder_history_funder_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.institution_history + ADD CONSTRAINT funder_history_funder_id_fkey FOREIGN KEY (institution_id) REFERENCES public.institution(institution_id) ON DELETE CASCADE; + + +-- +-- Name: funding funding_funder_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.funding + ADD CONSTRAINT funding_funder_id_fkey FOREIGN KEY (institution_id) REFERENCES public.institution(institution_id) ON DELETE CASCADE; + + +-- +-- Name: funding_history funding_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.funding_history + ADD CONSTRAINT funding_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: funding_history funding_history_funding_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.funding_history + ADD CONSTRAINT funding_history_funding_id_fkey FOREIGN KEY (funding_id) REFERENCES public.funding(funding_id) ON DELETE CASCADE; + + +-- +-- Name: funding funding_work_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.funding + ADD CONSTRAINT funding_work_id_fkey FOREIGN KEY (work_id) REFERENCES public.work(work_id) ON DELETE CASCADE; + + +-- +-- Name: imprint_history imprint_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.imprint_history + ADD CONSTRAINT imprint_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: imprint_history imprint_history_imprint_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.imprint_history + ADD CONSTRAINT imprint_history_imprint_id_fkey FOREIGN KEY (imprint_id) REFERENCES public.imprint(imprint_id) ON DELETE CASCADE; + + +-- +-- Name: imprint imprint_publisher_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.imprint + ADD CONSTRAINT imprint_publisher_id_fkey FOREIGN KEY (publisher_id) REFERENCES public.publisher(publisher_id) ON DELETE CASCADE; + + +-- +-- Name: issue_history issue_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.issue_history + ADD CONSTRAINT issue_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: issue_history issue_history_issue_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.issue_history + ADD CONSTRAINT issue_history_issue_id_fkey FOREIGN KEY (issue_id) REFERENCES public.issue(issue_id) ON DELETE CASCADE; + + +-- +-- Name: issue issue_series_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.issue + ADD CONSTRAINT issue_series_id_fkey FOREIGN KEY (series_id) REFERENCES public.series(series_id) ON DELETE CASCADE; + + +-- +-- Name: issue issue_work_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.issue + ADD CONSTRAINT issue_work_id_fkey FOREIGN KEY (work_id) REFERENCES public.work(work_id) ON DELETE CASCADE; + + +-- +-- Name: language_history language_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.language_history + ADD CONSTRAINT language_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: language_history language_history_language_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.language_history + ADD CONSTRAINT language_history_language_id_fkey FOREIGN KEY (language_id) REFERENCES public.language(language_id) ON DELETE CASCADE; + + +-- +-- Name: language language_work_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.language + ADD CONSTRAINT language_work_id_fkey FOREIGN KEY (work_id) REFERENCES public.work(work_id) ON DELETE CASCADE; + + +-- +-- Name: location_history location_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.location_history + ADD CONSTRAINT location_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: location_history location_history_location_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.location_history + ADD CONSTRAINT location_history_location_id_fkey FOREIGN KEY (location_id) REFERENCES public.location(location_id) ON DELETE CASCADE; + + +-- +-- Name: location location_publication_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.location + ADD CONSTRAINT location_publication_id_fkey FOREIGN KEY (publication_id) REFERENCES public.publication(publication_id) ON DELETE CASCADE; + + +-- +-- Name: price_history price_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.price_history + ADD CONSTRAINT price_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: price_history price_history_price_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.price_history + ADD CONSTRAINT price_history_price_id_fkey FOREIGN KEY (price_id) REFERENCES public.price(price_id) ON DELETE CASCADE; + + +-- +-- Name: price price_publication_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.price + ADD CONSTRAINT price_publication_id_fkey FOREIGN KEY (publication_id) REFERENCES public.publication(publication_id) ON DELETE CASCADE; + + +-- +-- Name: publication_history publication_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publication_history + ADD CONSTRAINT publication_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: publication_history publication_history_publication_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publication_history + ADD CONSTRAINT publication_history_publication_id_fkey FOREIGN KEY (publication_id) REFERENCES public.publication(publication_id) ON DELETE CASCADE; + + +-- +-- Name: publication publication_work_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publication + ADD CONSTRAINT publication_work_id_fkey FOREIGN KEY (work_id) REFERENCES public.work(work_id) ON DELETE CASCADE; + + +-- +-- Name: publisher_account publisher_account_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publisher_account + ADD CONSTRAINT publisher_account_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id) ON DELETE CASCADE; + + +-- +-- Name: publisher_account publisher_account_publisher_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publisher_account + ADD CONSTRAINT publisher_account_publisher_id_fkey FOREIGN KEY (publisher_id) REFERENCES public.publisher(publisher_id) ON DELETE CASCADE; + + +-- +-- Name: publisher_history publisher_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publisher_history + ADD CONSTRAINT publisher_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: publisher_history publisher_history_publisher_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.publisher_history + ADD CONSTRAINT publisher_history_publisher_id_fkey FOREIGN KEY (publisher_id) REFERENCES public.publisher(publisher_id) ON DELETE CASCADE; + + +-- +-- Name: reference_history reference_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reference_history + ADD CONSTRAINT reference_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: reference_history reference_history_reference_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reference_history + ADD CONSTRAINT reference_history_reference_id_fkey FOREIGN KEY (reference_id) REFERENCES public.reference(reference_id) ON DELETE CASCADE; + + +-- +-- Name: reference reference_work_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reference + ADD CONSTRAINT reference_work_id_fkey FOREIGN KEY (work_id) REFERENCES public.work(work_id) ON DELETE CASCADE; + + +-- +-- Name: series_history series_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.series_history + ADD CONSTRAINT series_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: series_history series_history_series_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.series_history + ADD CONSTRAINT series_history_series_id_fkey FOREIGN KEY (series_id) REFERENCES public.series(series_id) ON DELETE CASCADE; + + +-- +-- Name: series series_imprint_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.series + ADD CONSTRAINT series_imprint_id_fkey FOREIGN KEY (imprint_id) REFERENCES public.imprint(imprint_id) ON DELETE CASCADE; + + +-- +-- Name: subject_history subject_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subject_history + ADD CONSTRAINT subject_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: subject_history subject_history_subject_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subject_history + ADD CONSTRAINT subject_history_subject_id_fkey FOREIGN KEY (subject_id) REFERENCES public.subject(subject_id) ON DELETE CASCADE; + + +-- +-- Name: subject subject_work_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subject + ADD CONSTRAINT subject_work_id_fkey FOREIGN KEY (work_id) REFERENCES public.work(work_id) ON DELETE CASCADE; + + +-- +-- Name: work_history work_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work_history + ADD CONSTRAINT work_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: work_history work_history_work_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work_history + ADD CONSTRAINT work_history_work_id_fkey FOREIGN KEY (work_id) REFERENCES public.work(work_id) ON DELETE CASCADE; + + +-- +-- Name: work work_imprint_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work + ADD CONSTRAINT work_imprint_id_fkey FOREIGN KEY (imprint_id) REFERENCES public.imprint(imprint_id) ON DELETE CASCADE; + + +-- +-- Name: work_relation work_relation_active_passive_pair; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work_relation + ADD CONSTRAINT work_relation_active_passive_pair FOREIGN KEY (relator_work_id, related_work_id) REFERENCES public.work_relation(related_work_id, relator_work_id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: work_relation_history work_relation_history_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work_relation_history + ADD CONSTRAINT work_relation_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(account_id); + + +-- +-- Name: work_relation_history work_relation_history_work_relation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work_relation_history + ADD CONSTRAINT work_relation_history_work_relation_id_fkey FOREIGN KEY (work_relation_id) REFERENCES public.work_relation(work_relation_id) ON DELETE CASCADE; + + +-- +-- Name: work_relation work_relation_related_work_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work_relation + ADD CONSTRAINT work_relation_related_work_id_fkey FOREIGN KEY (related_work_id) REFERENCES public.work(work_id) ON DELETE CASCADE; + + +-- +-- Name: work_relation work_relation_relator_work_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.work_relation + ADD CONSTRAINT work_relation_relator_work_id_fkey FOREIGN KEY (relator_work_id) REFERENCES public.work(work_id) ON DELETE CASCADE; + diff --git a/thoth-api/migrations/20251203_v1.0.0/down.sql b/thoth-api/migrations/20251203_v1.0.0/down.sql new file mode 100644 index 00000000..0c80e7a0 --- /dev/null +++ b/thoth-api/migrations/20251203_v1.0.0/down.sql @@ -0,0 +1,55 @@ +------------------------------------------------------------------------------- +-- 1. Drop the current deterministic work_relation_work_updated_at_with_relations +-- and its trigger +------------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS set_work_relation_updated_at_with_relations ON work_relation; +DROP FUNCTION IF EXISTS work_relation_work_updated_at_with_relations() CASCADE; + +------------------------------------------------------------------------------- +-- 2. Restore the previous work_relation_work_updated_at_with_relations() +-- that bumps all involved works whenever a relation row changes +------------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION work_relation_work_updated_at_with_relations() RETURNS trigger AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + WHERE work_id = OLD.relator_work_id OR work_id = NEW.relator_work_id + OR work_id = OLD.related_work_id OR work_id = NEW.related_work_id; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR UPDATE OR DELETE ON work_relation + FOR EACH ROW EXECUTE PROCEDURE work_relation_work_updated_at_with_relations(); + +------------------------------------------------------------------------------- +-- 3. Restore work_work_updated_at_with_relations() and its trigger on work +------------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION work_work_updated_at_with_relations() RETURNS trigger AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM work_relation + -- The positions of relator/related IDs in this statement don't matter, as + -- every work_relation record has a mirrored record with relator/related IDs swapped + WHERE work.work_id = work_relation.relator_work_id AND work_relation.related_work_id = NEW.work_id; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON work; + +CREATE TRIGGER set_work_updated_at_with_relations + AFTER UPDATE ON work + FOR EACH ROW EXECUTE PROCEDURE work_work_updated_at_with_relations(); diff --git a/thoth-api/migrations/20251203_v1.0.0/up.sql b/thoth-api/migrations/20251203_v1.0.0/up.sql new file mode 100644 index 00000000..d08ed037 --- /dev/null +++ b/thoth-api/migrations/20251203_v1.0.0/up.sql @@ -0,0 +1,52 @@ +------------------------------------------------------------------------------- +-- 1. Remove the helper function, and associated triggers, that propagates +-- from work -> related works +------------------------------------------------------------------------------- + +DROP FUNCTION IF EXISTS work_work_updated_at_with_relations() CASCADE; + +------------------------------------------------------------------------------- +-- 2. Redefine work_relation_work_updated_at_with_relations() to update the +-- two endpoint works in deterministic order (LEAST/ GREATEST). +------------------------------------------------------------------------------- + +DROP FUNCTION IF EXISTS work_relation_work_updated_at_with_relations() CASCADE; + +CREATE OR REPLACE FUNCTION work_relation_work_updated_at_with_relations() + RETURNS trigger AS $$ +DECLARE + w1 uuid; -- smaller work_id + w2 uuid; -- larger work_id +BEGIN + -- If nothing really changed, skip + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NULL; + END IF; + + -- Determine the two work IDs involved in this relation + IF TG_OP = 'DELETE' THEN + w1 := LEAST(OLD.relator_work_id, OLD.related_work_id); + w2 := GREATEST(OLD.relator_work_id, OLD.related_work_id); + ELSE + w1 := LEAST(NEW.relator_work_id, NEW.related_work_id); + w2 := GREATEST(NEW.relator_work_id, NEW.related_work_id); + END IF; + + -- Always lock/update in deterministic order: smaller ID first, then larger + UPDATE work + SET updated_at_with_relations = current_timestamp + WHERE work_id = w1; + + IF w2 IS DISTINCT FROM w1 THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + WHERE work_id = w2; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_work_relation_updated_at_with_relations + AFTER INSERT OR UPDATE OR DELETE ON work_relation + FOR EACH ROW EXECUTE PROCEDURE work_relation_work_updated_at_with_relations(); diff --git a/thoth-api/migrations/20251204_v1.0.0/down.sql b/thoth-api/migrations/20251204_v1.0.0/down.sql new file mode 100644 index 00000000..a34cab43 --- /dev/null +++ b/thoth-api/migrations/20251204_v1.0.0/down.sql @@ -0,0 +1,24 @@ +ALTER TABLE affiliation + DROP CONSTRAINT affiliation_affiliation_ordinal_contribution_id_uniq; + +CREATE UNIQUE INDEX affiliation_uniq_ord_in_contribution_idx ON affiliation(contribution_id, affiliation_ordinal); + +ALTER TABLE contribution + DROP CONSTRAINT contribution_contribution_ordinal_work_id_uniq, + ADD CONSTRAINT contribution_contribution_ordinal_work_id_uniq UNIQUE (contribution_ordinal, work_id); + +ALTER TABLE issue + DROP CONSTRAINT issue_issue_ordinal_series_id_uniq; + +CREATE UNIQUE INDEX issue_uniq_ord_in_series_idx ON issue(series_id, issue_ordinal); + +ALTER TABLE reference + DROP CONSTRAINT reference_reference_ordinal_work_id_uniq, + ADD CONSTRAINT reference_reference_ordinal_work_id_uniq UNIQUE (work_id, reference_ordinal); + +ALTER TABLE subject + DROP CONSTRAINT subject_ordinal_type_uniq; + +ALTER TABLE work_relation + DROP CONSTRAINT work_relation_ordinal_type_uniq, + ADD CONSTRAINT work_relation_ordinal_type_uniq UNIQUE (relation_ordinal, relator_work_id, relation_type); diff --git a/thoth-api/migrations/20251204_v1.0.0/up.sql b/thoth-api/migrations/20251204_v1.0.0/up.sql new file mode 100644 index 00000000..6ab14fde --- /dev/null +++ b/thoth-api/migrations/20251204_v1.0.0/up.sql @@ -0,0 +1,43 @@ +ALTER TABLE affiliation + ADD CONSTRAINT affiliation_affiliation_ordinal_contribution_id_uniq UNIQUE (contribution_id, affiliation_ordinal) DEFERRABLE INITIALLY IMMEDIATE; + +DROP INDEX IF EXISTS affiliation_uniq_ord_in_contribution_idx; + +ALTER TABLE contribution + DROP CONSTRAINT contribution_contribution_ordinal_work_id_uniq, + ADD CONSTRAINT contribution_contribution_ordinal_work_id_uniq UNIQUE (work_id, contribution_ordinal) DEFERRABLE INITIALLY IMMEDIATE; + +ALTER TABLE issue + ADD CONSTRAINT issue_issue_ordinal_series_id_uniq UNIQUE (series_id, issue_ordinal) DEFERRABLE INITIALLY IMMEDIATE; + +DROP INDEX IF EXISTS issue_uniq_ord_in_series_idx; + +ALTER TABLE reference + DROP CONSTRAINT reference_reference_ordinal_work_id_uniq, + ADD CONSTRAINT reference_reference_ordinal_work_id_uniq UNIQUE (work_id, reference_ordinal) DEFERRABLE INITIALLY IMMEDIATE; + +-- There were previously no database constraints on subject ordinals, so multiple subjects +-- of the same type could have the same ordinal. We want to enforce a stricter hierarchy, +-- which requires renumbering existing duplicates. Keep existing ordering where ordinals +-- are distinctive, otherwise renumber them based on the order in which they were created. +-- Note that records created prior to the introduction of `created_at` in v0.2.11 may have +-- identical default values for the creation timestamp. Therefore, we perform a backup +-- sort on the system column `ctid`; although this value is subject to change and +-- should not be relied upon, it should give a suitable rough ordering here. +-- !!! This is irreversible +UPDATE subject + SET subject_ordinal = s.rownum + FROM ( + SELECT + subject_id, + row_number() OVER (PARTITION BY work_id,subject_type ORDER BY subject_ordinal,created_at,ctid) AS rownum + FROM subject + ) s + WHERE subject.subject_id = s.subject_id; + +ALTER TABLE subject + ADD CONSTRAINT subject_ordinal_type_uniq UNIQUE (work_id, subject_ordinal, subject_type) DEFERRABLE INITIALLY IMMEDIATE; + +ALTER TABLE work_relation + DROP CONSTRAINT work_relation_ordinal_type_uniq, + ADD CONSTRAINT work_relation_ordinal_type_uniq UNIQUE (relator_work_id, relation_ordinal, relation_type) DEFERRABLE INITIALLY IMMEDIATE; diff --git a/thoth-api/migrations/20251205_v1.0.0/down.sql b/thoth-api/migrations/20251205_v1.0.0/down.sql new file mode 100644 index 00000000..1be09582 --- /dev/null +++ b/thoth-api/migrations/20251205_v1.0.0/down.sql @@ -0,0 +1,141 @@ +-- Add title-related columns back to the work table +ALTER TABLE work + ADD COLUMN full_title TEXT CHECK (octet_length(full_title) >= 1), + ADD COLUMN title TEXT CHECK (octet_length(title) >= 1), + ADD COLUMN subtitle TEXT CHECK (octet_length(subtitle) >= 1); + +-- Migrate data back from title table to work table +UPDATE work w +SET + full_title = regexp_replace(t.full_title, '^(.*)$', '\\1'), + title = regexp_replace(t.title, '^(.*)$', '\\1'), + subtitle = CASE WHEN t.subtitle IS NOT NULL THEN regexp_replace(t.subtitle, '^(.*)$', '\\1') ELSE NULL END +FROM title t +WHERE w.work_id = t.work_id + AND t.canonical = TRUE; + +-- Drop the unique index for canonical titles +DROP INDEX IF EXISTS title_uniq_locale_idx; +-- Drop the unique index for locale codes +DROP INDEX IF EXISTS title_unique_canonical_true_idx; + +-- Drop the title_history table +DROP TABLE title_history; + +-- Drop the title table +DROP TABLE title; + +-- Recreate short_abstract and long_abstract columns in the work table +ALTER TABLE work + ADD COLUMN short_abstract TEXT CHECK (octet_length(short_abstract) >= 1), + ADD COLUMN long_abstract TEXT CHECK (octet_length(long_abstract) >= 1); + +-- ----------------------------------------------------------------------------- +-- Reverse Conversion Function +-- ----------------------------------------------------------------------------- +-- This function attempts to convert a JATS XML string back into a format that +-- resembles the original plaintext or Markdown. This is the reverse of the +-- `convert_to_jats` function from the `up` migration. +-- +-- NOTE: This is a best-effort reversal. The primary goal is to make the data +-- readable and usable, not to restore the original format with 100% fidelity. +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION convert_from_jats(jats_in TEXT) +RETURNS TEXT AS $$ +DECLARE + processed_content TEXT := jats_in; +BEGIN + -- Return NULL immediately if input is NULL or empty. + IF processed_content IS NULL OR processed_content = '' THEN + RETURN NULL; + END IF; + + -- The order of replacements is important to handle nested tags correctly. + + -- Convert JATS tags back to a Markdown-like format. + processed_content := regexp_replace(processed_content, '([^<]+)', '[\2](\1)', 'gi'); + processed_content := regexp_replace(processed_content, '([^<]+)', '**\1**', 'gi'); + processed_content := regexp_replace(processed_content, '([^<]+)', '*\1*', 'gi'); + processed_content := regexp_replace(processed_content, '([^<]+)', '`\1`', 'gi'); + processed_content := regexp_replace(processed_content, '([^<]+)', '\1', 'gi'); -- Revert small-caps to original text + processed_content := regexp_replace(processed_content, ']*>([^<]+)', '^\1^', 'gi'); -- A possible representation for superscript + processed_content := regexp_replace(processed_content, ']*>([^<]+)', '~\1~', 'gi'); -- A possible representation for subscript + processed_content := regexp_replace(processed_content, '', E'\n', 'gi'); + + -- Remove paragraph tags and handle the spacing. + -- Replace closing tags with double newlines to separate paragraphs. + processed_content := regexp_replace(processed_content, '

', E'\n\n', 'gi'); + -- Strip any remaining opening paragraph tags. + processed_content := regexp_replace(processed_content, '

', '', 'gi'); + + -- Clean up any leftover simple HTML tags that were not converted. + processed_content := regexp_replace(processed_content, '<[^>]+>', '', 'g'); + + -- Trim leading/trailing whitespace that may result from tag removal. + processed_content := trim(processed_content); + + RETURN processed_content; +END; +$$ LANGUAGE plpgsql; + + +-- Migrate data back from the abstract table to the work table using the reverse conversion +UPDATE work +SET + short_abstract = convert_from_jats(abstract.content) +FROM + abstract +WHERE + abstract.work_id = work.work_id + AND abstract.abstract_type = 'short' + AND abstract.canonical = TRUE; + +UPDATE work +SET + long_abstract = convert_from_jats(abstract.content) +FROM + abstract +WHERE + abstract.work_id = work.work_id + AND abstract.abstract_type = 'long' + AND abstract.canonical = TRUE; + +-- Drop unique indexes created for the abstract table +DROP INDEX IF EXISTS abstract_unique_canonical_true_idx; +DROP INDEX IF EXISTS abstract_uniq_locale_idx; + +-- Drop the abstract_history table +DROP TABLE abstract_history; +-- Drop the abstract table and its related objects +DROP TABLE IF EXISTS abstract; + +-- Drop the AbstractType enum +DROP TYPE IF EXISTS abstract_type; + +ALTER TABLE contribution + ADD COLUMN biography TEXT CHECK (octet_length(biography) >= 1); + +-- Migrate data back from the abstract table to the work table using the reverse conversion +UPDATE contribution +SET + biography = convert_from_jats(biography.content) +FROM + biography +WHERE + biography.contribution_id = contribution.contribution_id + AND biography.canonical = TRUE; + +-- Drop unique indexes created for the biography table +DROP INDEX IF EXISTS biography_unique_canonical_true_idx; +DROP INDEX IF EXISTS biography_uniq_locale_idx; + +-- Drop the biography_history table +DROP TABLE biography_history; +-- Drop the biography table and its related objects +DROP TABLE IF EXISTS biography; + +-- Drop the locale_code enum type +DROP TYPE locale_code; + +-- Clean up the reverse conversion function +DROP FUNCTION convert_from_jats(TEXT); \ No newline at end of file diff --git a/thoth-api/migrations/20251205_v1.0.0/up.sql b/thoth-api/migrations/20251205_v1.0.0/up.sql new file mode 100644 index 00000000..7ba9df84 --- /dev/null +++ b/thoth-api/migrations/20251205_v1.0.0/up.sql @@ -0,0 +1,390 @@ +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Create locale enum type +CREATE TYPE locale_code AS ENUM ( + 'af', 'af_na', 'af_za', 'agq', 'agq_cm', 'ak', 'ak_gh', 'sq', 'sq_al', 'am', 'am_et', 'aig', + 'ar', 'ar_dz', 'ar_bh', 'ar_eg', 'ar_iq', 'ar_jo', 'ar_kw', 'ar_lb', 'ar_ly', 'ar_ma', 'ar_om', + 'ar_qa', 'ar_sa', 'ar_sd', 'ar_sy', 'ar_tn', 'ar_ae', 'ar_001', 'ar_ye', 'hy', 'hy_am', 'as', + 'as_in', 'ast', 'ast_es', 'asa', 'asa_tz', 'az', 'az_cyrl', 'az_cyrl_az', 'az_latn', + 'az_latn_az', 'ksf', 'ksf_cm', 'bah', 'bm', 'bm_ml', 'bas', 'bas_cm', 'eu', 'eu_es', 'be', 'be_by', + 'bem', 'bem_zm', 'bez', 'bez_tz', 'bn', 'bn_bd', 'bn_in', 'brx', 'brx_in', 'bs', 'bs_ba', 'br', + 'br_fr', 'bg', 'bg_bg', 'my', 'my_mm', 'ca', 'ca_es', 'ckb', 'kmr', 'sdh', 'tzm', 'tzm_latn', + 'tzm_latn_ma', 'chr', 'chr_us', 'cgg', 'cgg_ug', 'zh', 'zh_hans', 'zh_cn', 'zh_hans_cn', + 'zh_hans_hk', 'zh_hans_mo', 'zh_hans_sg', 'zh_hant', 'zh_hant_hk', 'zh_hant_mo', 'zh_hant_tw', + 'swc', 'swc_cd', 'kw', 'kw_gb', 'hr', 'hr_hr', 'cs', 'cs_cz', 'da', 'da_dk', 'dua', 'dua_cm', + 'dv', 'nl', 'nl_aw', 'nl_be', 'nl_cw', 'nl_nl', 'nl_sx', 'ebu', 'ebu_ke', 'en', 'en_ai', + 'en_as', 'en_au', 'en_at', 'en_bb', 'en_be', 'en_bz', 'en_bm', 'en_bw', 'en_io', 'en_bi', 'en_cm', + 'en_ca', 'en_ky', 'en_cx', 'en_cc', 'en_ck', 'en_cy', 'en_dk', 'en_dg', 'en_dm', 'en_eg', 'en_er', + 'en_eu', 'en_fk', 'en_fj', 'en_fi', 'en_gm', 'en_de', 'en_gh', 'en_gi', 'en_gd', 'en_gu', 'en_gg', + 'en_gy', 'en_hk', 'en_in', 'en_ie', 'en_im', 'en_il', 'en_jm', 'en_je', 'en_ke', 'en_ki', 'en_kw', + 'en_ls', 'en_mo', 'en_mg', 'en_mw', 'en_my', 'en_mt', 'en_mh', 'en_mu', 'en_fm', 'en_ms', 'en_na', + 'en_nr', 'en_nl', 'en_nz', 'en_ng', 'en_nu', 'en_nf', 'en_mp', 'en_no', 'en_pa', 'en_pk', 'en_pw', + 'en_pg', 'en_ph', 'en_pn', 'en_pr', 'en_rw', 'en_ws', 'en_sa', 'en_sc', 'en_sl', 'en_sg', 'en_sx', + 'en_si', 'en_sb', 'en_ss', 'en_sh', 'en_kn', 'en_lc', 'svc', 'vic', 'en_sd', 'en_sz', 'en_se', + 'en_ch', 'en_tz', 'en_tk', 'en_to', 'en_tt', 'en_tv', 'en_za', 'en_ae', 'en_um', 'en_vi', + 'en_us_posix', 'en_ug', 'en_gb', 'en_us', 'en_vu', 'en_zm', 'en_zw', 'eo', 'et', 'et_ee', + 'ee', 'ee_gh', 'ee_tg', 'ewo', 'ewo_cm', 'fo', 'fo_fo', 'fil', 'fil_ph', 'fi', 'fi_fi', 'fr', + 'fr_be', 'fr_bj', 'fr_bf', 'fr_bi', 'fr_cm', 'fr_ca', 'fr_cf', 'fr_td', 'fr_km', 'fr_cg', 'fr_cd', + 'fr_ci', 'fr_dj', 'fr_gq', 'fr_fr', 'fr_gf', 'fr_ga', 'fr_gp', 'fr_gn', 'fr_lu', 'fr_mg', 'fr_ml', + 'fr_mq', 'fr_yt', 'fr_mc', 'fr_ne', 'fr_rw', 'fr_re', 'fr_bl', 'fr_mf', 'fr_mu', 'fr_sn', 'fr_ch', + 'fr_tg', 'ff', 'ff_sn', 'gl', 'gl_es', 'lao', 'lg', 'lg_ug', 'ka', 'ka_ge', 'de', 'de_at', 'de_be', + 'de_de', 'de_li', 'de_lu', 'de_ch', 'el', 'el_cy', 'el_gr', 'gu', 'gu_in', 'guz', 'guz_ke', 'ha', + 'ha_latn', 'ha_latn_gh', 'ha_latn_ne', 'ha_latn_ng', 'haw', 'haw_us', 'he', 'he_il', 'hi', 'hi_in', + 'hu', 'hu_hu', 'is', 'is_is', 'ig', 'ig_ng', 'smn', 'smn_fi', 'id', 'id_id', 'ga', 'ga_ie', 'it', + 'it_it', 'it_ch', 'ja', 'ja_jp', 'dyo', 'dyo_sn', 'kea', 'kea_cv', 'kab', 'kab_dz', 'kl', 'kl_gl', + 'kln', 'kln_ke', 'kam', 'kam_ke', 'kn', 'kn_in', 'kaa', 'kk', 'kk_cyrl', 'kk_cyrl_kz', 'km', 'km_kh', + 'ki', 'ki_ke', 'rw', 'rw_rw', 'kok', 'kok_in', 'ko', 'ko_kr', 'khq', 'khq_ml', 'ses', 'ses_ml', 'nmg', + 'nmg_cm', 'ky', 'lag', 'lag_tz', 'lv', 'lv_lv', 'lir', 'ln', 'ln_cg', 'ln_cd', 'lt', 'lt_lt', 'lu', + 'lu_cd', 'luo', 'luo_ke', 'luy', 'luy_ke', 'mk', 'mk_mk', 'jmc', 'jmc_tz', 'mgh', 'mgh_mz', 'kde', + 'kde_tz', 'mg', 'mg_mg', 'ms', 'ms_bn', 'ms_my', 'ml', 'ml_in', 'mt', 'mt_mt', 'gv', 'gv_gb', 'mr', + 'mr_in', 'mas', 'mas_ke', 'mas_tz', 'mer', 'mer_ke', 'mn', 'mfe', 'mfe_mu', 'mua', 'mua_cm', 'naq', + 'naq_na', 'ne', 'ne_in', 'ne_np', 'se', 'se_fi', 'se_no', 'se_se', 'nd', 'nd_zw', 'nb', 'nb_no', 'nn', + 'nn_no', 'nus', 'nus_sd', 'nyn', 'nyn_ug', 'or', 'or_in', 'om', 'om_et', 'om_ke', 'ps', 'ps_af', 'fa', + 'fa_af', 'fa_ir', 'pl', 'pl_pl', 'pt', 'pt_ao', 'pt_br', 'pt_gw', 'pt_mz', 'pt_pt', 'pt_st', 'pa', + 'pa_arab', 'pa_arab_pk', 'pa_guru', 'pa_guru_in', 'ro', 'ro_md', 'ro_ro', 'rm', 'rm_ch', 'rof', + 'rof_tz', 'rn', 'rn_bi', 'ru', 'ru_md', 'ru_ru', 'ru_ua', 'rwk', 'rwk_tz', 'saq', 'saq_ke', 'sg', + 'sg_cf', 'sbp', 'sbp_tz', 'sa', 'gd', 'gd_gb', 'seh', 'seh_mz', 'sr', 'sr_cyrl', 'sr_cyrl_ba', + 'sr_cyrl_me', 'sr_cyrl_rs', 'sr_latn', 'sr_latn_ba', 'sr_latn_me', 'sr_latn_rs', 'ksb', 'ksb_tz', + 'sn', 'sn_zw', 'ii', 'ii_cn', 'si', 'si_lk', 'sk', 'sk_sk', 'sl', 'sl_si', 'xog', 'xog_ug', 'so', + 'so_dj', 'so_et', 'so_ke', 'so_so', 'es', 'es_ar', 'es_bo', 'es_cl', 'es_co', 'es_cr', 'es_do', 'es_ec', + 'es_sv', 'es_gq', 'es_gt', 'es_hn', 'es_419', 'es_mx', 'es_ni', 'es_pa', 'es_py', 'es_pe', 'es_pr', + 'es_es', 'es_us', 'es_uy', 'es_ve', 'sw', 'sw_ke', 'sw_tz', 'sv', 'sv_fi', 'sv_se', 'gsw', 'gsw_ch', + 'shi', 'shi_latn', 'shi_latn_ma', 'shi_tfng', 'shi_tfng_ma', 'dav', 'dav_ke', 'tg', 'ta', 'ta_in', + 'ta_lk', 'twq', 'twq_ne', 'mi', 'te', 'te_in', 'teo', 'teo_ke', 'teo_ug', 'th', 'th_th', 'bo', 'bo_cn', + 'bo_in', 'ti', 'ti_er', 'ti_et', 'to', 'to_to', 'tr', 'tk', 'tr_tr', 'tch', 'uk', 'uk_ua', 'ur', 'ur_in', + 'ur_pk', 'ug', 'ug_cn', 'uz', 'uz_arab', 'uz_arab_af', 'uz_cyrl', 'uz_cyrl_uz', 'uz_latn', 'uz_latn_uz', + 'vai', 'vai_latn', 'vai_latn_lr', 'vai_vaii', 'vai_vaii_lr', 'val', 'val_es', 'ca_es_valencia', 'vi', + 'vi_vn', 'vun', 'vun_tz', 'cy', 'cy_gb', 'wo', 'xh', 'yav', 'yav_cm', 'yo', 'yo_ng', 'dje', 'dje_ne', + 'zu', 'zu_za' +); + +-- ----------------------------------------------------------------------------- +-- Conversion Function +-- ----------------------------------------------------------------------------- +-- This function attempts to detect the format of the input text (HTML, Markdown, +-- or Plaintext) and converts it into a basic JATS XML structure. +-- NOTE: This function uses heuristics and regular expressions for conversion. It +-- covers common cases but is not a full-fledged parser. It is designed to be +-- sufficient for this one-time data migration. +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION convert_to_jats(content_in TEXT) +RETURNS TEXT AS $$ +DECLARE + processed_content TEXT := content_in; +BEGIN + -- Return NULL immediately if input is NULL or empty. + IF processed_content IS NULL OR processed_content = '' THEN + RETURN NULL; + END IF; + + -- The CASE statement detects the format and applies conversion rules. + CASE + -- A) HTML Detection: Looks for common HTML tags. Now includes . + WHEN processed_content ~* '<(p|em|i|strong|b|sup|sub|sc|code|a|br)\b' THEN + -- Convert HTML tags to their JATS equivalents. + processed_content := regexp_replace(processed_content, ']*>(.*?)', '\2', 'gi'); + processed_content := regexp_replace(processed_content, '<(strong|b)>(.*?)', '\2', 'gi'); + processed_content := regexp_replace(processed_content, '<(em|i)>(.*?)', '\2', 'gi'); + processed_content := regexp_replace(processed_content, '(.*?)', '\1', 'gi'); + processed_content := regexp_replace(processed_content, '', '', 'gi'); + -- , , and are valid in JATS, so they are left as is. + + -- B) Markdown Detection: Looks for Markdown syntax like **, *, ``, etc. + WHEN processed_content ~ '(\*\*|__).+?\1' OR + processed_content ~ '(?\1', 'g'); + processed_content := regexp_replace(processed_content, '\*\*(.+?)\*\*', '\1', 'g'); + processed_content := regexp_replace(processed_content, '__(.+?)__', '\1', 'g'); + processed_content := regexp_replace(processed_content, '\*(.+?)\*', '\1', 'g'); + processed_content := regexp_replace(processed_content, '_(.+?)_', '\1', 'g'); + processed_content := regexp_replace(processed_content, '`([^`]+)`', '\1', 'g'); + processed_content := regexp_replace(processed_content, ' \n', '\n', 'g'); + + -- Wrap the result in

tags as Markdown is just a fragment. + processed_content := '

' || processed_content || '

'; + -- Convert double newlines to paragraph breaks. + processed_content := regexp_replace(processed_content, '\n\n', '

', 'g'); + + -- C) Plaintext (Default Case) + ELSE + -- For plaintext, convert all-caps words to tags, then wrap in

tags and handle newlines. + -- This rule assumes that words in all caps (e.g., "NASA") should be rendered in small-caps. + processed_content := regexp_replace(processed_content, '\b([A-Z]{2,})\b', '\1', 'g'); + + -- Wrap the content in

tags and convert newlines. + processed_content := '

' || processed_content || '

'; + processed_content := regexp_replace(processed_content, E'\n\n', '

', 'g'); + processed_content := regexp_replace(processed_content, E'\n', '', 'g'); + END CASE; + + -- Return the processed content without the wrapper. + RETURN processed_content; + +END; +$$ LANGUAGE plpgsql; + +-- ----------------------------------------------------------------------------- +-- Title Conversion Function +-- ----------------------------------------------------------------------------- +-- Similar to convert_to_jats but does NOT wrap content in

tags. +-- This is used specifically for titles which should not have paragraph wrappers. +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION convert_to_jats_title(content_in TEXT) +RETURNS TEXT AS $$ +DECLARE + processed_content TEXT := content_in; +BEGIN + -- Return NULL immediately if input is NULL or empty. + IF processed_content IS NULL OR processed_content = '' THEN + RETURN NULL; + END IF; + + -- The CASE statement detects the format and applies conversion rules. + CASE + -- A) HTML Detection: Looks for common HTML tags. Now includes . + WHEN processed_content ~* '<(p|em|i|strong|b|sup|sub|sc|code|a|br)\b' THEN + -- Convert HTML tags to their JATS equivalents. + processed_content := regexp_replace(processed_content, ']*>(.*?)', '\2', 'gi'); + processed_content := regexp_replace(processed_content, '<(strong|b)>(.*?)', '\2', 'gi'); + processed_content := regexp_replace(processed_content, '<(em|i)>(.*?)', '\2', 'gi'); + processed_content := regexp_replace(processed_content, '(.*?)', '\1', 'gi'); + processed_content := regexp_replace(processed_content, '', '', 'gi'); + -- Remove any existing

tags that might wrap the content + processed_content := regexp_replace(processed_content, '^

(.*)

$', '\1', 'g'); + -- , , and are valid in JATS, so they are left as is. + + -- B) Markdown Detection: Looks for Markdown syntax like **, *, ``, etc. + WHEN processed_content ~ '(\*\*|__).+?\1' OR + processed_content ~ '(?\1', 'g'); + processed_content := regexp_replace(processed_content, '\*\*(.+?)\*\*', '\1', 'g'); + processed_content := regexp_replace(processed_content, '__(.+?)__', '\1', 'g'); + processed_content := regexp_replace(processed_content, '\*(.+?)\*', '\1', 'g'); + processed_content := regexp_replace(processed_content, '_(.+?)_', '\1', 'g'); + processed_content := regexp_replace(processed_content, '`([^`]+)`', '\1', 'g'); + processed_content := regexp_replace(processed_content, ' \n', '\n', 'g'); + -- Convert newlines to breaks (no paragraph wrapping) + processed_content := regexp_replace(processed_content, E'\n', '', 'g'); + + -- C) Plaintext (Default Case) + ELSE + -- For plaintext, convert all-caps words to tags, then handle newlines. + -- This rule assumes that words in all caps (e.g., "NASA") should be rendered in small-caps. + processed_content := regexp_replace(processed_content, '\b([A-Z]{2,})\b', '\1', 'g'); + + -- Convert newlines to breaks (no paragraph wrapping) + processed_content := regexp_replace(processed_content, E'\n', '', 'g'); + END CASE; + + -- Return the processed content without paragraph wrappers. + RETURN processed_content; + +END; +$$ LANGUAGE plpgsql; + +-- Create the title table +CREATE TABLE IF NOT EXISTS title ( + title_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + work_id UUID NOT NULL REFERENCES work (work_id) ON DELETE CASCADE, + locale_code locale_code NOT NULL, + full_title TEXT NOT NULL CHECK (octet_length(full_title) >= 1), + title TEXT NOT NULL CHECK (octet_length(title) >= 1), + subtitle TEXT CHECK (octet_length(subtitle) >= 1), + canonical BOOLEAN NOT NULL DEFAULT FALSE +); + +-- Create the title_history table +CREATE TABLE IF NOT EXISTS title_history ( + title_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + title_id UUID NOT NULL REFERENCES title (title_id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES account (account_id) ON DELETE CASCADE, + data JSONB NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Migrate existing work titles to the title table with English locale +INSERT INTO title (title_id, work_id, locale_code, full_title, title, subtitle, canonical) +SELECT + uuid_generate_v4(), + work_id, + 'en'::locale_code, + convert_to_jats_title(full_title), + convert_to_jats_title(title), + CASE WHEN subtitle IS NOT NULL THEN convert_to_jats_title(subtitle) ELSE NULL END, + TRUE +FROM work +WHERE full_title IS NOT NULL + AND title IS NOT NULL; + +-- Only allow one canonical title per work +CREATE UNIQUE INDEX IF NOT EXISTS title_unique_canonical_true_idx ON title(work_id) + WHERE canonical; + +-- Only allow one instance of each locale per work +CREATE UNIQUE INDEX IF NOT EXISTS title_uniq_locale_idx ON title(work_id, locale_code); + +-- Drop title-related columns from the work table +ALTER TABLE work + DROP COLUMN full_title, + DROP COLUMN title, + DROP COLUMN subtitle; + +-- Create AbstractType enum +CREATE TYPE abstract_type AS ENUM ( + 'short', + 'long' +); + +-- Create the abstract table +CREATE TABLE IF NOT EXISTS abstract ( + abstract_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + work_id UUID NOT NULL REFERENCES work (work_id) ON DELETE CASCADE, + content TEXT NOT NULL CHECK (octet_length(content) >= 1), + locale_code locale_code NOT NULL, + abstract_type abstract_type NOT NULL DEFAULT 'short', + canonical BOOLEAN NOT NULL DEFAULT FALSE +); + +-- Create the abstract_history table +CREATE TABLE IF NOT EXISTS abstract_history ( + abstract_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + abstract_id UUID NOT NULL REFERENCES abstract (abstract_id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES account (account_id) ON DELETE CASCADE, + data JSONB NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Insert short abstracts into the abstract table using the conversion function +INSERT INTO abstract (abstract_id, work_id, content, locale_code, abstract_type, canonical) +SELECT + uuid_generate_v4() AS abstract_id, + work_id, + convert_to_jats(short_abstract) AS content, + 'en'::locale_code, -- Assuming 'en' as the default locale code + 'short'::abstract_type, + TRUE +FROM + work +WHERE + short_abstract IS NOT NULL AND short_abstract != ''; + +-- Insert long abstracts into the abstract table using the conversion function +INSERT INTO abstract (abstract_id, work_id, content, locale_code, abstract_type, canonical) +SELECT + uuid_generate_v4() AS abstract_id, + work_id, + convert_to_jats(long_abstract) AS content, + 'en'::locale_code, -- Assuming 'en' as the default locale code + 'long'::abstract_type, + TRUE +FROM + work +WHERE + long_abstract IS NOT NULL AND long_abstract != ''; + +-- Only allow one canonical abstract per work +CREATE UNIQUE INDEX IF NOT EXISTS abstract_unique_canonical_true_idx +ON abstract(work_id, abstract_type) +WHERE canonical; + +-- Only allow one instance of each locale per work +CREATE UNIQUE INDEX IF NOT EXISTS abstract_uniq_locale_idx +ON abstract(work_id, locale_code, abstract_type); + +-- Drop title-related columns from the work table +ALTER TABLE work + DROP COLUMN short_abstract, + DROP COLUMN long_abstract; + +-- Create the abstract table +CREATE TABLE IF NOT EXISTS biography ( + biography_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + contribution_id UUID NOT NULL REFERENCES contribution (contribution_id) ON DELETE CASCADE, + content TEXT NOT NULL CHECK (octet_length(content) >= 1), + canonical BOOLEAN NOT NULL DEFAULT FALSE, + locale_code locale_code NOT NULL +); + +-- Create the biography_history table +CREATE TABLE IF NOT EXISTS biography_history ( + biography_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + biography_id UUID NOT NULL REFERENCES biography (biography_id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES account (account_id) ON DELETE CASCADE, + data JSONB NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Migrate existing contribution biographies to the biography table with English locale +INSERT INTO biography (biography_id, contribution_id, content, canonical, locale_code) +SELECT + uuid_generate_v4(), + contribution_id, + convert_to_jats(biography) AS content, + TRUE, + 'en'::locale_code +FROM contribution +WHERE biography IS NOT NULL; + +-- Only allow one canonical biography per contribution +CREATE UNIQUE INDEX IF NOT EXISTS biography_unique_canonical_true_idx +ON biography(contribution_id) +WHERE canonical; + +-- Only allow one instance of each locale per contribution +CREATE UNIQUE INDEX IF NOT EXISTS biography_uniq_locale_idx +ON biography(contribution_id, locale_code); + +-- Drop title-related columns from the work table +ALTER TABLE contribution + DROP COLUMN biography; + +-- Clean up the conversion functions after the migration is complete +DROP FUNCTION convert_to_jats(TEXT); +DROP FUNCTION convert_to_jats_title(TEXT); + +-- +-- Name: biography_work_updated_at_with_relations(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.biography_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql +AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM contribution + WHERE work.work_id = contribution.work_id AND contribution.contribution_id = OLD.contribution_id + OR work.work_id = contribution.work_id AND contribution.contribution_id = NEW.contribution_id; + END IF; + RETURN NULL; +END; +$$; + +-- +-- Name: affiliation set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.biography FOR EACH ROW EXECUTE FUNCTION public.biography_work_updated_at_with_relations(); + + +-- +-- Name: title set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.title FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); + +-- +-- Name: abstract set_work_updated_at_with_relations; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.abstract FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); diff --git a/thoth-api/migrations/20251212_v1.0.0/down.sql b/thoth-api/migrations/20251212_v1.0.0/down.sql new file mode 100644 index 00000000..f777d56f --- /dev/null +++ b/thoth-api/migrations/20251212_v1.0.0/down.sql @@ -0,0 +1,19 @@ +DROP TABLE contact_history; +DROP TABLE contact; + +ALTER TABLE publisher + DROP COLUMN accessibility_statement, + DROP COLUMN accessibility_report_url; + +ALTER TABLE publication + DROP CONSTRAINT check_accessibility_standard_rules, + DROP CONSTRAINT check_additional_standard_pdf_epub, + DROP CONSTRAINT check_standard_or_exception, + DROP COLUMN accessibility_standard, + DROP COLUMN accessibility_additional_standard, + DROP COLUMN accessibility_exception, + DROP COLUMN accessibility_report_url; + +DROP TYPE contact_type; +DROP TYPE accessibility_exception; +DROP TYPE accessibility_standard; diff --git a/thoth-api/migrations/20251212_v1.0.0/up.sql b/thoth-api/migrations/20251212_v1.0.0/up.sql new file mode 100644 index 00000000..6e62bb15 --- /dev/null +++ b/thoth-api/migrations/20251212_v1.0.0/up.sql @@ -0,0 +1,116 @@ +CREATE TYPE contact_type AS ENUM ( + 'Accessibility' +); + +CREATE TABLE contact ( + contact_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + publisher_id UUID NOT NULL REFERENCES publisher(publisher_id) ON DELETE CASCADE, + contact_type contact_type NOT NULL DEFAULT 'Accessibility', + email TEXT NOT NULL CHECK (octet_length(email) >= 1), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT contact_contact_type_publisher_id_uniq UNIQUE (publisher_id, contact_type) +); +SELECT diesel_manage_updated_at('contact'); +CREATE INDEX idx_contact_email ON contact (email); + +CREATE TABLE contact_history ( + contact_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + contact_id UUID NOT NULL REFERENCES contact(contact_id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES account(account_id), + data JSONB NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE publisher + ADD COLUMN accessibility_statement TEXT CHECK (octet_length(accessibility_statement) >= 1), + ADD COLUMN accessibility_report_url TEXT CHECK (octet_length(accessibility_report_url) >= 1); + +CREATE TYPE accessibility_standard AS ENUM ( + 'wcag-21-aa', + 'wcag-21-aaa', + 'wcag-22-aa', + 'wcag-22-aaa', + 'epub-a11y-10-aa', + 'epub-a11y-10-aaa', + 'epub-a11y-11-aa', + 'epub-a11y-11-aaa', + 'pdf-ua-1', + 'pdf-ua-2' +); + +CREATE TYPE accessibility_exception AS ENUM ( + 'micro-enterprises', + 'disproportionate-burden', + 'fundamental-alteration' +); + +ALTER TABLE publication + ADD COLUMN accessibility_standard accessibility_standard, -- WCAG only + ADD COLUMN accessibility_additional_standard accessibility_standard, -- EPUB or PDF only + ADD COLUMN accessibility_exception accessibility_exception, + ADD COLUMN accessibility_report_url TEXT, + + -- Either standards or exception (or none, for excluded types) + ADD CONSTRAINT check_standard_or_exception + CHECK ( + ( + accessibility_exception IS NULL + AND accessibility_standard IS NOT NULL + ) + OR ( + accessibility_exception IS NOT NULL + AND accessibility_standard IS NULL + AND accessibility_additional_standard IS NULL + ) + OR ( + accessibility_exception IS NULL + AND accessibility_standard IS NULL + AND accessibility_additional_standard IS NULL + ) + ), + + -- Ensure additional_standard is only used for PDFs or EPUBs + ADD CONSTRAINT check_additional_standard_pdf_epub + CHECK ( + accessibility_additional_standard IS NULL + OR publication_type IN ('PDF', 'Epub') + ), + + -- Ensure standards are valid per publication type + ADD CONSTRAINT check_accessibility_standard_rules + CHECK ( + CASE publication_type + WHEN 'Paperback' THEN accessibility_standard IS NULL AND accessibility_additional_standard IS NULL AND accessibility_exception IS NULL + WHEN 'Hardback' THEN accessibility_standard IS NULL AND accessibility_additional_standard IS NULL AND accessibility_exception IS NULL + WHEN 'MP3' THEN accessibility_standard IS NULL AND accessibility_additional_standard IS NULL AND accessibility_exception IS NULL + WHEN 'WAV' THEN accessibility_standard IS NULL AND accessibility_additional_standard IS NULL AND accessibility_exception IS NULL + WHEN 'PDF' THEN ( + (accessibility_standard IS NULL OR accessibility_standard IN ( + 'wcag-21-aa','wcag-21-aaa', + 'wcag-22-aa','wcag-22-aaa' + )) + AND + (accessibility_additional_standard IS NULL OR accessibility_additional_standard IN ('pdf-ua-1','pdf-ua-2')) + ) + WHEN 'Epub' THEN ( + (accessibility_standard IS NULL OR accessibility_standard IN ( + 'wcag-21-aa','wcag-21-aaa', + 'wcag-22-aa','wcag-22-aaa' + )) + AND + (accessibility_additional_standard IS NULL OR accessibility_additional_standard IN ( + 'epub-a11y-10-aa','epub-a11y-10-aaa', + 'epub-a11y-11-aa','epub-a11y-11-aaa' + )) + ) + ELSE ( + (accessibility_standard IS NULL OR accessibility_standard IN ( + 'wcag-21-aa','wcag-21-aaa', + 'wcag-22-aa','wcag-22-aaa' + )) + AND + accessibility_additional_standard IS NULL + ) + END + ); diff --git a/thoth-api/migrations/20260107_v1.0.0/down.sql b/thoth-api/migrations/20260107_v1.0.0/down.sql new file mode 100644 index 00000000..2cd2be6e --- /dev/null +++ b/thoth-api/migrations/20260107_v1.0.0/down.sql @@ -0,0 +1,103 @@ +-- Recreate the `account` table +CREATE TABLE account ( + account_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL CHECK (octet_length(name) >= 1), + surname TEXT NOT NULL CHECK (octet_length(surname) >= 1), + email TEXT NOT NULL CHECK (octet_length(email) >= 1), + hash BYTEA NOT NULL, + salt TEXT NOT NULL CHECK (octet_length(salt) >= 1), + is_superuser BOOLEAN NOT NULL DEFAULT False, + is_bot BOOLEAN NOT NULL DEFAULT False, + is_active BOOLEAN NOT NULL DEFAULT True, + token TEXT NULL CHECK (OCTET_LENGTH(token) >= 1), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +SELECT diesel_manage_updated_at('account'); + +-- case-insensitive UNIQ index on email +CREATE UNIQUE INDEX email_uniq_idx ON account(lower(email)); + +-- Recreate the `publisher_account` table +CREATE TABLE publisher_account ( + account_id UUID NOT NULL REFERENCES account(account_id) ON DELETE CASCADE, + publisher_id UUID NOT NULL REFERENCES publisher(publisher_id) ON DELETE CASCADE, + is_admin BOOLEAN NOT NULL DEFAULT False, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account_id, publisher_id) +); +SELECT diesel_manage_updated_at('publisher_account'); + +-- Rename column user_id → account_id and change type to UUID +ALTER TABLE abstract_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE affiliation_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE biography_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE contact_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE contribution_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE contributor_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE funding_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE imprint_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE institution_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE issue_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE language_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE location_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE price_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE publication_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE publisher_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE reference_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE series_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE subject_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE title_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE work_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE work_relation_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; + +ALTER TABLE abstract_history RENAME COLUMN user_id TO account_id; +ALTER TABLE affiliation_history RENAME COLUMN user_id TO account_id; +ALTER TABLE biography_history RENAME COLUMN user_id TO account_id; +ALTER TABLE contact_history RENAME COLUMN user_id TO account_id; +ALTER TABLE contribution_history RENAME COLUMN user_id TO account_id; +ALTER TABLE contributor_history RENAME COLUMN user_id TO account_id; +ALTER TABLE funding_history RENAME COLUMN user_id TO account_id; +ALTER TABLE imprint_history RENAME COLUMN user_id TO account_id; +ALTER TABLE institution_history RENAME COLUMN user_id TO account_id; +ALTER TABLE issue_history RENAME COLUMN user_id TO account_id; +ALTER TABLE language_history RENAME COLUMN user_id TO account_id; +ALTER TABLE location_history RENAME COLUMN user_id TO account_id; +ALTER TABLE price_history RENAME COLUMN user_id TO account_id; +ALTER TABLE publication_history RENAME COLUMN user_id TO account_id; +ALTER TABLE publisher_history RENAME COLUMN user_id TO account_id; +ALTER TABLE reference_history RENAME COLUMN user_id TO account_id; +ALTER TABLE series_history RENAME COLUMN user_id TO account_id; +ALTER TABLE subject_history RENAME COLUMN user_id TO account_id; +ALTER TABLE title_history RENAME COLUMN user_id TO account_id; +ALTER TABLE work_history RENAME COLUMN user_id TO account_id; +ALTER TABLE work_relation_history RENAME COLUMN user_id TO account_id; + +-- Restore foreign key constraints +ALTER TABLE abstract_history ADD CONSTRAINT abstract_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE affiliation_history ADD CONSTRAINT affiliation_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE biography_history ADD CONSTRAINT biography_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE contact_history ADD CONSTRAINT contact_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE contribution_history ADD CONSTRAINT contribution_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE contributor_history ADD CONSTRAINT contributor_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE funding_history ADD CONSTRAINT funding_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE imprint_history ADD CONSTRAINT imprint_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE institution_history ADD CONSTRAINT institution_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE issue_history ADD CONSTRAINT issue_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE language_history ADD CONSTRAINT language_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE location_history ADD CONSTRAINT location_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE price_history ADD CONSTRAINT price_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE publication_history ADD CONSTRAINT publication_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE publisher_history ADD CONSTRAINT publisher_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE reference_history ADD CONSTRAINT reference_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE series_history ADD CONSTRAINT series_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE subject_history ADD CONSTRAINT subject_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE title_history ADD CONSTRAINT title_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE work_history ADD CONSTRAINT work_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE work_relation_history ADD CONSTRAINT work_relation_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); + +-- Remove ZITADEL organisation id column/index from publisher +DROP INDEX IF EXISTS publisher_zitadel_id_key; +ALTER TABLE publisher + DROP COLUMN IF EXISTS zitadel_id; \ No newline at end of file diff --git a/thoth-api/migrations/20260107_v1.0.0/up.sql b/thoth-api/migrations/20260107_v1.0.0/up.sql new file mode 100644 index 00000000..d820af4d --- /dev/null +++ b/thoth-api/migrations/20260107_v1.0.0/up.sql @@ -0,0 +1,81 @@ +-- Drop foreign key constraints +ALTER TABLE abstract_history DROP CONSTRAINT IF EXISTS abstract_history_account_id_fkey; +ALTER TABLE affiliation_history DROP CONSTRAINT IF EXISTS affiliation_history_account_id_fkey; +ALTER TABLE biography_history DROP CONSTRAINT IF EXISTS biography_history_account_id_fkey; +ALTER TABLE contact_history DROP CONSTRAINT IF EXISTS contact_history_account_id_fkey; +ALTER TABLE contribution_history DROP CONSTRAINT IF EXISTS contribution_history_account_id_fkey; +ALTER TABLE contributor_history DROP CONSTRAINT IF EXISTS contributor_history_account_id_fkey; +ALTER TABLE funding_history DROP CONSTRAINT IF EXISTS funding_history_account_id_fkey; +ALTER TABLE imprint_history DROP CONSTRAINT IF EXISTS imprint_history_account_id_fkey; +ALTER TABLE institution_history DROP CONSTRAINT IF EXISTS institution_history_account_id_fkey; +ALTER TABLE institution_history DROP CONSTRAINT IF EXISTS funder_history_account_id_fkey; -- historical +ALTER TABLE issue_history DROP CONSTRAINT IF EXISTS issue_history_account_id_fkey; +ALTER TABLE language_history DROP CONSTRAINT IF EXISTS language_history_account_id_fkey; +ALTER TABLE location_history DROP CONSTRAINT IF EXISTS location_history_account_id_fkey; +ALTER TABLE price_history DROP CONSTRAINT IF EXISTS price_history_account_id_fkey; +ALTER TABLE publication_history DROP CONSTRAINT IF EXISTS publication_history_account_id_fkey; +ALTER TABLE publisher_history DROP CONSTRAINT IF EXISTS publisher_history_account_id_fkey; +ALTER TABLE reference_history DROP CONSTRAINT IF EXISTS reference_history_account_id_fkey; +ALTER TABLE series_history DROP CONSTRAINT IF EXISTS series_history_account_id_fkey; +ALTER TABLE subject_history DROP CONSTRAINT IF EXISTS subject_history_account_id_fkey; +ALTER TABLE title_history DROP CONSTRAINT IF EXISTS title_history_account_id_fkey; +ALTER TABLE work_history DROP CONSTRAINT IF EXISTS work_history_account_id_fkey; +ALTER TABLE work_relation_history DROP CONSTRAINT IF EXISTS work_relation_history_account_id_fkey; + +-- Rename column account_id to user_id and change type to TEXT +ALTER TABLE abstract_history RENAME COLUMN account_id TO user_id; +ALTER TABLE affiliation_history RENAME COLUMN account_id TO user_id; +ALTER TABLE biography_history RENAME COLUMN account_id TO user_id; +ALTER TABLE contact_history RENAME COLUMN account_id TO user_id; +ALTER TABLE contribution_history RENAME COLUMN account_id TO user_id; +ALTER TABLE contributor_history RENAME COLUMN account_id TO user_id; +ALTER TABLE funding_history RENAME COLUMN account_id TO user_id; +ALTER TABLE imprint_history RENAME COLUMN account_id TO user_id; +ALTER TABLE institution_history RENAME COLUMN account_id TO user_id; +ALTER TABLE issue_history RENAME COLUMN account_id TO user_id; +ALTER TABLE language_history RENAME COLUMN account_id TO user_id; +ALTER TABLE location_history RENAME COLUMN account_id TO user_id; +ALTER TABLE price_history RENAME COLUMN account_id TO user_id; +ALTER TABLE publication_history RENAME COLUMN account_id TO user_id; +ALTER TABLE publisher_history RENAME COLUMN account_id TO user_id; +ALTER TABLE reference_history RENAME COLUMN account_id TO user_id; +ALTER TABLE series_history RENAME COLUMN account_id TO user_id; +ALTER TABLE subject_history RENAME COLUMN account_id TO user_id; +ALTER TABLE title_history RENAME COLUMN account_id TO user_id; +ALTER TABLE work_history RENAME COLUMN account_id TO user_id; +ALTER TABLE work_relation_history RENAME COLUMN account_id TO user_id; + +ALTER TABLE abstract_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE affiliation_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE biography_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE contact_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE contribution_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE contributor_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE funding_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE imprint_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE institution_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE issue_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE language_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE location_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE price_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE publication_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE publisher_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE reference_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE series_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE subject_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE title_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE work_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE work_relation_history ALTER COLUMN user_id TYPE TEXT; + +-- Drop the obsolete tables +DROP TABLE IF EXISTS publisher_account; +DROP TABLE IF EXISTS account; + +-- Store ZITADEL organisation id for publisher-scoped authorisation +ALTER TABLE publisher + ADD COLUMN IF NOT EXISTS zitadel_id TEXT; + +-- Each publisher should map to at most one ZITADEL organisation +CREATE UNIQUE INDEX IF NOT EXISTS publisher_zitadel_id_key + ON publisher (zitadel_id) + WHERE zitadel_id IS NOT NULL; diff --git a/thoth-api/migrations/20260210_v1.0.0/down.sql b/thoth-api/migrations/20260210_v1.0.0/down.sql new file mode 100644 index 00000000..d50a2dbb --- /dev/null +++ b/thoth-api/migrations/20260210_v1.0.0/down.sql @@ -0,0 +1,11 @@ +DROP TABLE IF EXISTS file_upload; +DROP TABLE IF EXISTS file; +DROP TYPE IF EXISTS file_type; +DROP FUNCTION IF EXISTS file_upload_work_updated_at_with_relations(); +DROP FUNCTION IF EXISTS file_work_updated_at_with_relations(); + +ALTER TABLE imprint + DROP CONSTRAINT IF EXISTS imprint_storage_cfg_all_or_none, + DROP COLUMN IF EXISTS s3_bucket, + DROP COLUMN IF EXISTS cdn_domain, + DROP COLUMN IF EXISTS cloudfront_dist_id; diff --git a/thoth-api/migrations/20260210_v1.0.0/up.sql b/thoth-api/migrations/20260210_v1.0.0/up.sql new file mode 100644 index 00000000..978a5a66 --- /dev/null +++ b/thoth-api/migrations/20260210_v1.0.0/up.sql @@ -0,0 +1,134 @@ +ALTER TABLE imprint + ADD COLUMN s3_bucket TEXT, + ADD COLUMN cdn_domain TEXT, + ADD COLUMN cloudfront_dist_id TEXT; + +ALTER TABLE imprint + ADD CONSTRAINT imprint_storage_cfg_all_or_none + CHECK ( + ( + s3_bucket IS NULL AND + cdn_domain IS NULL AND + cloudfront_dist_id IS NULL + ) + OR + ( + s3_bucket IS NOT NULL AND + cdn_domain IS NOT NULL AND + cloudfront_dist_id IS NOT NULL + ) + ); + + +CREATE TYPE file_type AS ENUM ('publication', 'frontcover'); + +CREATE TABLE file ( + file_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + file_type file_type NOT NULL, + work_id UUID REFERENCES work (work_id), + publication_id UUID REFERENCES publication (publication_id), + object_key TEXT NOT NULL, + cdn_url TEXT NOT NULL, + mime_type TEXT NOT NULL, + bytes BIGINT NOT NULL, + sha256 TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE file + ADD CONSTRAINT file_type_check + CHECK ( + (file_type = 'frontcover' AND work_id IS NOT NULL AND publication_id IS NULL) OR + (file_type = 'publication' AND publication_id IS NOT NULL AND work_id IS NULL) + ); + +CREATE UNIQUE INDEX file_frontcover_work_unique_idx + ON file (work_id) + WHERE file_type = 'frontcover'; + +CREATE UNIQUE INDEX file_publication_unique_idx + ON file (publication_id) + WHERE file_type = 'publication'; + +CREATE UNIQUE INDEX file_object_key_unique_idx + ON file (object_key); + +SELECT diesel_manage_updated_at('file'); + +CREATE TABLE file_upload ( + file_upload_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + file_type file_type NOT NULL, + work_id UUID REFERENCES work (work_id), + publication_id UUID REFERENCES publication (publication_id), + declared_mime_type TEXT NOT NULL, + declared_extension TEXT NOT NULL, + declared_sha256 TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE file_upload + ADD CONSTRAINT file_upload_type_check + CHECK ( + (file_type = 'frontcover' AND work_id IS NOT NULL AND publication_id IS NULL) OR + (file_type = 'publication' AND publication_id IS NOT NULL AND work_id IS NULL) + ); + +CREATE INDEX file_upload_work_idx + ON file_upload (work_id) + WHERE file_type = 'frontcover'; + +CREATE INDEX file_upload_publication_idx + ON file_upload (publication_id) + WHERE file_type = 'publication'; + +SELECT diesel_manage_updated_at('file_upload'); + +CREATE FUNCTION public.file_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql +AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + WHERE work_id = OLD.work_id OR work_id = NEW.work_id; + + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM publication + WHERE work.work_id = publication.work_id + AND (publication.publication_id = OLD.publication_id OR publication.publication_id = NEW.publication_id); + END IF; + RETURN NULL; +END; +$$; + +CREATE FUNCTION public.file_upload_work_updated_at_with_relations() RETURNS trigger + LANGUAGE plpgsql +AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD + ) THEN + UPDATE work + SET updated_at_with_relations = current_timestamp + WHERE work_id = OLD.work_id OR work_id = NEW.work_id; + + UPDATE work + SET updated_at_with_relations = current_timestamp + FROM publication + WHERE work.work_id = publication.work_id + AND (publication.publication_id = OLD.publication_id OR publication.publication_id = NEW.publication_id); + END IF; + RETURN NULL; +END; +$$; + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.file + FOR EACH ROW EXECUTE FUNCTION public.file_work_updated_at_with_relations(); + +CREATE TRIGGER set_work_updated_at_with_relations AFTER INSERT OR DELETE OR UPDATE ON public.file_upload + FOR EACH ROW EXECUTE FUNCTION public.file_upload_work_updated_at_with_relations(); diff --git a/thoth-api/migrations/20260214_v1.0.0/down.sql b/thoth-api/migrations/20260214_v1.0.0/down.sql new file mode 100644 index 00000000..ba7e8f07 --- /dev/null +++ b/thoth-api/migrations/20260214_v1.0.0/down.sql @@ -0,0 +1,52 @@ +DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON public.work_featured_video; +DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON public.book_review; +DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON public.endorsement; +DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON public.award; +DROP TRIGGER IF EXISTS set_work_updated_at_with_relations ON public.additional_resource; + +DROP INDEX IF EXISTS file_upload_work_featured_video_idx; +DROP INDEX IF EXISTS file_upload_additional_resource_idx; +DROP INDEX IF EXISTS file_work_featured_video_unique_idx; +DROP INDEX IF EXISTS file_additional_resource_unique_idx; + +ALTER TABLE file DROP CONSTRAINT IF EXISTS file_type_check; +ALTER TABLE file_upload DROP CONSTRAINT IF EXISTS file_upload_type_check; + +ALTER TABLE file + DROP COLUMN IF EXISTS work_featured_video_id, + DROP COLUMN IF EXISTS additional_resource_id; + +ALTER TABLE file_upload + DROP COLUMN IF EXISTS work_featured_video_id, + DROP COLUMN IF EXISTS additional_resource_id; + +ALTER TABLE file + ADD CONSTRAINT file_type_check + CHECK ( + (file_type = 'frontcover' AND work_id IS NOT NULL AND publication_id IS NULL) OR + (file_type = 'publication' AND publication_id IS NOT NULL AND work_id IS NULL) + ); + +ALTER TABLE file_upload + ADD CONSTRAINT file_upload_type_check + CHECK ( + (file_type = 'frontcover' AND work_id IS NOT NULL AND publication_id IS NULL) OR + (file_type = 'publication' AND publication_id IS NOT NULL AND work_id IS NULL) + ); + +DROP TABLE IF EXISTS work_featured_video_history; +DROP TABLE IF EXISTS book_review_history; +DROP TABLE IF EXISTS endorsement_history; +DROP TABLE IF EXISTS award_history; +DROP TABLE IF EXISTS additional_resource_history; + +DROP TABLE IF EXISTS work_featured_video; +DROP TABLE IF EXISTS book_review; +DROP TABLE IF EXISTS endorsement; +DROP TABLE IF EXISTS award; +DROP TABLE IF EXISTS additional_resource; + +DROP TYPE IF EXISTS resource_type; + +ALTER TABLE work + DROP COLUMN IF EXISTS resources_description; diff --git a/thoth-api/migrations/20260214_v1.0.0/metadata.toml b/thoth-api/migrations/20260214_v1.0.0/metadata.toml new file mode 100644 index 00000000..79e9221c --- /dev/null +++ b/thoth-api/migrations/20260214_v1.0.0/metadata.toml @@ -0,0 +1 @@ +run_in_transaction = false diff --git a/thoth-api/migrations/20260214_v1.0.0/up.sql b/thoth-api/migrations/20260214_v1.0.0/up.sql new file mode 100644 index 00000000..473df098 --- /dev/null +++ b/thoth-api/migrations/20260214_v1.0.0/up.sql @@ -0,0 +1,244 @@ +ALTER TYPE file_type ADD VALUE IF NOT EXISTS 'additional_resource'; +ALTER TYPE file_type ADD VALUE IF NOT EXISTS 'work_featured_video'; + +CREATE TYPE resource_type AS ENUM ( + 'AUDIO', + 'VIDEO', + 'IMAGE', + 'BLOG', + 'WEBSITE', + 'DOCUMENT', + 'BOOK', + 'ARTICLE', + 'MAP', + 'SOURCE', + 'DATASET', + 'SPREADSHEET', + 'OTHER' +); + +CREATE TABLE additional_resource ( + additional_resource_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, + + title TEXT NOT NULL CHECK (octet_length(title) >= 1), + description TEXT, + attribution TEXT, + resource_type resource_type NOT NULL, + + doi TEXT, + handle TEXT, + url TEXT, + + resource_ordinal INTEGER NOT NULL DEFAULT 1 CHECK (resource_ordinal > 0), + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT additional_resource_resource_ordinal_work_id_uniq + UNIQUE (work_id, resource_ordinal) + DEFERRABLE INITIALLY IMMEDIATE +); +SELECT diesel_manage_updated_at('additional_resource'); + +CREATE TABLE award ( + award_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, + + title TEXT NOT NULL CHECK (octet_length(title) >= 1), + url TEXT, + category TEXT, + note TEXT, + + award_ordinal INTEGER NOT NULL DEFAULT 1 CHECK (award_ordinal > 0), + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT award_award_ordinal_work_id_uniq + UNIQUE (work_id, award_ordinal) + DEFERRABLE INITIALLY IMMEDIATE +); +SELECT diesel_manage_updated_at('award'); + +CREATE TABLE endorsement ( + endorsement_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, + + author_name TEXT, + author_role TEXT, + url TEXT, + text TEXT, + + endorsement_ordinal INTEGER NOT NULL DEFAULT 1 CHECK (endorsement_ordinal > 0), + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT endorsement_endorsement_ordinal_work_id_uniq + UNIQUE (work_id, endorsement_ordinal) + DEFERRABLE INITIALLY IMMEDIATE +); +SELECT diesel_manage_updated_at('endorsement'); + +CREATE TABLE book_review ( + book_review_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + work_id UUID NOT NULL REFERENCES work(work_id) ON DELETE CASCADE, + + title TEXT, + author_name TEXT, + url TEXT, + doi TEXT, + review_date DATE, + journal_name TEXT, + journal_volume TEXT, + journal_number TEXT, + journal_issn TEXT, + text TEXT, + + review_ordinal INTEGER NOT NULL DEFAULT 1 CHECK (review_ordinal > 0), + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT book_review_review_ordinal_work_id_uniq + UNIQUE (work_id, review_ordinal) + DEFERRABLE INITIALLY IMMEDIATE +); +SELECT diesel_manage_updated_at('book_review'); + +CREATE TABLE work_featured_video ( + work_featured_video_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + work_id UUID NOT NULL UNIQUE REFERENCES work(work_id) ON DELETE CASCADE, + + title TEXT, + url TEXT, + width INTEGER NOT NULL DEFAULT 560 CHECK (width > 0), + height INTEGER NOT NULL DEFAULT 315 CHECK (height > 0), + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +SELECT diesel_manage_updated_at('work_featured_video'); + +ALTER TABLE work + ADD COLUMN resources_description TEXT; + +ALTER TABLE file + ADD COLUMN additional_resource_id UUID REFERENCES additional_resource(additional_resource_id) ON DELETE CASCADE, + ADD COLUMN work_featured_video_id UUID REFERENCES work_featured_video(work_featured_video_id) ON DELETE CASCADE; + +ALTER TABLE file_upload + ADD COLUMN additional_resource_id UUID REFERENCES additional_resource(additional_resource_id) ON DELETE CASCADE, + ADD COLUMN work_featured_video_id UUID REFERENCES work_featured_video(work_featured_video_id) ON DELETE CASCADE; + +ALTER TABLE file DROP CONSTRAINT IF EXISTS file_type_check; +ALTER TABLE file_upload DROP CONSTRAINT IF EXISTS file_upload_type_check; + +ALTER TABLE file + ADD CONSTRAINT file_type_check + CHECK ( + (file_type = 'frontcover' AND work_id IS NOT NULL AND publication_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'publication' AND publication_id IS NOT NULL AND work_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) OR + ( + file_type NOT IN ('frontcover', 'publication') AND + work_id IS NULL AND + publication_id IS NULL AND + ( + (additional_resource_id IS NOT NULL AND work_featured_video_id IS NULL) OR + (work_featured_video_id IS NOT NULL AND additional_resource_id IS NULL) + ) + ) + ); + +ALTER TABLE file_upload + ADD CONSTRAINT file_upload_type_check + CHECK ( + (file_type = 'frontcover' AND work_id IS NOT NULL AND publication_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'publication' AND publication_id IS NOT NULL AND work_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) OR + ( + file_type NOT IN ('frontcover', 'publication') AND + work_id IS NULL AND + publication_id IS NULL AND + ( + (additional_resource_id IS NOT NULL AND work_featured_video_id IS NULL) OR + (work_featured_video_id IS NOT NULL AND additional_resource_id IS NULL) + ) + ) + ); + +CREATE UNIQUE INDEX file_additional_resource_unique_idx + ON file (additional_resource_id) + WHERE additional_resource_id IS NOT NULL; + +CREATE UNIQUE INDEX file_work_featured_video_unique_idx + ON file (work_featured_video_id) + WHERE work_featured_video_id IS NOT NULL; + +CREATE INDEX file_upload_additional_resource_idx + ON file_upload (additional_resource_id) + WHERE additional_resource_id IS NOT NULL; + +CREATE INDEX file_upload_work_featured_video_idx + ON file_upload (work_featured_video_id) + WHERE work_featured_video_id IS NOT NULL; + +CREATE TABLE additional_resource_history ( + additional_resource_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + additional_resource_id UUID NOT NULL REFERENCES additional_resource(additional_resource_id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + data JSONB NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE award_history ( + award_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + award_id UUID NOT NULL REFERENCES award(award_id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + data JSONB NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE endorsement_history ( + endorsement_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + endorsement_id UUID NOT NULL REFERENCES endorsement(endorsement_id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + data JSONB NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE book_review_history ( + book_review_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + book_review_id UUID NOT NULL REFERENCES book_review(book_review_id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + data JSONB NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE work_featured_video_history ( + work_featured_video_history_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + work_featured_video_id UUID NOT NULL REFERENCES work_featured_video(work_featured_video_id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + data JSONB NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TRIGGER set_work_updated_at_with_relations + AFTER INSERT OR DELETE OR UPDATE ON public.additional_resource + FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); + +CREATE TRIGGER set_work_updated_at_with_relations + AFTER INSERT OR DELETE OR UPDATE ON public.award + FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); + +CREATE TRIGGER set_work_updated_at_with_relations + AFTER INSERT OR DELETE OR UPDATE ON public.endorsement + FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); + +CREATE TRIGGER set_work_updated_at_with_relations + AFTER INSERT OR DELETE OR UPDATE ON public.book_review + FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); + +CREATE TRIGGER set_work_updated_at_with_relations + AFTER INSERT OR DELETE OR UPDATE ON public.work_featured_video + FOR EACH ROW EXECUTE FUNCTION public.work_updated_at_with_relations(); diff --git a/thoth-api/migrations/20260223_v1.0.0/down.sql b/thoth-api/migrations/20260223_v1.0.0/down.sql new file mode 100644 index 00000000..e8d77ae9 --- /dev/null +++ b/thoth-api/migrations/20260223_v1.0.0/down.sql @@ -0,0 +1,19 @@ +ALTER TABLE file DROP CONSTRAINT IF EXISTS file_work_id_fkey; +ALTER TABLE file + ADD CONSTRAINT file_work_id_fkey + FOREIGN KEY (work_id) REFERENCES work(work_id); + +ALTER TABLE file DROP CONSTRAINT IF EXISTS file_publication_id_fkey; +ALTER TABLE file + ADD CONSTRAINT file_publication_id_fkey + FOREIGN KEY (publication_id) REFERENCES publication(publication_id); + +ALTER TABLE file_upload DROP CONSTRAINT IF EXISTS file_upload_work_id_fkey; +ALTER TABLE file_upload + ADD CONSTRAINT file_upload_work_id_fkey + FOREIGN KEY (work_id) REFERENCES work(work_id); + +ALTER TABLE file_upload DROP CONSTRAINT IF EXISTS file_upload_publication_id_fkey; +ALTER TABLE file_upload + ADD CONSTRAINT file_upload_publication_id_fkey + FOREIGN KEY (publication_id) REFERENCES publication(publication_id); diff --git a/thoth-api/migrations/20260223_v1.0.0/up.sql b/thoth-api/migrations/20260223_v1.0.0/up.sql new file mode 100644 index 00000000..e6fda43f --- /dev/null +++ b/thoth-api/migrations/20260223_v1.0.0/up.sql @@ -0,0 +1,19 @@ +ALTER TABLE file DROP CONSTRAINT IF EXISTS file_work_id_fkey; +ALTER TABLE file + ADD CONSTRAINT file_work_id_fkey + FOREIGN KEY (work_id) REFERENCES work(work_id) ON DELETE CASCADE; + +ALTER TABLE file DROP CONSTRAINT IF EXISTS file_publication_id_fkey; +ALTER TABLE file + ADD CONSTRAINT file_publication_id_fkey + FOREIGN KEY (publication_id) REFERENCES publication(publication_id) ON DELETE CASCADE; + +ALTER TABLE file_upload DROP CONSTRAINT IF EXISTS file_upload_work_id_fkey; +ALTER TABLE file_upload + ADD CONSTRAINT file_upload_work_id_fkey + FOREIGN KEY (work_id) REFERENCES work(work_id) ON DELETE CASCADE; + +ALTER TABLE file_upload DROP CONSTRAINT IF EXISTS file_upload_publication_id_fkey; +ALTER TABLE file_upload + ADD CONSTRAINT file_upload_publication_id_fkey + FOREIGN KEY (publication_id) REFERENCES publication(publication_id) ON DELETE CASCADE; diff --git a/thoth-api/migrations/20260303_v1.0.0/down.sql b/thoth-api/migrations/20260303_v1.0.0/down.sql new file mode 100644 index 00000000..33421ab5 --- /dev/null +++ b/thoth-api/migrations/20260303_v1.0.0/down.sql @@ -0,0 +1,4 @@ +ALTER TABLE imprint + DROP COLUMN IF EXISTS default_currency, + DROP COLUMN IF EXISTS default_place, + DROP COLUMN IF EXISTS default_locale; diff --git a/thoth-api/migrations/20260303_v1.0.0/up.sql b/thoth-api/migrations/20260303_v1.0.0/up.sql new file mode 100644 index 00000000..4b161c39 --- /dev/null +++ b/thoth-api/migrations/20260303_v1.0.0/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE imprint + ADD COLUMN default_currency currency_code, + ADD COLUMN default_place text, + ADD COLUMN default_locale locale_code; diff --git a/thoth-api/migrations/20260311_v1.0.0/down.sql b/thoth-api/migrations/20260311_v1.0.0/down.sql new file mode 100644 index 00000000..69033664 --- /dev/null +++ b/thoth-api/migrations/20260311_v1.0.0/down.sql @@ -0,0 +1,9 @@ +ALTER TABLE public.language + ADD COLUMN main_language boolean DEFAULT false NOT NULL; + +ALTER TABLE public.funding + ADD COLUMN jurisdiction text, + ADD CONSTRAINT funding_jurisdiction_check CHECK ((octet_length(jurisdiction) >= 1)); + +ALTER TABLE public.issue + DROP COLUMN IF EXISTS issue_number; diff --git a/thoth-api/migrations/20260311_v1.0.0/up.sql b/thoth-api/migrations/20260311_v1.0.0/up.sql new file mode 100644 index 00000000..03d1f99a --- /dev/null +++ b/thoth-api/migrations/20260311_v1.0.0/up.sql @@ -0,0 +1,9 @@ +ALTER TABLE public.language + DROP COLUMN IF EXISTS main_language; + +ALTER TABLE public.funding + DROP CONSTRAINT IF EXISTS funding_jurisdiction_check, + DROP COLUMN IF EXISTS jurisdiction; + +ALTER TABLE public.issue + ADD COLUMN issue_number integer; diff --git a/thoth-api/migrations/20260312_v1.0.0/down.sql b/thoth-api/migrations/20260312_v1.0.0/down.sql new file mode 100644 index 00000000..7a689c3e --- /dev/null +++ b/thoth-api/migrations/20260312_v1.0.0/down.sql @@ -0,0 +1,23 @@ +DROP INDEX IF EXISTS endorsement_author_institution_idx; + +ALTER TABLE endorsement + DROP COLUMN IF EXISTS author_institution_id, + DROP COLUMN IF EXISTS author_orcid; + +DROP INDEX IF EXISTS book_review_reviewer_institution_idx; + +ALTER TABLE book_review + DROP COLUMN IF EXISTS page_range, + DROP COLUMN IF EXISTS reviewer_institution_id, + DROP COLUMN IF EXISTS reviewer_orcid; + +ALTER TABLE additional_resource + DROP COLUMN IF EXISTS date; + +ALTER TABLE award + DROP COLUMN IF EXISTS role; + +ALTER TABLE award + RENAME COLUMN prize_statement TO note; + +DROP TYPE IF EXISTS award_role; diff --git a/thoth-api/migrations/20260312_v1.0.0/up.sql b/thoth-api/migrations/20260312_v1.0.0/up.sql new file mode 100644 index 00000000..3dc4e3a1 --- /dev/null +++ b/thoth-api/migrations/20260312_v1.0.0/up.sql @@ -0,0 +1,35 @@ +CREATE TYPE award_role AS ENUM ( + 'SHORT_LISTED', + 'WINNER', + 'LONG_LISTED', + 'COMMENDED', + 'RUNNER_UP', + 'JOINT_WINNER', + 'NOMINATED' +); + +ALTER TABLE award + RENAME COLUMN note TO prize_statement; + +ALTER TABLE award + ADD COLUMN role award_role; + +ALTER TABLE additional_resource + ADD COLUMN date DATE; + +ALTER TABLE book_review + ADD COLUMN reviewer_orcid TEXT, + ADD COLUMN reviewer_institution_id UUID REFERENCES institution(institution_id) ON DELETE SET NULL, + ADD COLUMN page_range TEXT; + +CREATE INDEX book_review_reviewer_institution_idx + ON book_review (reviewer_institution_id) + WHERE reviewer_institution_id IS NOT NULL; + +ALTER TABLE endorsement + ADD COLUMN author_orcid TEXT, + ADD COLUMN author_institution_id UUID REFERENCES institution(institution_id) ON DELETE SET NULL; + +CREATE INDEX endorsement_author_institution_idx + ON endorsement (author_institution_id) + WHERE author_institution_id IS NOT NULL; diff --git a/thoth-api/migrations/v0.11.12/down.sql b/thoth-api/migrations/v0.11.12/down.sql deleted file mode 100644 index 299feb08..00000000 --- a/thoth-api/migrations/v0.11.12/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE price DROP CONSTRAINT price_publication_id_currency_code_uniq; diff --git a/thoth-api/migrations/v0.11.12/up.sql b/thoth-api/migrations/v0.11.12/up.sql deleted file mode 100644 index 531b5f8e..00000000 --- a/thoth-api/migrations/v0.11.12/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE price ADD CONSTRAINT price_publication_id_currency_code_uniq - UNIQUE (publication_id, currency_code); diff --git a/thoth-api/migrations/v0.11.14/down.sql b/thoth-api/migrations/v0.11.14/down.sql deleted file mode 100644 index c4b2f997..00000000 --- a/thoth-api/migrations/v0.11.14/down.sql +++ /dev/null @@ -1,33 +0,0 @@ -UPDATE location SET location_platform = 'Other' WHERE location_platform IN ( - 'Google Books', - 'Internet Archive', - 'ScienceOpen', - 'SciELO' -); - --- Drop the default and unique constraint, otherwise it won't be able to cast to text -ALTER TABLE location ALTER COLUMN location_platform DROP DEFAULT; -DROP INDEX location_uniq_platform_idx; - -ALTER TABLE location ALTER COLUMN location_platform TYPE text; -DROP TYPE location_platform; -CREATE TYPE location_platform AS ENUM ( - 'Project MUSE', - 'OAPEN', - 'DOAB', - 'JSTOR', - 'EBSCO Host', - 'OCLC KB', - 'ProQuest KB', - 'ProQuest ExLibris', - 'EBSCO KB', - 'JISC KB', - 'Other' - ); -ALTER TABLE location ALTER location_platform TYPE location_platform USING location_platform::location_platform; -ALTER TABLE location - ALTER COLUMN location_platform SET DEFAULT 'Other'::location_platform; - -CREATE UNIQUE INDEX location_uniq_platform_idx - ON location (publication_id, location_platform) - WHERE NOT location_platform = 'Other'::location_platform; \ No newline at end of file diff --git a/thoth-api/migrations/v0.11.14/up.sql b/thoth-api/migrations/v0.11.14/up.sql deleted file mode 100644 index d6d61234..00000000 --- a/thoth-api/migrations/v0.11.14/up.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TYPE location_platform ADD VALUE 'Google Books'; -ALTER TYPE location_platform ADD VALUE 'Internet Archive'; -ALTER TYPE location_platform ADD VALUE 'ScienceOpen'; -ALTER TYPE location_platform ADD VALUE 'SciELO'; diff --git a/thoth-api/migrations/v0.11.15/down.sql b/thoth-api/migrations/v0.11.15/down.sql deleted file mode 100644 index ca127880..00000000 --- a/thoth-api/migrations/v0.11.15/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TYPE location_platform RENAME VALUE 'SciELO Books' TO 'SciELO'; \ No newline at end of file diff --git a/thoth-api/migrations/v0.11.15/up.sql b/thoth-api/migrations/v0.11.15/up.sql deleted file mode 100644 index 597faa48..00000000 --- a/thoth-api/migrations/v0.11.15/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TYPE location_platform RENAME VALUE 'SciELO' TO 'SciELO Books'; diff --git a/thoth-api/migrations/v0.11.16/down.sql b/thoth-api/migrations/v0.11.16/down.sql deleted file mode 100644 index 920646da..00000000 --- a/thoth-api/migrations/v0.11.16/down.sql +++ /dev/null @@ -1,34 +0,0 @@ -UPDATE location SET location_platform = 'Other' WHERE location_platform IN ( - 'Publisher Website' -); - --- Drop the default and unique constraint, otherwise it won't be able to cast to text -ALTER TABLE location ALTER COLUMN location_platform DROP DEFAULT; -DROP INDEX location_uniq_platform_idx; - -ALTER TABLE location ALTER COLUMN location_platform TYPE text; -DROP TYPE location_platform; -CREATE TYPE location_platform AS ENUM ( - 'Project MUSE', - 'OAPEN', - 'DOAB', - 'JSTOR', - 'EBSCO Host', - 'OCLC KB', - 'ProQuest KB', - 'ProQuest ExLibris', - 'EBSCO KB', - 'JISC KB', - 'Google Books', - 'Internet Archive', - 'ScienceOpen', - 'SciELO Books', - 'Other' - ); -ALTER TABLE location ALTER location_platform TYPE location_platform USING location_platform::location_platform; -ALTER TABLE location - ALTER COLUMN location_platform SET DEFAULT 'Other'::location_platform; - -CREATE UNIQUE INDEX location_uniq_platform_idx - ON location (publication_id, location_platform) - WHERE NOT location_platform = 'Other'::location_platform; diff --git a/thoth-api/migrations/v0.11.16/up.sql b/thoth-api/migrations/v0.11.16/up.sql deleted file mode 100644 index addc5d68..00000000 --- a/thoth-api/migrations/v0.11.16/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TYPE location_platform ADD VALUE 'Publisher Website'; diff --git a/thoth-api/migrations/v0.11.17/down.sql b/thoth-api/migrations/v0.11.17/down.sql deleted file mode 100644 index 055b53a9..00000000 --- a/thoth-api/migrations/v0.11.17/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE contribution - ALTER COLUMN main_contribution SET DEFAULT False; diff --git a/thoth-api/migrations/v0.11.17/up.sql b/thoth-api/migrations/v0.11.17/up.sql deleted file mode 100644 index 87fe07b7..00000000 --- a/thoth-api/migrations/v0.11.17/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE contribution - ALTER COLUMN main_contribution SET DEFAULT True; diff --git a/thoth-api/migrations/v0.11.7/down.sql b/thoth-api/migrations/v0.11.7/down.sql deleted file mode 100644 index b9297c0c..00000000 --- a/thoth-api/migrations/v0.11.7/down.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TABLE work DROP CONSTRAINT work_doi_check; -ALTER TABLE work ADD CONSTRAINT work_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\[\]\/:a-zA-Z0-9]+$'); - -ALTER TABLE reference DROP CONSTRAINT reference_doi_check; -ALTER TABLE reference ADD CONSTRAINT reference_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\[\]\/:a-zA-Z0-9]+$'); - -ALTER TABLE institution DROP CONSTRAINT institution_institution_doi_check; -ALTER TABLE institution ADD CONSTRAINT institution_institution_doi_check - CHECK (institution_doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\[\]\/:a-zA-Z0-9]+$'); diff --git a/thoth-api/migrations/v0.11.7/up.sql b/thoth-api/migrations/v0.11.7/up.sql deleted file mode 100644 index 40680f44..00000000 --- a/thoth-api/migrations/v0.11.7/up.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TABLE work DROP CONSTRAINT work_doi_check; -ALTER TABLE work ADD CONSTRAINT work_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\[\]<>\/:a-zA-Z0-9]+$'); - -ALTER TABLE reference DROP CONSTRAINT reference_doi_check; -ALTER TABLE reference ADD CONSTRAINT reference_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\[\]<>\/:a-zA-Z0-9]+$'); - -ALTER TABLE institution DROP CONSTRAINT institution_institution_doi_check; -ALTER TABLE institution ADD CONSTRAINT institution_institution_doi_check - CHECK (institution_doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\[\]<>\/:a-zA-Z0-9]+$'); diff --git a/thoth-api/migrations/v0.12.2/down.sql b/thoth-api/migrations/v0.12.2/down.sql deleted file mode 100644 index f21aa271..00000000 --- a/thoth-api/migrations/v0.12.2/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE imprint - DROP COLUMN crossmark_doi; diff --git a/thoth-api/migrations/v0.12.2/up.sql b/thoth-api/migrations/v0.12.2/up.sql deleted file mode 100644 index 9f2f56d9..00000000 --- a/thoth-api/migrations/v0.12.2/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE imprint - ADD COLUMN crossmark_doi TEXT CHECK (crossmark_doi ~* 'https:\/\/doi.org\/10.\d{4,9}\/[-._\;\(\)\/:a-zA-Z0-9]+$'); diff --git a/thoth-api/migrations/v0.12.3/down.sql b/thoth-api/migrations/v0.12.3/down.sql deleted file mode 100644 index 36c5925f..00000000 --- a/thoth-api/migrations/v0.12.3/down.sql +++ /dev/null @@ -1,12 +0,0 @@ -ALTER TABLE series - ALTER COLUMN issn_print SET NOT NULL; - -ALTER TABLE series - ALTER COLUMN issn_digital SET NOT NULL; - -ALTER TABLE work - DROP CONSTRAINT work_active_withdrawn_date_check, - DROP CONSTRAINT work_inactive_no_withdrawn_date_check, - DROP CONSTRAINT work_withdrawn_date_after_publication_date_check, - DROP COLUMN withdrawn_date; - diff --git a/thoth-api/migrations/v0.12.3/up.sql b/thoth-api/migrations/v0.12.3/up.sql deleted file mode 100644 index daf55fb8..00000000 --- a/thoth-api/migrations/v0.12.3/up.sql +++ /dev/null @@ -1,25 +0,0 @@ -ALTER TABLE series - ALTER COLUMN issn_print DROP NOT NULL; - -ALTER TABLE series - ALTER COLUMN issn_digital DROP NOT NULL; - -ALTER TABLE work - ADD COLUMN withdrawn_date DATE; - -UPDATE work - SET withdrawn_date = updated_at - WHERE (work_status = 'withdrawn-from-sale' - OR work_status = 'out-of-print'); - -ALTER TABLE work - ADD CONSTRAINT work_active_withdrawn_date_check CHECK - ((work_status = 'withdrawn-from-sale' OR work_status = 'out-of-print') - OR (work_status NOT IN ('withdrawn-from-sale', 'out-of-print') AND withdrawn_date IS NULL)), - - ADD CONSTRAINT work_inactive_no_withdrawn_date_check CHECK - (((work_status = 'withdrawn-from-sale' OR work_status = 'out-of-print') AND withdrawn_date IS NOT NULL) - OR (work_status NOT IN ('withdrawn-from-sale', 'out-of-print'))), - - ADD CONSTRAINT work_withdrawn_date_after_publication_date_check CHECK - (withdrawn_date IS NULL OR (publication_date < withdrawn_date)); diff --git a/thoth-api/migrations/v0.12.4/down.sql b/thoth-api/migrations/v0.12.4/down.sql deleted file mode 100644 index 96df703e..00000000 --- a/thoth-api/migrations/v0.12.4/down.sql +++ /dev/null @@ -1,33 +0,0 @@ -UPDATE location SET location_platform = 'Other' WHERE location_platform = 'Zenodo'; - --- Drop the default and unique constraint, otherwise it won't be able to cast to text -ALTER TABLE location ALTER COLUMN location_platform DROP DEFAULT; -DROP INDEX location_uniq_platform_idx; - -ALTER TABLE location ALTER COLUMN location_platform TYPE text; -DROP TYPE location_platform; -CREATE TYPE location_platform AS ENUM ( - 'Project MUSE', - 'OAPEN', - 'DOAB', - 'JSTOR', - 'EBSCO Host', - 'OCLC KB', - 'ProQuest KB', - 'ProQuest ExLibris', - 'EBSCO KB', - 'JISC KB', - 'Google Books', - 'Internet Archive', - 'ScienceOpen', - 'SciELO Books', - 'Publisher Website', - 'Other' - ); -ALTER TABLE location ALTER location_platform TYPE location_platform USING location_platform::location_platform; -ALTER TABLE location - ALTER COLUMN location_platform SET DEFAULT 'Other'::location_platform; - -CREATE UNIQUE INDEX location_uniq_platform_idx - ON location (publication_id, location_platform) - WHERE NOT location_platform = 'Other'::location_platform; diff --git a/thoth-api/migrations/v0.12.4/up.sql b/thoth-api/migrations/v0.12.4/up.sql deleted file mode 100644 index 6aadfa98..00000000 --- a/thoth-api/migrations/v0.12.4/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TYPE location_platform ADD VALUE IF NOT EXISTS 'Zenodo'; diff --git a/thoth-api/migrations/v0.12.6/down.sql b/thoth-api/migrations/v0.12.6/down.sql deleted file mode 100644 index 40680f44..00000000 --- a/thoth-api/migrations/v0.12.6/down.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TABLE work DROP CONSTRAINT work_doi_check; -ALTER TABLE work ADD CONSTRAINT work_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\[\]<>\/:a-zA-Z0-9]+$'); - -ALTER TABLE reference DROP CONSTRAINT reference_doi_check; -ALTER TABLE reference ADD CONSTRAINT reference_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\[\]<>\/:a-zA-Z0-9]+$'); - -ALTER TABLE institution DROP CONSTRAINT institution_institution_doi_check; -ALTER TABLE institution ADD CONSTRAINT institution_institution_doi_check - CHECK (institution_doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._\;\(\)\[\]<>\/:a-zA-Z0-9]+$'); diff --git a/thoth-api/migrations/v0.12.6/up.sql b/thoth-api/migrations/v0.12.6/up.sql deleted file mode 100644 index c3f17d5b..00000000 --- a/thoth-api/migrations/v0.12.6/up.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TABLE work DROP CONSTRAINT work_doi_check; -ALTER TABLE work ADD CONSTRAINT work_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._;()\/:a-zA-Z0-9<>+[\]]+$'); - -ALTER TABLE reference DROP CONSTRAINT reference_doi_check; -ALTER TABLE reference ADD CONSTRAINT reference_doi_check - CHECK (doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._;()\/:a-zA-Z0-9<>+[\]]+$'); - -ALTER TABLE institution DROP CONSTRAINT institution_institution_doi_check; -ALTER TABLE institution ADD CONSTRAINT institution_institution_doi_check - CHECK (institution_doi ~ '^https:\/\/doi\.org\/10\.\d{4,9}\/[-._;()\/:a-zA-Z0-9<>+[\]]+$'); diff --git a/thoth-api/migrations/v0.12.7/down.sql b/thoth-api/migrations/v0.12.7/down.sql deleted file mode 100644 index 49c63186..00000000 --- a/thoth-api/migrations/v0.12.7/down.sql +++ /dev/null @@ -1,33 +0,0 @@ --- We cannot drop individual enum values - we must drop the type and recreate it - --- Drop constraints, otherwise it won't be able to cast to text -ALTER TABLE publication - DROP CONSTRAINT IF EXISTS publication_publication_type_work_id_uniq, - DROP CONSTRAINT IF EXISTS publication_non_physical_no_dimensions; - --- Delete publications with about-to-be-dropped types -DELETE FROM publication WHERE publication_type IN ('MP3', 'WAV'); -ALTER TABLE publication ALTER COLUMN publication_type TYPE text; -DROP TYPE publication_type; -CREATE TYPE publication_type AS ENUM ( - 'Paperback', - 'Hardback', - 'PDF', - 'HTML', - 'XML', - 'Epub', - 'Mobi', - 'AZW3', - 'DOCX', - 'FictionBook' -); -ALTER TABLE publication ALTER COLUMN publication_type TYPE publication_type USING publication_type::publication_type; - -ALTER TABLE publication - ADD CONSTRAINT publication_publication_type_work_id_uniq UNIQUE (publication_type, work_id), - ADD CONSTRAINT publication_non_physical_no_dimensions CHECK - ((width_mm IS NULL AND width_in IS NULL - AND height_mm IS NULL AND height_in IS NULL - AND depth_mm IS NULL AND depth_in IS NULL - AND weight_g IS NULL AND weight_oz IS NULL) - OR publication_type = 'Paperback' OR publication_type = 'Hardback'); diff --git a/thoth-api/migrations/v0.12.7/up.sql b/thoth-api/migrations/v0.12.7/up.sql deleted file mode 100644 index 47dc3682..00000000 --- a/thoth-api/migrations/v0.12.7/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TYPE publication_type ADD VALUE IF NOT EXISTS 'MP3'; -ALTER TYPE publication_type ADD VALUE IF NOT EXISTS 'WAV'; diff --git a/thoth-api/migrations/v0.12.9/down.sql b/thoth-api/migrations/v0.12.9/down.sql deleted file mode 100644 index 8bd2d0ea..00000000 --- a/thoth-api/migrations/v0.12.9/down.sql +++ /dev/null @@ -1,51 +0,0 @@ -ALTER TYPE work_status RENAME VALUE 'withdrawn' TO 'withdrawn-from-sale'; - -ALTER TABLE work - -- Drop constraints originally from v0.12.3, - -- otherwise it won't be able to cast to text - DROP CONSTRAINT IF EXISTS work_inactive_no_withdrawn_date_check, - DROP CONSTRAINT IF EXISTS work_active_withdrawn_date_check, - -- Drop new constraint from v.0.12.9 - DROP CONSTRAINT IF EXISTS work_active_publication_date_check; - -ALTER TABLE work ALTER COLUMN work_status TYPE text; - --- !!! if this down migration is run, 'out-of-print' should --- be treated as a placeholder work_status. --- Works will need to be manually reassigned correct work_status: --- out-of-print, out-of-stock-indefinitely, or inactive --- This needs to be run because superseded is a new work_status --- that is removed in this down migration. -UPDATE work - SET work_status = 'out-of-print' - WHERE work_status = 'superseded'; - -DROP TYPE work_status; - -CREATE TYPE work_status AS ENUM ( - 'unspecified', - 'cancelled', - 'forthcoming', - 'postponed-indefinitely', - 'active', - 'no-longer-our-product', - 'out-of-stock-indefinitely', - 'out-of-print', - 'inactive', - 'unknown', - 'remaindered', - 'withdrawn-from-sale', - 'recalled' -); - -ALTER TABLE work ALTER COLUMN work_status TYPE work_status USING work_status::work_status; - --- add constraints back to work table -ALTER TABLE work - ADD CONSTRAINT work_active_withdrawn_date_check CHECK - ((work_status = 'withdrawn-from-sale' OR work_status = 'out-of-print') - OR (work_status NOT IN ('withdrawn-from-sale', 'out-of-print') AND withdrawn_date IS NULL)), - - ADD CONSTRAINT work_inactive_no_withdrawn_date_check CHECK - (((work_status = 'withdrawn-from-sale' OR work_status = 'out-of-print') AND withdrawn_date IS NOT NULL) - OR (work_status NOT IN ('withdrawn-from-sale', 'out-of-print'))); diff --git a/thoth-api/migrations/v0.12.9/up.sql b/thoth-api/migrations/v0.12.9/up.sql deleted file mode 100644 index 08795bc8..00000000 --- a/thoth-api/migrations/v0.12.9/up.sql +++ /dev/null @@ -1,104 +0,0 @@ -ALTER TYPE work_status RENAME VALUE 'withdrawn-from-sale' TO 'withdrawn'; - --- Assign 1900-01-01 as placeholder publication_date for --- Active, withdrawn from sale, out of print, out of stock indefinitely works with no publication date --- Required for work_active_publication_date_check constraint below --- Affected works in production db with this status, 29-05-2024: 59 works (incl. chapters) --- Before running migration, make a list of affected works --- After running migration, publishers should be notified to add correct publication_date --- !!! This is irreversible -UPDATE work - SET - publication_date = '1900-01-01' - WHERE - work_status IN - ('active', 'withdrawn', 'out-of-print', 'out-of-stock-indefinitely', 'inactive') - AND publication_date IS NULL; - --- Drop constraints, otherwise it won't be able to cast to text -ALTER TABLE work - DROP CONSTRAINT IF EXISTS work_active_withdrawn_date_check, - DROP CONSTRAINT IF EXISTS work_inactive_no_withdrawn_date_check; - -ALTER TABLE work ALTER COLUMN work_status TYPE text; - --- delete unused work_status enum -DROP TYPE work_status; - --- Assign out of print/inactive/out of stock indefinitely works work_status 'superseded' --- current counts in production db as of 29-05-2024: --- 145 works (incl. chapters) --- Before running migration, make a list of affected works --- After running migration, publishers should be notified to add correct work_status --- and remove withdrawn_date as necessary. Many OBP "out of print" works are actually first editions --- for which superseded is the correct new work_status. --- !!! This is irreversible -UPDATE work - SET - work_status = 'superseded', - -- assign a withdrawn_date, which is required for superseded works - withdrawn_date = CASE - WHEN withdrawn_date IS NOT NULL THEN withdrawn_date - -- + INTERVAL '1 day' is necessary because at least one work has publication_date on - -- the same day as updated_at, but updated_at has a timestamp, so it's - -- greater than. Which then throws an error with the - -- work_withdrawn_date_after_publication_date_check constraint. - WHEN withdrawn_date IS NULL AND publication_date + INTERVAL '1 day' < updated_at THEN updated_at - ELSE CURRENT_DATE - END - WHERE - work_status = 'out-of-print' - OR work_status = 'out-of-stock-indefinitely' - OR work_status = 'inactive'; - --- Assign unspecified/unkown works work_status 'forthcoming' --- current counts in production db as of 29-05-2024: --- unspecified, 0 works --- unknown, 0 works --- !!! This is irreversible -UPDATE work - SET work_status = 'forthcoming' - WHERE work_status = 'unspecified' OR work_status = 'unknown'; - --- Assign no longer our product/remaindered/recalled works work_status 'withdrawn-from-sale' --- current counts in production db as of 29-05-2024: --- no-longer-our-product, 0 works --- remaindered, 0 works --- recalled, 0 works --- !!! This is irreversible -UPDATE work - SET - work_status = 'withdrawn', - withdrawn_date = COALESCE(withdrawn_date, updated_at) - WHERE - work_status = 'no-longer-our-product' - OR work_status = 'remaindered' - OR work_status = 'recalled'; - --- create new work_status enum, adds superseded -CREATE TYPE work_status AS ENUM ( - 'cancelled', - 'forthcoming', - 'postponed-indefinitely', - 'active', - 'withdrawn', - 'superseded' -); -ALTER TABLE work ALTER COLUMN work_status TYPE work_status USING work_status::work_status; - --- add new constraints (with same names as in v0.12.3) to work table -ALTER TABLE work - -- withdrawn and superseded works must have withdrawn_date - -- note that this constraint has the same name as migration from v.0.12.3, - -- but changes previous constraint by adding superseded alongside withdrawn - ADD CONSTRAINT work_inactive_no_withdrawn_date_check CHECK - (((work_status = 'withdrawn' OR work_status = 'superseded') AND withdrawn_date IS NOT NULL) - OR (work_status NOT IN ('withdrawn', 'superseded'))), - -- all other work statuses must not have withdrawn_date; see above, adds superseded - ADD CONSTRAINT work_active_withdrawn_date_check CHECK - ((work_status = 'withdrawn' OR work_status = 'superseded') - OR (work_status NOT IN ('withdrawn', 'superseded') AND withdrawn_date IS NULL)), - -- active, withdrawn-from-sale, and superseded works must have publication_date - ADD CONSTRAINT work_active_publication_date_check CHECK - ((work_status IN ('active', 'withdrawn', 'superseded') AND publication_date IS NOT NULL) - OR (work_status NOT IN ('active', 'withdrawn', 'superseded'))); diff --git a/thoth-api/migrations/v0.13.0/down.sql b/thoth-api/migrations/v0.13.0/down.sql deleted file mode 100644 index 7207af34..00000000 --- a/thoth-api/migrations/v0.13.0/down.sql +++ /dev/null @@ -1,34 +0,0 @@ -UPDATE location SET location_platform = 'Other' WHERE location_platform = 'Thoth'; - --- Drop the default and unique constraint, otherwise it won't be able to cast to text -ALTER TABLE location ALTER COLUMN location_platform DROP DEFAULT; -DROP INDEX location_uniq_platform_idx; - -ALTER TABLE location ALTER COLUMN location_platform TYPE text; -DROP TYPE location_platform; -CREATE TYPE location_platform AS ENUM ( - 'Project MUSE', - 'OAPEN', - 'DOAB', - 'JSTOR', - 'EBSCO Host', - 'OCLC KB', - 'ProQuest KB', - 'ProQuest ExLibris', - 'EBSCO KB', - 'JISC KB', - 'Google Books', - 'Internet Archive', - 'ScienceOpen', - 'SciELO Books', - 'Publisher Website', - 'Zenodo', - 'Other' - ); -ALTER TABLE location ALTER location_platform TYPE location_platform USING location_platform::location_platform; -ALTER TABLE location - ALTER COLUMN location_platform SET DEFAULT 'Other'::location_platform; - -CREATE UNIQUE INDEX location_uniq_platform_idx - ON location (publication_id, location_platform) - WHERE NOT location_platform = 'Other'::location_platform; diff --git a/thoth-api/migrations/v0.13.0/up.sql b/thoth-api/migrations/v0.13.0/up.sql deleted file mode 100644 index 505e038b..00000000 --- a/thoth-api/migrations/v0.13.0/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TYPE location_platform ADD VALUE IF NOT EXISTS 'Thoth'; diff --git a/thoth-api/migrations/v0.13.1/down.sql b/thoth-api/migrations/v0.13.1/down.sql deleted file mode 100644 index 1ce4f65e..00000000 --- a/thoth-api/migrations/v0.13.1/down.sql +++ /dev/null @@ -1,100 +0,0 @@ --- Remove indexes from account table -DROP INDEX IF EXISTS idx_account_email; - --- Remove indexes from publisher_account table -DROP INDEX IF EXISTS idx_publisher_account_account_id; - --- Remove indexes from work table -DROP INDEX IF EXISTS idx_work_doi; -DROP INDEX IF EXISTS idx_work_reference; -DROP INDEX IF EXISTS idx_work_short_abstract_substr; -DROP INDEX IF EXISTS idx_work_long_abstract_substr; -DROP INDEX IF EXISTS idx_work_landing_page; -DROP INDEX IF EXISTS idx_work_imprint_id; -DROP INDEX IF EXISTS idx_work_updated_at_with_relations_desc; -DROP INDEX IF EXISTS idx_work_full_title_asc; -DROP INDEX IF EXISTS idx_work_publication_date_asc; -DROP INDEX IF EXISTS idx_work_publication_date_desc; -DROP INDEX IF EXISTS idx_work_type_status_pub_date_desc; -DROP INDEX IF EXISTS idx_work_books_pub_date_desc; - --- Remove indexes from work_relation table -DROP INDEX IF EXISTS idx_work_relation_relation_ordinal_relator_relation_type_asc; -DROP INDEX IF EXISTS idx_work_relation_relation_ordinal_related_relation_type_asc; - --- Remove indexes from publisher table -DROP INDEX IF EXISTS idx_publisher_publisher_name; -DROP INDEX IF EXISTS idx_publisher_publisher_shortname; - --- Remove indexes from imprint table -DROP INDEX IF EXISTS idx_imprint_imprint_name; -DROP INDEX IF EXISTS idx_imprint_imprint_url; -DROP INDEX IF EXISTS idx_imprint_publisher_id; - --- Remove indexes from subject table -DROP INDEX IF EXISTS idx_subject_subject_code_asc; -DROP INDEX IF EXISTS idx_subject_subject_ordinal_asc; - --- Remove indexes from publication table -DROP INDEX IF EXISTS idx_publication_work_id; -DROP INDEX IF EXISTS idx_publication_isbn; -DROP INDEX IF EXISTS idx_publication_publication_type; - --- Remove indexes from location table -DROP INDEX IF EXISTS idx_location_location_platform_asc; - --- Remove indexes from price table -DROP INDEX IF EXISTS idx_price_currency_code_asc; - --- Remove indexes from contributor table -DROP INDEX IF EXISTS idx_contributor_full_name; -DROP INDEX IF EXISTS idx_contributor_last_name; -DROP INDEX IF EXISTS idx_contributor_orcid; - --- Remove indexes from contribution table -DROP INDEX IF EXISTS idx_contribution_work_id; -DROP INDEX IF EXISTS idx_contribution_contributor_id; -DROP INDEX IF EXISTS idx_contribution_ordinal_asc; - --- Remove indexes from affiliation table -DROP INDEX IF EXISTS idx_affiliation_contribution_id; -DROP INDEX IF EXISTS idx_affiliation_ordinal_asc; - --- Remove indexes from institution table -DROP INDEX IF EXISTS idx_institution_institution_name; -DROP INDEX IF EXISTS idx_institution_ror; -DROP INDEX IF EXISTS idx_institution_institution_doi; - --- Remove indexes from funding table -DROP INDEX IF EXISTS idx_funding_work_id; -DROP INDEX IF EXISTS idx_funding_program; - --- Remove indexes from series table -DROP INDEX IF EXISTS idx_series_series_name; -DROP INDEX IF EXISTS idx_series_issn_print; -DROP INDEX IF EXISTS idx_series_issn_digital; -DROP INDEX IF EXISTS idx_series_series_url; -DROP INDEX IF EXISTS idx_series_series_description; -DROP INDEX IF EXISTS idx_series_imprint_id; - --- Remove indexes from issue table -DROP INDEX IF EXISTS idx_issue_ordinal_work_id_asc; -DROP INDEX IF EXISTS idx_issue_ordinal_series_id_asc; - --- Remove indexes from language table -DROP INDEX IF EXISTS idx_language_language_code_asc; - --- Remove indexes from reference table -DROP INDEX IF EXISTS idx_reference_work_id; -DROP INDEX IF EXISTS idx_reference_doi; -DROP INDEX IF EXISTS idx_reference_unstructured_citation; -DROP INDEX IF EXISTS idx_reference_issn; -DROP INDEX IF EXISTS idx_reference_isbn; -DROP INDEX IF EXISTS idx_reference_journal_title; -DROP INDEX IF EXISTS idx_reference_article_title; -DROP INDEX IF EXISTS idx_reference_series_title; -DROP INDEX IF EXISTS idx_reference_volume_title; -DROP INDEX IF EXISTS idx_reference_author_substr; -DROP INDEX IF EXISTS idx_reference_standard_designator; -DROP INDEX IF EXISTS idx_reference_standards_body_name; -DROP INDEX IF EXISTS idx_reference_standards_body_acronym; diff --git a/thoth-api/migrations/v0.13.1/up.sql b/thoth-api/migrations/v0.13.1/up.sql deleted file mode 100644 index c8e408b1..00000000 --- a/thoth-api/migrations/v0.13.1/up.sql +++ /dev/null @@ -1,105 +0,0 @@ --- Indexes account table -CREATE INDEX idx_account_email ON account (email); - --- Indexes publisher_account table -CREATE INDEX idx_publisher_account_account_id ON publisher_account (account_id); - --- Indexes work table -CREATE INDEX idx_work_doi ON work (doi); -CREATE INDEX idx_work_reference ON work (reference); -CREATE INDEX idx_work_short_abstract_substr ON work (substring(short_abstract FROM 1 FOR 255)); -CREATE INDEX idx_work_long_abstract_substr ON work (substring(long_abstract FROM 1 FOR 255)); -CREATE INDEX idx_work_landing_page ON work (landing_page); -CREATE INDEX idx_work_imprint_id ON work (imprint_id); -CREATE INDEX idx_work_updated_at_with_relations_desc ON work (updated_at_with_relations DESC, work_id); -CREATE INDEX idx_work_full_title_asc ON work (full_title ASC, work_id); -CREATE INDEX idx_work_publication_date_asc ON work (publication_date ASC, work_id); -CREATE INDEX idx_work_publication_date_desc ON work (publication_date DESC, work_id); -CREATE INDEX idx_work_type_status_pub_date_desc - ON work (work_type, work_status, publication_date DESC); -CREATE INDEX idx_work_books_pub_date_desc - ON work (publication_date DESC) - WHERE work_type IN ('monograph', 'edited-book', 'textbook') AND work_status = 'active'; - --- Indexes work_relation table -CREATE INDEX idx_work_relation_relation_ordinal_relator_relation_type_asc - ON work_relation (relation_ordinal ASC, relator_work_id, relation_type); -CREATE INDEX idx_work_relation_relation_ordinal_related_relation_type_asc - ON work_relation (relation_ordinal ASC, related_work_id, relation_type); - --- Indexes publisher table -CREATE INDEX idx_publisher_publisher_name ON publisher (publisher_name); -CREATE INDEX idx_publisher_publisher_shortname ON publisher (publisher_shortname); - --- Indexes imprint table -CREATE INDEX idx_imprint_imprint_name ON imprint (imprint_name); -CREATE INDEX idx_imprint_imprint_url ON imprint (imprint_url); -CREATE INDEX idx_imprint_publisher_id ON imprint (publisher_id); - --- Indexes subject table -CREATE INDEX idx_subject_subject_code_asc ON subject (subject_code ASC, work_id); -CREATE INDEX idx_subject_subject_ordinal_asc ON subject (subject_ordinal ASC, work_id); - --- Indexes publication table -CREATE INDEX idx_publication_work_id ON publication (work_id); -CREATE INDEX idx_publication_isbn ON publication (isbn); -CREATE INDEX idx_publication_publication_type ON publication (publication_type); - --- Indexes location table -CREATE INDEX idx_location_location_platform_asc ON location (location_platform ASC, publication_id); - --- Indexes price table -CREATE INDEX idx_price_currency_code_asc ON price (currency_code ASC, publication_id); - --- Indexes contributor table -CREATE INDEX idx_contributor_full_name ON contributor (full_name); -CREATE INDEX idx_contributor_last_name ON contributor (last_name); -CREATE INDEX idx_contributor_orcid ON contributor (orcid); - --- Indexes contribution table -CREATE INDEX idx_contribution_work_id ON contribution (work_id); -CREATE INDEX idx_contribution_contributor_id ON contribution (contributor_id); -CREATE INDEX idx_contribution_ordinal_asc ON contribution (contribution_ordinal ASC, work_id); - --- Indexes affiliation table -CREATE INDEX idx_affiliation_contribution_id ON affiliation (contribution_id); -CREATE INDEX idx_affiliation_ordinal_asc ON affiliation (affiliation_ordinal ASC, contribution_id); - --- Indexes contributor table -CREATE INDEX idx_institution_institution_name ON institution (institution_name); -CREATE INDEX idx_institution_ror ON institution (ror); -CREATE INDEX idx_institution_institution_doi ON institution (institution_doi); - --- Indexes funding table -CREATE INDEX idx_funding_work_id ON funding (work_id); -CREATE INDEX idx_funding_program ON funding (program); - --- Indexes series table -CREATE INDEX idx_series_series_name ON series (series_name); -CREATE INDEX idx_series_issn_print ON series (issn_print); -CREATE INDEX idx_series_issn_digital ON series (issn_digital); -CREATE INDEX idx_series_series_url ON series (series_url); -CREATE INDEX idx_series_series_description ON series (series_description); -CREATE INDEX idx_series_imprint_id ON series (imprint_id); - --- Indexes issue table -CREATE INDEX idx_issue_ordinal_work_id_asc ON issue (issue_ordinal ASC, work_id); -CREATE INDEX idx_issue_ordinal_series_id_asc ON issue (issue_ordinal ASC, series_id); - --- Indexes language table -CREATE INDEX idx_language_language_code_asc ON language (language_code ASC, work_id); - --- Indexes reference table -CREATE INDEX idx_reference_work_id ON reference (work_id); -CREATE INDEX idx_reference_doi ON reference (doi); -CREATE INDEX idx_reference_unstructured_citation ON reference (unstructured_citation); -CREATE INDEX idx_reference_issn ON reference (issn); -CREATE INDEX idx_reference_isbn ON reference (isbn); -CREATE INDEX idx_reference_journal_title ON reference (journal_title); -CREATE INDEX idx_reference_article_title ON reference (article_title); -CREATE INDEX idx_reference_series_title ON reference (series_title); -CREATE INDEX idx_reference_volume_title ON reference (volume_title); -CREATE INDEX idx_reference_author_substr ON reference ((substring(author FROM 1 FOR 255))); -CREATE INDEX idx_reference_standard_designator ON reference (standard_designator); -CREATE INDEX idx_reference_standards_body_name ON reference (standards_body_name); -CREATE INDEX idx_reference_standards_body_acronym ON reference (standards_body_acronym); diff --git a/thoth-api/src/account/handler.rs b/thoth-api/src/account/handler.rs deleted file mode 100644 index dfa36608..00000000 --- a/thoth-api/src/account/handler.rs +++ /dev/null @@ -1,216 +0,0 @@ -use diesel::prelude::*; -use dotenv::dotenv; -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; -use regex::Regex; -use std::env; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; -use uuid::Uuid; - -use crate::account::{ - model::{ - Account, AccountAccess, AccountData, DecodedToken, LinkedPublisher, NewAccount, - NewPassword, NewPublisherAccount, PublisherAccount, Token, - }, - service::get_account, - util::{make_hash, make_salt}, -}; -use crate::db::PgPool; -use thoth_errors::{ThothError, ThothResult}; - -impl Account { - pub fn get_permissions(&self, pool: &PgPool) -> ThothResult> { - let publisher_accounts = self.get_publisher_accounts(pool)?; - let permissions: Vec = - publisher_accounts.into_iter().map(|p| p.into()).collect(); - Ok(permissions) - } - - pub fn get_publisher_accounts(&self, pool: &PgPool) -> ThothResult> { - use crate::schema::publisher_account::dsl::*; - let mut conn = pool.get()?; - - let publisher_accounts = publisher_account - .filter(account_id.eq(self.account_id)) - .load::(&mut conn) - .expect("Error loading publisher accounts"); - Ok(publisher_accounts) - } - - pub fn add_publisher_account( - &self, - pool: &PgPool, - linked_publisher: LinkedPublisher, - ) -> ThothResult { - use crate::schema::publisher_account::dsl::*; - let mut conn = pool.get()?; - let new_publisher_account = NewPublisherAccount { - account_id: self.account_id, - publisher_id: linked_publisher.publisher_id, - is_admin: linked_publisher.is_admin, - }; - diesel::insert_into(publisher_account) - .values(&new_publisher_account) - .get_result::(&mut conn) - .map_err(Into::into) - } - - pub fn get_account_access(&self, linked_publishers: Vec) -> AccountAccess { - AccountAccess { - is_superuser: self.is_superuser, - is_bot: self.is_bot, - linked_publishers, - } - } - - pub fn issue_token(&self, pool: &PgPool) -> ThothResult { - const DEFAULT_TOKEN_VALIDITY: i64 = 24 * 60 * 60; - let mut connection = pool.get()?; - dotenv().ok(); - let linked_publishers: Vec = - self.get_permissions(pool).unwrap_or_default(); - let namespace = self.get_account_access(linked_publishers); - let secret_str = env::var("SECRET_KEY").expect("SECRET_KEY must be set"); - let secret: &[u8] = secret_str.as_bytes(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| ThothError::InternalError("Unable to set token iat".into()))?; - let claim = Token { - sub: self.email.clone(), - exp: now.as_secs() as i64 + DEFAULT_TOKEN_VALIDITY, - iat: now.as_secs() as i64, - jti: Uuid::new_v4().to_string(), - namespace, - }; - let token = encode( - &Header::default(), - &claim, - &EncodingKey::from_secret(secret), - ) - .map_err(|_| ThothError::InternalError("Unable to create token".into())); - - use crate::schema::account::dsl; - let updated_account = diesel::update(dsl::account.find(self.account_id)) - .set(dsl::token.eq(token?)) - .get_result::(&mut connection) - .expect("Unable to set token"); - Ok(updated_account.token.unwrap()) - } -} - -impl From for NewAccount { - fn from(account_data: AccountData) -> Self { - let AccountData { - name, - surname, - email, - password, - is_superuser, - is_bot, - .. - } = account_data; - - let salt = make_salt(); - let hash = make_hash(&password, &salt).to_vec(); - Self { - name, - surname, - email, - hash, - salt, - is_superuser, - is_bot, - } - } -} - -impl From for LinkedPublisher { - fn from(publisher_account: PublisherAccount) -> Self { - let PublisherAccount { - publisher_id, - is_admin, - .. - } = publisher_account; - Self { - publisher_id, - is_admin, - } - } -} - -impl Token { - pub fn verify(token: &str) -> ThothResult { - dotenv().ok(); - let secret_str = env::var("SECRET_KEY").expect("SECRET_KEY must be set"); - let secret: &[u8] = secret_str.as_bytes(); - - let data = decode::( - token, - &DecodingKey::from_secret(secret), - &Validation::default(), - ) - .map_err(|_| ThothError::InvalidToken)?; - Ok(data.claims) - } - - pub fn account_id(&self, pool: &PgPool) -> Uuid { - get_account(&self.sub, pool).unwrap().account_id - } -} - -lazy_static::lazy_static! { - static ref BEARER_REGEXP : Regex = Regex::new(r"^Bearer\s(.*)$").expect("Bearer regexp failed!"); -} - -impl actix_web::FromRequest for DecodedToken { - type Error = actix_web::Error; - type Future = futures::future::Ready>; - - fn from_request(req: &actix_web::HttpRequest, _: &mut actix_web::dev::Payload) -> Self::Future { - let token = req - .headers() - .get(actix_web::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|authorization| { - BEARER_REGEXP - .captures(authorization) - .and_then(|captures| captures.get(1)) - }) - .map(|v| v.as_str()); - - futures::future::ready(Ok(match token { - None => DecodedToken { jwt: None }, - Some(token) => match Token::verify(token) { - Ok(decoded) => DecodedToken { jwt: Some(decoded) }, - Err(_) => DecodedToken { jwt: None }, - }, - })) - } -} - -impl NewPassword { - pub fn new(email: String, password: String) -> Self { - let salt = make_salt(); - let hash = make_hash(&password, &salt).to_vec(); - Self { email, hash, salt } - } -} - -impl PublisherAccount { - pub fn delete(&self, pool: &PgPool) -> ThothResult<()> { - use crate::schema::publisher_account::dsl::*; - - pool.get()?.transaction(|connection| { - diesel::delete( - publisher_account.filter( - account_id - .eq(self.account_id) - .and(publisher_id.eq(self.publisher_id)), - ), - ) - .execute(connection) - .map(|_| ()) - .map_err(Into::into) - }) - } -} diff --git a/thoth-api/src/account/mod.rs b/thoth-api/src/account/mod.rs deleted file mode 100644 index 225e37d9..00000000 --- a/thoth-api/src/account/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[cfg(feature = "backend")] -pub mod handler; -pub mod model; -#[cfg(feature = "backend")] -pub mod service; -#[cfg(feature = "backend")] -pub mod util; diff --git a/thoth-api/src/account/model.rs b/thoth-api/src/account/model.rs deleted file mode 100644 index 773c54cc..00000000 --- a/thoth-api/src/account/model.rs +++ /dev/null @@ -1,164 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::model::Timestamp; -#[cfg(feature = "backend")] -use crate::schema::account; -#[cfg(feature = "backend")] -use crate::schema::publisher_account; -use thoth_errors::ThothError; -use thoth_errors::ThothResult; - -#[cfg_attr(feature = "backend", derive(Queryable))] -#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] -pub struct Account { - pub account_id: Uuid, - pub name: String, - pub surname: String, - pub email: String, - pub hash: Vec, - pub salt: String, - pub is_superuser: bool, - pub is_bot: bool, - pub is_active: bool, - pub created_at: Timestamp, - pub updated_at: Timestamp, - pub token: Option, -} - -#[cfg_attr(feature = "backend", derive(Insertable))] -#[cfg_attr(feature = "backend", diesel(table_name = account))] -pub struct NewAccount { - pub name: String, - pub surname: String, - pub email: String, - pub hash: Vec, - pub salt: String, - pub is_superuser: bool, - pub is_bot: bool, -} - -#[derive(Debug)] -pub struct AccountData { - pub name: String, - pub surname: String, - pub email: String, - pub password: String, - pub is_superuser: bool, - pub is_bot: bool, -} - -#[cfg_attr(feature = "backend", derive(Queryable))] -pub struct PublisherAccount { - pub account_id: Uuid, - pub publisher_id: Uuid, - pub is_admin: bool, - pub created_at: Timestamp, - pub updated_at: Timestamp, -} - -#[cfg_attr(feature = "backend", derive(Insertable))] -#[cfg_attr(feature = "backend", diesel(table_name = publisher_account))] -pub struct NewPublisherAccount { - pub account_id: Uuid, - pub publisher_id: Uuid, - pub is_admin: bool, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AccountAccess { - pub is_superuser: bool, - pub is_bot: bool, - pub linked_publishers: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct LinkedPublisher { - pub publisher_id: Uuid, - pub is_admin: bool, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Token { - pub sub: String, - pub exp: i64, - pub iat: i64, - pub jti: String, - #[serde(rename = "https://thoth.pub/resource_access")] - pub namespace: AccountAccess, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AccountDetails { - pub account_id: Uuid, - pub name: String, - pub surname: String, - pub email: String, - pub token: Option, - pub created_at: Timestamp, - pub updated_at: Timestamp, - pub resource_access: AccountAccess, -} - -#[derive(Debug, Clone)] -pub struct DecodedToken { - pub jwt: Option, -} - -#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Default)] -pub struct LoginCredentials { - pub email: String, - pub password: String, -} - -#[cfg_attr(feature = "backend", derive(AsChangeset), diesel(table_name = account))] -pub struct NewPassword { - pub email: String, - pub hash: Vec, - pub salt: String, -} - -impl DecodedToken { - pub fn get_user_permissions(&self) -> AccountAccess { - if let Some(jwt) = &self.jwt { - jwt.namespace.clone() - } else { - AccountAccess { - is_superuser: false, - is_bot: false, - linked_publishers: vec![], - } - } - } -} - -impl AccountAccess { - pub fn can_edit(&self, publisher_id: Uuid) -> ThothResult<()> { - if self.is_superuser - || self - .linked_publishers - .iter() - .any(|publisher| publisher.publisher_id == publisher_id) - { - Ok(()) - } else { - Err(ThothError::Unauthorised) - } - } - - pub fn restricted_to(&self) -> Option> { - if self.is_superuser { - None - } else { - Some( - self.linked_publishers - .iter() - .map(|publisher| publisher.publisher_id.to_string()) - .collect(), - ) - } - } -} diff --git a/thoth-api/src/account/service.rs b/thoth-api/src/account/service.rs deleted file mode 100644 index 45943391..00000000 --- a/thoth-api/src/account/service.rs +++ /dev/null @@ -1,126 +0,0 @@ -use diesel::prelude::*; - -use crate::account::{ - model::{Account, AccountData, AccountDetails, LinkedPublisher, NewAccount, NewPassword}, - util::verify, -}; -use crate::db::PgPool; -use crate::model::publisher::Publisher; -use thoth_errors::{ThothError, ThothResult}; - -pub fn login(user_email: &str, user_password: &str, pool: &PgPool) -> ThothResult { - use crate::schema::account::dsl; - - let mut conn = pool.get()?; - let account = dsl::account - .filter(dsl::email.eq(user_email)) - .first::(&mut conn) - .map_err(|_| ThothError::Unauthorised)?; - - if verify(&account, user_password) { - Ok(account) - } else { - Err(ThothError::Unauthorised) - } -} - -pub fn get_account(email: &str, pool: &PgPool) -> ThothResult { - use crate::schema::account::dsl; - - let mut conn = pool.get()?; - let account = dsl::account - .filter(dsl::email.eq(email)) - .first::(&mut conn) - .map_err(|_| ThothError::Unauthorised)?; - Ok(account) -} - -pub fn get_account_details(email: &str, pool: &PgPool) -> ThothResult { - use crate::schema::account::dsl; - - let mut conn = pool.get()?; - let account = dsl::account - .filter(dsl::email.eq(email)) - .first::(&mut conn) - .map_err(|_| ThothError::Unauthorised)?; - let linked_publishers: Vec = account.get_permissions(pool).unwrap_or_default(); - let resource_access = account.get_account_access(linked_publishers); - let account_details = AccountDetails { - account_id: account.account_id, - name: account.name, - surname: account.surname, - email: account.email, - token: account.token, - created_at: account.created_at, - updated_at: account.updated_at, - resource_access, - }; - Ok(account_details) -} - -pub fn register( - pool: &PgPool, - name: String, - surname: String, - email: String, - password: String, - is_superuser: bool, - is_bot: bool, -) -> ThothResult { - use crate::schema::account::dsl; - - let mut connection = pool.get()?; - let account: NewAccount = AccountData { - name, - surname, - email, - password, - is_superuser, - is_bot, - } - .into(); - let created_account: Account = diesel::insert_into(dsl::account) - .values(&account) - .get_result::(&mut connection)?; - Ok(created_account) -} - -pub fn all_emails(pool: &PgPool) -> ThothResult> { - let mut connection = pool.get()?; - - use crate::schema::account::dsl; - let emails = dsl::account - .select((dsl::email, dsl::is_superuser, dsl::is_bot, dsl::is_active)) - .order(dsl::email.asc()) - .load::<(String, bool, bool, bool)>(&mut connection) - .map_err(|_| ThothError::InternalError("Unable to load records".into()))?; - Ok(emails) -} - -pub fn all_publishers(pool: &PgPool) -> ThothResult> { - let mut connection = pool.get()?; - - use crate::schema::publisher::dsl; - let publishers = dsl::publisher - .order(dsl::publisher_name.asc()) - .load::(&mut connection) - .map_err(|_| ThothError::InternalError("Unable to load records".into()))?; - Ok(publishers) -} - -pub fn update_password(email: &str, password: &str, pool: &PgPool) -> ThothResult { - let mut connection = pool.get()?; - - let new_password = NewPassword::new(email.to_string(), password.to_string()); - use crate::schema::account::dsl; - - let account_obj = dsl::account - .filter(dsl::email.eq(email)) - .first::(&mut connection) - .map_err(Into::::into)?; - - diesel::update(dsl::account.find(&account_obj.account_id)) - .set(&new_password) - .get_result(&mut connection) - .map_err(Into::into) -} diff --git a/thoth-api/src/account/util.rs b/thoth-api/src/account/util.rs deleted file mode 100644 index 79d86dca..00000000 --- a/thoth-api/src/account/util.rs +++ /dev/null @@ -1,30 +0,0 @@ -use argon2rs::argon2i_simple; - -use super::model::Account; - -pub fn make_salt() -> String { - use rand::Rng; - const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ - abcdefghijklmnopqrstuvwxyz\ - 0123456789)(*&^%$#@!~"; - const PASSWORD_LEN: usize = 128; - let mut rng = rand::rng(); - - let password: String = (0..PASSWORD_LEN) - .map(|_| { - let idx = rng.random_range(0..CHARSET.len()); - CHARSET[idx] as char - }) - .collect(); - password -} - -pub fn make_hash(password: &str, salt: &str) -> [u8; argon2rs::defaults::LENGTH] { - argon2i_simple(password, salt) -} - -pub fn verify(account: &Account, password: &str) -> bool { - let Account { hash, salt, .. } = account; - - make_hash(password, salt) == hash.as_ref() -} diff --git a/thoth-api/src/graphql/mod.rs b/thoth-api/src/graphql/mod.rs index 51e33363..10c37fb3 100644 --- a/thoth-api/src/graphql/mod.rs +++ b/thoth-api/src/graphql/mod.rs @@ -1,6 +1,22 @@ -#[cfg(feature = "backend")] pub mod model; -pub mod utils; +pub mod types; + +mod mutation; +mod query; -#[cfg(feature = "backend")] pub use juniper::http::GraphQLRequest; + +pub use model::Context; +pub use mutation::MutationRoot; +pub use query::QueryRoot; + +use juniper::{EmptySubscription, RootNode}; + +pub type Schema = RootNode<'static, QueryRoot, MutationRoot, EmptySubscription>; + +pub fn create_schema() -> Schema { + Schema::new(QueryRoot {}, MutationRoot {}, EmptySubscription::new()) +} + +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index 8a157c52..d83e5f02 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -1,523 +1,556 @@ -use chrono::naive::NaiveDate; -use juniper::RootNode; -use juniper::{EmptySubscription, FieldResult}; use std::sync::Arc; + +use chrono::naive::NaiveDate; +use juniper::{FieldError, FieldResult}; use uuid::Uuid; +use zitadel::actix::introspection::IntrospectedUser; -use crate::account::model::AccountAccess; -use crate::account::model::DecodedToken; +use super::types::inputs::{ + ContributionOrderBy, Convert, Direction, FundingOrderBy, IssueOrderBy, LanguageOrderBy, + LengthUnit, PriceOrderBy, SubjectOrderBy, TimeExpression, WeightUnit, +}; use crate::db::PgPool; -use crate::model::affiliation::*; -use crate::model::contribution::*; -use crate::model::contributor::*; -use crate::model::funding::*; -use crate::model::imprint::*; -use crate::model::institution::*; -use crate::model::issue::*; -use crate::model::language::*; -use crate::model::location::*; -use crate::model::price::*; -use crate::model::publication::*; -use crate::model::publisher::*; -use crate::model::reference::*; -use crate::model::series::*; -use crate::model::subject::*; -use crate::model::work::*; -use crate::model::work_relation::*; -use crate::model::Convert; -use crate::model::Crud; -use crate::model::Doi; -use crate::model::Isbn; -use crate::model::LengthUnit; -use crate::model::Orcid; -use crate::model::Ror; -use crate::model::Timestamp; -use crate::model::WeightUnit; -use thoth_errors::{ThothError, ThothResult}; - -use super::utils::{Direction, Expression}; +use crate::markup::{convert_from_jats, ConversionLimit, MarkupFormat}; +use crate::model::{ + additional_resource::{AdditionalResource, AdditionalResourceOrderBy}, + affiliation::{Affiliation, AffiliationOrderBy}, + award::{Award, AwardOrderBy, AwardRole}, + biography::{Biography, BiographyOrderBy}, + book_review::{BookReview, BookReviewOrderBy}, + contact::{Contact, ContactOrderBy, ContactType}, + contribution::{Contribution, ContributionType}, + contributor::Contributor, + endorsement::{Endorsement, EndorsementOrderBy}, + file::{File, FileType}, + funding::Funding, + imprint::{Imprint, ImprintField, ImprintOrderBy}, + institution::{CountryCode, Institution}, + issue::Issue, + language::{Language, LanguageCode, LanguageRelation}, + locale::LocaleCode, + location::{Location, LocationOrderBy, LocationPlatform}, + price::{CurrencyCode, Price}, + publication::{ + AccessibilityException, AccessibilityStandard, Publication, PublicationOrderBy, + PublicationType, + }, + publisher::Publisher, + r#abstract::{Abstract, AbstractOrderBy, AbstractType}, + reference::{Reference, ReferenceOrderBy}, + series::{Series, SeriesType}, + subject::{Subject, SubjectType}, + title::{Title, TitleOrderBy}, + work::{Work, WorkOrderBy, WorkStatus, WorkType}, + work_featured_video::WorkFeaturedVideo, + work_relation::{RelationType, WorkRelation, WorkRelationOrderBy}, + Crud, Doi, Isbn, Orcid, Ror, Timestamp, +}; +use crate::policy::PolicyContext; +use crate::storage::{CloudFrontClient, S3Client}; +use thoth_errors::ThothError; impl juniper::Context for Context {} -#[derive(Clone)] pub struct Context { pub db: Arc, - pub account_access: AccountAccess, - pub token: DecodedToken, + pub user: Option, + pub s3_client: Arc, + pub cloudfront_client: Arc, } impl Context { - pub fn new(pool: Arc, token: DecodedToken) -> Self { + pub fn new( + pool: Arc, + user: Option, + s3_client: Arc, + cloudfront_client: Arc, + ) -> Self { Self { db: pool, - account_access: token.get_user_permissions(), - token, + user, + s3_client, + cloudfront_client, } } -} -#[derive(juniper::GraphQLInputObject)] -#[graphql(description = "Field and order to use when sorting contributions list")] -pub struct ContributionOrderBy { - pub field: ContributionField, - pub direction: Direction, -} - -impl Default for ContributionOrderBy { - fn default() -> ContributionOrderBy { - ContributionOrderBy { - field: ContributionField::ContributionType, - direction: Default::default(), - } + pub fn s3_client(&self) -> &S3Client { + self.s3_client.as_ref() } -} -#[derive(juniper::GraphQLInputObject)] -#[graphql(description = "Field and order to use when sorting issues list")] -pub struct IssueOrderBy { - pub field: IssueField, - pub direction: Direction, -} - -impl Default for IssueOrderBy { - fn default() -> IssueOrderBy { - IssueOrderBy { - field: IssueField::IssueOrdinal, - direction: Default::default(), - } + pub fn cloudfront_client(&self) -> &CloudFrontClient { + self.cloudfront_client.as_ref() } } -#[derive(juniper::GraphQLInputObject)] -#[graphql(description = "Field and order to use when sorting languages list")] -pub struct LanguageOrderBy { - pub field: LanguageField, - pub direction: Direction, -} - -impl Default for LanguageOrderBy { - fn default() -> LanguageOrderBy { - LanguageOrderBy { - field: LanguageField::LanguageCode, - direction: Default::default(), - } +impl PolicyContext for Context { + fn db(&self) -> &PgPool { + &self.db } -} - -#[derive(juniper::GraphQLInputObject)] -#[graphql(description = "Field and order to use when sorting prices list")] -pub struct PriceOrderBy { - pub field: PriceField, - pub direction: Direction, -} - -impl Default for PriceOrderBy { - fn default() -> PriceOrderBy { - PriceOrderBy { - field: PriceField::CurrencyCode, - direction: Default::default(), - } + fn user(&self) -> Option<&IntrospectedUser> { + self.user.as_ref() } } -#[derive(juniper::GraphQLInputObject)] -#[graphql(description = "Field and order to use when sorting subjects list")] -pub struct SubjectOrderBy { - pub field: SubjectField, - pub direction: Direction, -} - -impl Default for SubjectOrderBy { - fn default() -> SubjectOrderBy { - SubjectOrderBy { - field: SubjectField::SubjectType, - direction: Default::default(), - } +#[juniper::graphql_object(Context = Context, description = "A written text that can be published")] +impl Work { + #[graphql(description = "Thoth ID of the work")] + pub fn work_id(&self) -> &Uuid { + &self.work_id } -} -#[derive(juniper::GraphQLInputObject)] -#[graphql(description = "Field and order to use when sorting fundings list")] -pub struct FundingOrderBy { - pub field: FundingField, - pub direction: Direction, -} - -impl Default for FundingOrderBy { - fn default() -> FundingOrderBy { - FundingOrderBy { - field: FundingField::Program, - direction: Default::default(), - } + #[graphql(description = "Type of the work")] + pub fn work_type(&self) -> &WorkType { + &self.work_type } -} -#[derive(juniper::GraphQLInputObject)] -#[graphql( - description = "Timestamp and choice out of greater than/less than to use when filtering by a time field (e.g. updated_at)" -)] -pub struct TimeExpression { - pub timestamp: Timestamp, - pub expression: Expression, -} + #[graphql(description = "Publication status of the work")] + pub fn work_status(&self) -> &WorkStatus { + &self.work_status + } -pub struct QueryRoot; + #[graphql(description = "Concatenation of title and subtitle with punctuation mark")] + #[graphql( + deprecated = "Please use Work `titles` field instead to get the correct full title in a multilingual manner" + )] + pub fn full_title(&self, ctx: &Context) -> FieldResult { + Ok(Title::canonical_from_work_id(&ctx.db, &self.work_id)?.full_title) + } -#[juniper::graphql_object(Context = Context)] -impl QueryRoot { - #[allow(clippy::too_many_arguments)] - #[graphql(description = "Query the full list of works")] - fn works( - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page" - )] - filter: Option, - #[graphql( - default = WorkOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - #[graphql( - default = vec![], - description = "Specific types to filter by", - )] - work_types: Option>, - #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< - WorkStatus, - >, - #[graphql( - default = vec![], - description = "Specific statuses to filter by" - )] - work_statuses: Option>, - #[graphql( - description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" - )] - updated_at_with_relations: Option, - ) -> FieldResult> { - let mut statuses = work_statuses.unwrap_or_default(); - if let Some(status) = work_status { - statuses.push(status); - } - Work::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - filter, - order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, - None, - work_types.unwrap_or_default(), - statuses, - updated_at_with_relations, - ) - .map_err(|e| e.into()) + #[graphql(description = "Main title of the work (excluding subtitle)")] + #[graphql( + deprecated = "Please use Work `titles` field instead to get the correct title in a multilingual manner" + )] + pub fn title(&self, ctx: &Context) -> FieldResult { + Ok(Title::canonical_from_work_id(&ctx.db, &self.work_id)?.title) } - #[graphql(description = "Query a single work using its ID")] - fn work( - context: &Context, - #[graphql(description = "Thoth work ID to search on")] work_id: Uuid, - ) -> FieldResult { - Work::from_id(&context.db, &work_id).map_err(|e| e.into()) + #[graphql(description = "Secondary title of the work (excluding main title)")] + #[graphql( + deprecated = "Please use Work `titles` field instead to get the correct sub_title in a multilingual manner" + )] + pub fn subtitle(&self, ctx: &Context) -> FieldResult> { + Ok(Title::canonical_from_work_id(&ctx.db, &self.work_id)?.subtitle) } - #[graphql(description = "Query a single work using its DOI")] - fn work_by_doi( - context: &Context, - #[graphql(description = "Work DOI to search on")] doi: Doi, - ) -> FieldResult { - Work::from_doi(&context.db, doi, vec![]).map_err(|e| e.into()) + #[graphql( + description = "Short abstract of the work. Where a work has two different versions of the abstract, the truncated version should be entered here. Otherwise, it can be left blank. This field is not output in metadata formats; where relevant, Long Abstract is used instead." + )] + #[graphql( + deprecated = "Please use Work `abstracts` field instead to get the correct short abstract in a multilingual manner" + )] + pub fn short_abstract(&self, ctx: &Context) -> FieldResult> { + Ok( + Abstract::short_canonical_from_work_id(&ctx.db, &self.work_id) + .map(|a| a.content) + .ok(), + ) } - #[graphql(description = "Get the total number of works")] - fn work_count( - context: &Context, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page", - )] - filter: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs", - )] - publishers: Option>, - #[graphql( - default = vec![], - description = "Specific types to filter by", - )] - work_types: Option>, - #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< - WorkStatus, - >, - #[graphql( - default = vec![], - description = "Specific statuses to filter by" - )] - work_statuses: Option>, - #[graphql( - description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" - )] - updated_at_with_relations: Option, - ) -> FieldResult { - let mut statuses = work_statuses.unwrap_or_default(); - if let Some(status) = work_status { - statuses.push(status); - } - Work::count( - &context.db, - filter, - publishers.unwrap_or_default(), - work_types.unwrap_or_default(), - statuses, - updated_at_with_relations, + #[graphql( + description = "Abstract of the work. Where a work has only one abstract, it should be entered here, and Short Abstract can be left blank. Long Abstract is output in metadata formats, and Short Abstract is not." + )] + #[graphql( + deprecated = "Please use Work `abstracts` field instead to get the correct long abstract in a multilingual manner" + )] + pub fn long_abstract(&self, ctx: &Context) -> FieldResult> { + Ok( + Abstract::long_canonical_from_work_id(&ctx.db, &self.work_id) + .map(|a| a.content) + .ok(), ) - .map_err(|e| e.into()) } #[allow(clippy::too_many_arguments)] - #[graphql(description = "Query the full list of books (a subset of the full list of works)")] - fn books( + #[graphql(description = "Query titles by work ID")] + fn titles( + &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page" + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on title_, subtitle, full_title fields" )] filter: Option, #[graphql( - default = WorkOrderBy::default(), + default = TitleOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< - WorkStatus, - >, + order: Option, #[graphql( default = vec![], - description = "Specific statuses to filter by" + description = "If set, only shows results with these locale codes" )] - work_statuses: Option>, + locale_codes: Option>, #[graphql( - description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" + default = MarkupFormat::JatsXml, + description = "If set, only shows results with this markup format" )] - updated_at_with_relations: Option, - ) -> FieldResult> { - let mut statuses = work_statuses.unwrap_or_default(); - if let Some(status) = work_status { - statuses.push(status); - } - Work::all( + markup_format: Option, + ) -> FieldResult> { + let mut titles = Title::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), filter, order.unwrap_or_default(), - publishers.unwrap_or_default(), + vec![], + Some(self.work_id), + None, + locale_codes.unwrap_or_default(), + vec![], None, None, - vec![ - WorkType::Monograph, - WorkType::EditedBook, - WorkType::Textbook, - WorkType::JournalIssue, - ], - statuses, - updated_at_with_relations, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single book using its DOI")] - fn book_by_doi( - context: &Context, - #[graphql(description = "Book DOI to search on")] doi: Doi, - ) -> FieldResult { - Work::from_doi( - &context.db, - doi, - vec![ - WorkType::Monograph, - WorkType::EditedBook, - WorkType::Textbook, - WorkType::JournalIssue, - ], ) - .map_err(|e| e.into()) - } - - #[graphql( - description = "Get the total number of books (a subset of the total number of works)" - )] - fn book_count( - context: &Context, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page" - )] - filter: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< - WorkStatus, - >, - #[graphql( - default = vec![], - description = "Specific statuses to filter by" - )] - work_statuses: Option>, - #[graphql( - description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" - )] - updated_at_with_relations: Option, - ) -> FieldResult { - let mut statuses = work_statuses.unwrap_or_default(); - if let Some(status) = work_status { - statuses.push(status); + .map_err(FieldError::from)?; + + let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + for title in titles.iter_mut() { + title.title = convert_from_jats(&title.title, markup, ConversionLimit::Title)?; + title.subtitle = title + .subtitle + .as_ref() + .map(|subtitle| convert_from_jats(subtitle, markup, ConversionLimit::Title)) + .transpose()?; + title.full_title = + convert_from_jats(&title.full_title, markup, ConversionLimit::Title)?; } - Work::count( - &context.db, - filter, - publishers.unwrap_or_default(), - vec![ - WorkType::Monograph, - WorkType::EditedBook, - WorkType::Textbook, - WorkType::JournalIssue, - ], - statuses, - updated_at_with_relations, - ) - .map_err(|e| e.into()) + + Ok(titles) } #[allow(clippy::too_many_arguments)] - #[graphql(description = "Query the full list of chapters (a subset of the full list of works)")] - fn chapters( + #[graphql(description = "Query abstracts by work ID")] + fn abstracts( + &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page" + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on title_, subtitle, full_title fields" )] filter: Option, #[graphql( - default = WorkOrderBy::default(), + default = AbstractOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< - WorkStatus, - >, + order: Option, #[graphql( default = vec![], - description = "Specific statuses to filter by" + description = "If set, only shows results with these locale codes" )] - work_statuses: Option>, + locale_codes: Option>, #[graphql( - description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" + default = MarkupFormat::JatsXml, + description = "If set, only shows results with this markup format" )] - updated_at_with_relations: Option, - ) -> FieldResult> { - let mut statuses = work_statuses.unwrap_or_default(); - if let Some(status) = work_status { - statuses.push(status); - } - Work::all( + markup_format: Option, + ) -> FieldResult> { + let mut abstracts = Abstract::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), filter, order.unwrap_or_default(), - publishers.unwrap_or_default(), + vec![], + Some(*self.work_id()), + None, + locale_codes.unwrap_or_default(), + vec![], None, None, - vec![WorkType::BookChapter], - statuses, - updated_at_with_relations, ) - .map_err(|e| e.into()) + .map_err(FieldError::from)?; + + let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + for r#abstract in &mut abstracts { + r#abstract.content = + convert_from_jats(&r#abstract.content, markup, ConversionLimit::Abstract)?; + } + + Ok(abstracts) } - #[graphql(description = "Query a single chapter using its DOI")] - fn chapter_by_doi( - context: &Context, - #[graphql(description = "Chapter DOI to search on")] doi: Doi, - ) -> FieldResult { - Work::from_doi(&context.db, doi, vec![WorkType::BookChapter]).map_err(|e| e.into()) + #[graphql(description = "Internal reference code")] + pub fn reference(&self) -> Option<&String> { + self.reference.as_ref() + } + + #[graphql(description = "Edition number of the work (not applicable to chapters)")] + pub fn edition(&self) -> Option<&i32> { + self.edition.as_ref() + } + + #[graphql(description = "Thoth ID of the work's imprint")] + pub fn imprint_id(&self) -> Uuid { + self.imprint_id } #[graphql( - description = "Get the total number of chapters (a subset of the total number of works)" + description = "Digital Object Identifier of the work as full URL, using the HTTPS scheme and the doi.org domain (e.g. https://doi.org/10.11647/obp.0001)" + )] + pub fn doi(&self) -> Option<&Doi> { + self.doi.as_ref() + } + + #[graphql(description = "Date the work was published")] + pub fn publication_date(&self) -> Option { + self.publication_date + } + + #[graphql( + description = "Date the work was withdrawn from publication. Only applies to out of print and withdrawn works." + )] + pub fn withdrawn_date(&self) -> Option { + self.withdrawn_date + } + + #[graphql(description = "Place of publication of the work")] + pub fn place(&self) -> Option<&String> { + self.place.as_ref() + } + + #[graphql( + description = "Total number of pages in the work. In most cases, unnumbered pages (e.g. endpapers) should be omitted from this count." + )] + pub fn page_count(&self) -> Option<&i32> { + self.page_count.as_ref() + } + + #[graphql( + description = "Breakdown of work's page count into front matter, main content, and/or back matter (e.g. 'xi + 140')" + )] + pub fn page_breakdown(&self) -> Option<&String> { + self.page_breakdown.as_ref() + } + + #[graphql(description = "Total number of images in the work")] + pub fn image_count(&self) -> Option<&i32> { + self.image_count.as_ref() + } + + #[graphql(description = "Total number of tables in the work")] + pub fn table_count(&self) -> Option<&i32> { + self.table_count.as_ref() + } + + #[graphql(description = "Total number of audio fragments in the work")] + pub fn audio_count(&self) -> Option<&i32> { + self.audio_count.as_ref() + } + + #[graphql(description = "Total number of video fragments in the work")] + pub fn video_count(&self) -> Option<&i32> { + self.video_count.as_ref() + } + + #[graphql( + description = "URL of the license which applies to this work (frequently a Creative Commons license for open-access works)" + )] + pub fn license(&self) -> Option<&String> { + self.license.as_ref() + } + + #[graphql(description = "Copyright holder of the work")] + pub fn copyright_holder(&self) -> Option<&String> { + self.copyright_holder.as_ref() + } + + #[graphql(description = "URL of the web page of the work")] + pub fn landing_page(&self) -> Option<&String> { + self.landing_page.as_ref() + } + + #[graphql( + description = "Library of Congress Control Number of the work (not applicable to chapters)" + )] + pub fn lccn(&self) -> Option<&String> { + self.lccn.as_ref() + } + + #[graphql( + description = "OCLC (WorldCat) Control Number of the work (not applicable to chapters)" + )] + pub fn oclc(&self) -> Option<&String> { + self.oclc.as_ref() + } + + #[graphql( + description = "A general-purpose field used to include information that does not have a specific designated field" + )] + pub fn general_note(&self) -> Option<&String> { + self.general_note.as_ref() + } + + #[graphql( + description = "Indicates that the work contains a bibliography or other similar information" + )] + pub fn bibliography_note(&self) -> Option<&String> { + self.bibliography_note.as_ref() + } + + #[graphql(description = "Table of contents of the work (not applicable to chapters)")] + pub fn toc(&self) -> Option<&String> { + self.toc.as_ref() + } + + #[graphql(description = "Description of additional resources linked to this work")] + pub fn resources_description( + &self, + #[graphql( + default = MarkupFormat::JatsXml, + description = "Markup format used for rendering resources description", + )] + markup_format: Option, + ) -> FieldResult> { + self.resources_description + .as_ref() + .map(|value| { + convert_from_jats( + value, + markup_format.ok_or(ThothError::MissingMarkupFormat)?, + ConversionLimit::Abstract, + ) + }) + .transpose() + .map_err(Into::into) + } + + #[graphql(description = "URL of the work's cover image")] + pub fn cover_url(&self) -> Option<&String> { + self.cover_url.as_ref() + } + + #[graphql(description = "Caption describing the work's cover image")] + pub fn cover_caption(&self) -> Option<&String> { + self.cover_caption.as_ref() + } + + #[graphql(description = "Date and time at which the work record was created")] + pub fn created_at(&self) -> Timestamp { + self.created_at + } + + #[graphql(description = "Date and time at which the work record was last updated")] + pub fn updated_at(&self) -> Timestamp { + self.updated_at + } + + #[graphql(description = "Page number on which the work begins (only applicable to chapters)")] + pub fn first_page(&self) -> Option<&String> { + self.first_page.as_ref() + } + + #[graphql(description = "Page number on which the work ends (only applicable to chapters)")] + pub fn last_page(&self) -> Option<&String> { + self.last_page.as_ref() + } + + #[graphql( + description = "Concatenation of first page and last page with dash (only applicable to chapters)" )] - fn chapter_count( + pub fn page_interval(&self) -> Option<&String> { + self.page_interval.as_ref() + } + + #[graphql( + description = "Date and time at which the work record or any of its linked records was last updated" + )] + pub fn updated_at_with_relations(&self) -> Timestamp { + self.updated_at_with_relations + } + + #[graphql(description = "Get this work's imprint")] + pub fn imprint(&self, context: &Context) -> FieldResult { + Imprint::from_id(&context.db, &self.imprint_id).map_err(Into::into) + } + + #[graphql(description = "Get contributions linked to this work")] + pub fn contributions( + &self, context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page" + default = ContributionOrderBy::default(), + description = "The order in which to sort the results" )] - filter: Option, + order: Option, #[graphql( default = vec![], - description = "If set, only shows results connected to publishers with these IDs" + description = "Specific types to filter by", )] - publishers: Option>, - #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< - WorkStatus, - >, + contribution_types: Option>, + ) -> FieldResult> { + Contribution::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + vec![], + Some(self.work_id), + None, + contribution_types.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[allow(clippy::too_many_arguments)] + #[graphql(description = "Get languages linked to this work")] + pub fn languages( + &self, + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option, + #[graphql( + default = LanguageOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option, #[graphql( default = vec![], - description = "Specific statuses to filter by" + description = "Specific languages to filter by" )] - work_statuses: Option>, + language_codes: Option>, #[graphql( - description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" + description = "(deprecated) A specific relation to filter by" )] - updated_at_with_relations: Option, - ) -> FieldResult { - let mut statuses = work_statuses.unwrap_or_default(); - if let Some(status) = work_status { - statuses.push(status); + language_relation: Option, + #[graphql( + default = vec![], + description = "Specific relations to filter by" + )] + language_relations: Option>, + ) -> FieldResult> { + let mut relations = language_relations.unwrap_or_default(); + if let Some(relation) = language_relation { + relations.push(relation); } - Work::count( + Language::all( &context.db, - filter, - publishers.unwrap_or_default(), - vec![WorkType::BookChapter], - statuses, - updated_at_with_relations, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + vec![], + Some(self.work_id), + None, + language_codes.unwrap_or_default(), + relations, + None, + None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } - #[graphql(description = "Query the full list of publications")] - fn publications( + #[graphql(description = "Get publications linked to this work")] + pub fn publications( + &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, @@ -531,11 +564,6 @@ impl QueryRoot { description = "The order in which to sort the results" )] order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, #[graphql( default = vec![], description = "Specific types to filter by", @@ -548,2889 +576,1503 @@ impl QueryRoot { offset.unwrap_or_default(), filter, order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, + vec![], + Some(self.work_id), None, publication_types.unwrap_or_default(), vec![], None, + None, ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single publication using its ID")] - fn publication( - context: &Context, - #[graphql(description = "Thoth publication ID to search on")] publication_id: Uuid, - ) -> FieldResult { - Publication::from_id(&context.db, &publication_id).map_err(|e| e.into()) + .map_err(Into::into) } - #[graphql(description = "Get the total number of publications")] - fn publication_count( - context: &Context, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on isbn" - )] - filter: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - #[graphql( - default = vec![], - description = "Specific types to filter by", - )] - publication_types: Option>, - ) -> FieldResult { - Publication::count( - &context.db, - filter, - publishers.unwrap_or_default(), - publication_types.unwrap_or_default(), - vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query the full list of publishers")] - fn publishers( + #[graphql(description = "Get subjects linked to this work")] + pub fn subjects( + &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on publisher_name and publisher_shortname" + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on subject_code" )] filter: Option, #[graphql( - default = PublisherOrderBy::default(), + default = SubjectOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, + order: Option, #[graphql( default = vec![], - description = "If set, only shows results connected to publishers with these IDs" + description = "Specific types to filter by", )] - publishers: Option>, - ) -> FieldResult> { - Publisher::all( + subject_types: Option>, + ) -> FieldResult> { + Subject::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), filter, order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, - None, - vec![], vec![], + Some(self.work_id), None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single publisher using its ID")] - fn publisher( - context: &Context, - #[graphql(description = "Thoth publisher ID to search on")] publisher_id: Uuid, - ) -> FieldResult { - Publisher::from_id(&context.db, &publisher_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of publishers")] - fn publisher_count( - context: &Context, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on publisher_name and publisher_shortname" - )] - filter: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - ) -> FieldResult { - Publisher::count( - &context.db, - filter, - publishers.unwrap_or_default(), - vec![], + subject_types.unwrap_or_default(), vec![], None, + None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } - #[graphql(description = "Query the full list of imprints")] - fn imprints( + #[graphql(description = "Get fundings linked to this work")] + pub fn fundings( + &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on imprint_name and imprint_url" - )] - filter: Option, - #[graphql( - default = ImprintOrderBy::default(), + default = FundingOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - ) -> FieldResult> { - Imprint::all( + order: Option, + ) -> FieldResult> { + Funding::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), - filter, - order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, None, + order.unwrap_or_default(), vec![], - vec![], + Some(self.work_id), None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single imprint using its ID")] - fn imprint( - context: &Context, - #[graphql(description = "Thoth imprint ID to search on")] imprint_id: Uuid, - ) -> FieldResult { - Imprint::from_id(&context.db, &imprint_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of imprints")] - fn imprint_count( - context: &Context, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on imprint_name and imprint_url" - )] - filter: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - ) -> FieldResult { - Imprint::count( - &context.db, - filter, - publishers.unwrap_or_default(), vec![], vec![], None, + None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } - #[graphql(description = "Query the full list of contributors")] - fn contributors( + #[graphql(description = "Get issues linked to this work")] + pub fn issues( + &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_name, last_name and orcid" - )] - filter: Option, - #[graphql( - default = ContributorOrderBy::default(), + default = IssueOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, - ) -> FieldResult> { - Contributor::all( + order: Option, + ) -> FieldResult> { + Issue::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), - filter, + None, order.unwrap_or_default(), vec![], - None, + Some(self.work_id), None, vec![], vec![], None, + None, ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single contributor using its ID")] - fn contributor( - context: &Context, - #[graphql(description = "Thoth contributor ID to search on")] contributor_id: Uuid, - ) -> FieldResult { - Contributor::from_id(&context.db, &contributor_id).map_err(|e| e.into()) + .map_err(Into::into) } - - #[graphql(description = "Get the total number of contributors")] - fn contributor_count( - context: &Context, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_name, last_name and orcid" - )] - filter: Option, - ) -> FieldResult { - Contributor::count(&context.db, filter, vec![], vec![], vec![], None).map_err(|e| e.into()) - } - - #[graphql(description = "Query the full list of contributions")] - fn contributions( + #[graphql(description = "Get other works related to this work")] + pub fn relations( + &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( - default = ContributionOrderBy::default(), + default = WorkRelationOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, + order: Option, #[graphql( default = vec![], description = "Specific types to filter by", )] - contribution_types: Option>, - ) -> FieldResult> { - Contribution::all( + relation_types: Option>, + ) -> FieldResult> { + WorkRelation::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), None, order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, - None, - contribution_types.unwrap_or_default(), vec![], + Some(self.work_id), None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single contribution using its ID")] - fn contribution( - context: &Context, - #[graphql(description = "Thoth contribution ID to search on")] contribution_id: Uuid, - ) -> FieldResult { - Contribution::from_id(&context.db, &contribution_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of contributions")] - fn contribution_count( - context: &Context, - #[graphql( - default = vec![], - description = "Specific types to filter by", - )] - contribution_types: Option>, - ) -> FieldResult { - Contribution::count( - &context.db, - None, - vec![], - contribution_types.unwrap_or_default(), + relation_types.unwrap_or_default(), vec![], None, + None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } - - #[graphql(description = "Query the full list of series")] - fn serieses( + #[graphql(description = "Get the front cover file for this work")] + pub fn frontcover(&self, context: &Context) -> FieldResult> { + File::from_work_id(&context.db, &self.work_id).map_err(Into::into) + } + #[graphql(description = "Get references cited by this work")] + pub fn references( + &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on series_name, issn_print, issn_digital, series_url and series_description" + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on doi, unstructured_citation, issn, isbn, journal_title, article_title, series_title, volume_title, author, standard_designator, standards_body_name, and standards_body_acronym" )] filter: Option, #[graphql( - default = SeriesOrderBy::default(), + default = ReferenceOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - #[graphql( - default = vec![], - description = "Specific types to filter by", - )] - series_types: Option>, - ) -> FieldResult> { - Series::all( + order: Option, + ) -> FieldResult> { + Reference::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), filter, order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, - None, - series_types.unwrap_or_default(), - vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single series using its ID")] - fn series( - context: &Context, - #[graphql(description = "Thoth series ID to search on")] series_id: Uuid, - ) -> FieldResult { - Series::from_id(&context.db, &series_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of series")] - fn series_count( - context: &Context, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on series_name, issn_print, issn_digital, series_url and series_description" - )] - filter: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - #[graphql( - default = vec![], - description = "Specific types to filter by", - )] - series_types: Option>, - ) -> FieldResult { - Series::count( - &context.db, - filter, - publishers.unwrap_or_default(), - series_types.unwrap_or_default(), vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query the full list of issues")] - fn issues( - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = IssueOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - ) -> FieldResult> { - Issue::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - None, - order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, + Some(self.work_id), None, vec![], vec![], None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single issue using its ID")] - fn issue( - context: &Context, - #[graphql(description = "Thoth issue ID to search on")] issue_id: Uuid, - ) -> FieldResult { - Issue::from_id(&context.db, &issue_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of issues")] - fn issue_count(context: &Context) -> FieldResult { - Issue::count(&context.db, None, vec![], vec![], vec![], None).map_err(|e| e.into()) - } - - #[allow(clippy::too_many_arguments)] - #[graphql(description = "Query the full list of languages")] - fn languages( - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = LanguageOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - #[graphql( - default = vec![], - description = "Specific languages to filter by" - )] - language_codes: Option>, - #[graphql( - description = "(deprecated) A specific relation to filter by" - )] - language_relation: Option, - #[graphql( - default = vec![], - description = "Specific relations to filter by" - )] - language_relations: Option>, - ) -> FieldResult> { - let mut relations = language_relations.unwrap_or_default(); - if let Some(relation) = language_relation { - relations.push(relation); - } - Language::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - None, - order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, - None, - language_codes.unwrap_or_default(), - relations, - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single language using its ID")] - fn language( - context: &Context, - #[graphql(description = "Thoth language ID to search on")] language_id: Uuid, - ) -> FieldResult { - Language::from_id(&context.db, &language_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of languages associated to works")] - fn language_count( - context: &Context, - #[graphql( - default = vec![], - description = "Specific languages to filter by" - )] - language_codes: Option>, - #[graphql( - description = "(deprecated) A specific relation to filter by" - )] - language_relation: Option, - #[graphql( - default = vec![], - description = "Specific relations to filter by" - )] - language_relations: Option>, - ) -> FieldResult { - let mut relations = language_relations.unwrap_or_default(); - if let Some(relation) = language_relation { - relations.push(relation); - } - Language::count( - &context.db, - None, - vec![], - language_codes.unwrap_or_default(), - relations, - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query the full list of locations")] - fn locations( - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = LocationOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - #[graphql( - default = vec![], - description = "Specific platforms to filter by" - )] - location_platforms: Option>, - ) -> FieldResult> { - Location::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - None, - order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, - None, - location_platforms.unwrap_or_default(), - vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single location using its ID")] - fn location( - context: &Context, - #[graphql(description = "Thoth location ID to search on")] location_id: Uuid, - ) -> FieldResult { - Location::from_id(&context.db, &location_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of locations associated to works")] - fn location_count( - context: &Context, - #[graphql( - default = vec![], - description = "Specific platforms to filter by" - )] - location_platforms: Option>, - ) -> FieldResult { - Location::count( - &context.db, - None, - vec![], - location_platforms.unwrap_or_default(), - vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query the full list of prices")] - fn prices( - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = PriceOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - #[graphql( - default = vec![], - description = "Specific currencies to filter by" - )] - currency_codes: Option>, - ) -> FieldResult> { - Price::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - None, - order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, - None, - currency_codes.unwrap_or_default(), - vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single price using its ID")] - fn price( - context: &Context, - #[graphql(description = "Thoth price ID to search on")] price_id: Uuid, - ) -> FieldResult { - Price::from_id(&context.db, &price_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of prices associated to works")] - fn price_count( - context: &Context, - #[graphql( - default = vec![], - description = "Specific currencies to filter by" - )] - currency_codes: Option>, - ) -> FieldResult { - Price::count( - &context.db, - None, - vec![], - currency_codes.unwrap_or_default(), - vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query the full list of subjects")] - fn subjects( - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on subject_code" - )] - filter: Option, - #[graphql( - default = SubjectOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - #[graphql( - default = vec![], - description = "Specific types to filter by", - )] - subject_types: Option>, - ) -> FieldResult> { - Subject::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - filter, - order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, - None, - subject_types.unwrap_or_default(), - vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single subject using its ID")] - fn subject( - context: &Context, - #[graphql(description = "Thoth subject ID to search on")] subject_id: Uuid, - ) -> FieldResult { - Subject::from_id(&context.db, &subject_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of subjects associated to works")] - fn subject_count( - context: &Context, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on subject_code" - )] - filter: Option, - #[graphql( - default = vec![], - description = "Specific types to filter by", - )] - subject_types: Option>, - ) -> FieldResult { - Subject::count( - &context.db, - filter, - vec![], - subject_types.unwrap_or_default(), - vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query the full list of institutions")] - fn institutions( - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on institution_name, ror and institution_doi" - )] - filter: Option, - #[graphql( - default = InstitutionOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - ) -> FieldResult> { - Institution::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - filter, - order.unwrap_or_default(), - vec![], - None, - None, - vec![], - vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single institution using its ID")] - fn institution( - context: &Context, - #[graphql(description = "Thoth institution ID to search on")] institution_id: Uuid, - ) -> FieldResult { - Institution::from_id(&context.db, &institution_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of institutions")] - fn institution_count( - context: &Context, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on institution_name, ror and institution_doi" - )] - filter: Option, - ) -> FieldResult { - Institution::count(&context.db, filter, vec![], vec![], vec![], None).map_err(|e| e.into()) - } - - #[graphql(description = "Query the full list of fundings")] - fn fundings( - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = FundingOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - ) -> FieldResult> { - Funding::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - None, - order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, - None, - vec![], - vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single funding using its ID")] - fn funding( - context: &Context, - #[graphql(description = "Thoth funding ID to search on")] funding_id: Uuid, - ) -> FieldResult { - Funding::from_id(&context.db, &funding_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of funding instances associated to works")] - fn funding_count(context: &Context) -> FieldResult { - Funding::count(&context.db, None, vec![], vec![], vec![], None).map_err(|e| e.into()) - } - - #[graphql(description = "Query the full list of affiliations")] - fn affiliations( - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = AffiliationOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - ) -> FieldResult> { - Affiliation::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - None, - order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, - None, - vec![], - vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single affiliation using its ID")] - fn affiliation( - context: &Context, - #[graphql(description = "Thoth affiliation ID to search on")] affiliation_id: Uuid, - ) -> FieldResult { - Affiliation::from_id(&context.db, &affiliation_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of affiliations")] - fn affiliation_count(context: &Context) -> FieldResult { - Affiliation::count(&context.db, None, vec![], vec![], vec![], None).map_err(|e| e.into()) - } - - #[graphql(description = "Query the full list of references")] - fn references( - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = ReferenceOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - #[graphql( - default = vec![], - description = "If set, only shows results connected to publishers with these IDs" - )] - publishers: Option>, - ) -> FieldResult> { - Reference::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - None, - order.unwrap_or_default(), - publishers.unwrap_or_default(), - None, - None, - vec![], - vec![], - None, - ) - .map_err(|e| e.into()) - } - - #[graphql(description = "Query a single reference using its ID")] - fn reference( - context: &Context, - #[graphql(description = "Thoth reference ID to search on")] reference_id: Uuid, - ) -> FieldResult { - Reference::from_id(&context.db, &reference_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get the total number of references")] - fn reference_count(context: &Context) -> FieldResult { - Reference::count(&context.db, None, vec![], vec![], vec![], None).map_err(|e| e.into()) - } -} - -pub struct MutationRoot; - -#[juniper::graphql_object(Context = Context)] -impl MutationRoot { - #[graphql(description = "Create a new work with the specified values")] - fn create_work( - context: &Context, - #[graphql(description = "Values for work to be created")] data: NewWork, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_imprint_id(&context.db, data.imprint_id)?)?; - - data.validate()?; - - Work::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new publisher with the specified values")] - fn create_publisher( - context: &Context, - #[graphql(description = "Values for publisher to be created")] data: NewPublisher, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - // Only superusers can create new publishers - NewPublisher has no ID field - if !context.account_access.is_superuser { - return Err(ThothError::Unauthorised.into()); - } - - Publisher::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new imprint with the specified values")] - fn create_imprint( - context: &Context, - #[graphql(description = "Values for imprint to be created")] data: NewImprint, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context.account_access.can_edit(data.publisher_id)?; - - Imprint::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new contributor with the specified values")] - fn create_contributor( - context: &Context, - #[graphql(description = "Values for contributor to be created")] data: NewContributor, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - Contributor::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new contribution with the specified values")] - fn create_contribution( - context: &Context, - #[graphql(description = "Values for contribution to be created")] data: NewContribution, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - - Contribution::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new publication with the specified values")] - fn create_publication( - context: &Context, - #[graphql(description = "Values for publication to be created")] data: NewPublication, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - - data.validate(&context.db)?; - - Publication::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new series with the specified values")] - fn create_series( - context: &Context, - #[graphql(description = "Values for series to be created")] data: NewSeries, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_imprint_id(&context.db, data.imprint_id)?)?; - - Series::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new issue with the specified values")] - fn create_issue( - context: &Context, - #[graphql(description = "Values for issue to be created")] data: NewIssue, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - - data.imprints_match(&context.db)?; - - Issue::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new language with the specified values")] - fn create_language( - context: &Context, - #[graphql(description = "Values for language to be created")] data: NewLanguage, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - - Language::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new institution with the specified values")] - fn create_institution( - context: &Context, - #[graphql(description = "Values for institution to be created")] data: NewInstitution, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - Institution::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new funding with the specified values")] - fn create_funding( - context: &Context, - #[graphql(description = "Values for funding to be created")] data: NewFunding, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - - Funding::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new location with the specified values")] - fn create_location( - context: &Context, - #[graphql(description = "Values for location to be created")] data: NewLocation, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - // Only superusers can create new locations where Location Platform is Thoth - if !context.account_access.is_superuser && data.location_platform == LocationPlatform::Thoth - { - return Err(ThothError::ThothLocationError.into()); - } - context - .account_access - .can_edit(publisher_id_from_publication_id( - &context.db, - data.publication_id, - )?)?; - - if data.canonical { - data.canonical_record_complete(&context.db)?; - } else { - data.can_be_non_canonical(&context.db)?; - } - - Location::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new price with the specified values")] - fn create_price( - context: &Context, - #[graphql(description = "Values for price to be created")] data: NewPrice, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_publication_id( - &context.db, - data.publication_id, - )?)?; - - if data.unit_price <= 0.0 { - // Prices must be non-zero (and non-negative). - return Err(ThothError::PriceZeroError.into()); - } - - Price::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new subject with the specified values")] - fn create_subject( - context: &Context, - #[graphql(description = "Values for subject to be created")] data: NewSubject, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - - check_subject(&data.subject_type, &data.subject_code)?; - - Subject::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new affiliation with the specified values")] - fn create_affiliation( - context: &Context, - #[graphql(description = "Values for affiliation to be created")] data: NewAffiliation, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_contribution_id( - &context.db, - data.contribution_id, - )?)?; - - Affiliation::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new work relation with the specified values")] - fn create_work_relation( - context: &Context, - #[graphql(description = "Values for work relation to be created")] data: NewWorkRelation, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - // Work relations may link works from different publishers. - // User must have permissions for all relevant publishers. - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - data.relator_work_id, - )?)?; - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - data.related_work_id, - )?)?; - - WorkRelation::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Create a new reference with the specified values")] - fn create_reference( - context: &Context, - #[graphql(description = "Values for reference to be created")] data: NewReference, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - - Reference::create(&context.db, &data).map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing work with the specified values")] - fn update_work( - context: &Context, - #[graphql(description = "Values to apply to existing work")] data: PatchWork, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work = Work::from_id(&context.db, &data.work_id).unwrap(); - context - .account_access - .can_edit(work.publisher_id(&context.db)?)?; - - if data.imprint_id != work.imprint_id { - context - .account_access - .can_edit(publisher_id_from_imprint_id(&context.db, data.imprint_id)?)?; - work.can_update_imprint(&context.db)?; - } - - if data.work_type == WorkType::BookChapter { - work.can_be_chapter(&context.db)?; - } - - data.validate()?; - - if work.is_published() && !data.is_published() && !context.account_access.is_superuser { - return Err(ThothError::ThothSetWorkStatusError.into()); - } - - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - // update the work and, if it succeeds, synchronise its children statuses and pub. date - match work.update(&context.db, &data, &account_id) { - Ok(w) => { - // update chapters if their pub. data, withdrawn_date or work_status doesn't match the parent's - for child in work.children(&context.db)? { - if child.publication_date != w.publication_date - || child.work_status != w.work_status - || child.withdrawn_date != w.withdrawn_date - { - let mut data: PatchWork = child.clone().into(); - data.publication_date = w.publication_date; - data.withdrawn_date = w.withdrawn_date; - data.work_status = w.work_status; - child.update(&context.db, &data, &account_id)?; - } - } - Ok(w) - } - Err(e) => Err(e.into()), - } - } - - #[graphql(description = "Update an existing publisher with the specified values")] - fn update_publisher( - context: &Context, - #[graphql(description = "Values to apply to existing publisher")] data: PatchPublisher, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let publisher = Publisher::from_id(&context.db, &data.publisher_id).unwrap(); - context.account_access.can_edit(publisher.publisher_id)?; - - if data.publisher_id != publisher.publisher_id { - context.account_access.can_edit(data.publisher_id)?; - } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - publisher - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing imprint with the specified values")] - fn update_imprint( - context: &Context, - #[graphql(description = "Values to apply to existing imprint")] data: PatchImprint, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let imprint = Imprint::from_id(&context.db, &data.imprint_id).unwrap(); - context.account_access.can_edit(imprint.publisher_id())?; - - if data.publisher_id != imprint.publisher_id { - context.account_access.can_edit(data.publisher_id)?; - } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - imprint - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing contributor with the specified values")] - fn update_contributor( - context: &Context, - #[graphql(description = "Values to apply to existing contributor")] data: PatchContributor, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - Contributor::from_id(&context.db, &data.contributor_id) - .unwrap() - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing contribution with the specified values")] - fn update_contribution( - context: &Context, - #[graphql(description = "Values to apply to existing contribution")] - data: PatchContribution, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let contribution = Contribution::from_id(&context.db, &data.contribution_id).unwrap(); - context - .account_access - .can_edit(contribution.publisher_id(&context.db)?)?; - - if data.work_id != contribution.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - contribution - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing publication with the specified values")] - fn update_publication( - context: &Context, - #[graphql(description = "Values to apply to existing publication")] data: PatchPublication, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let publication = Publication::from_id(&context.db, &data.publication_id).unwrap(); - context - .account_access - .can_edit(publication.publisher_id(&context.db)?)?; - - if data.work_id != publication.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } - - data.validate(&context.db)?; - - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - publication - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing series with the specified values")] - fn update_series( - context: &Context, - #[graphql(description = "Values to apply to existing series")] data: PatchSeries, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let series = Series::from_id(&context.db, &data.series_id).unwrap(); - context - .account_access - .can_edit(series.publisher_id(&context.db)?)?; - - if data.imprint_id != series.imprint_id { - context - .account_access - .can_edit(publisher_id_from_imprint_id(&context.db, data.imprint_id)?)?; - } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - series - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing issue with the specified values")] - fn update_issue( - context: &Context, - #[graphql(description = "Values to apply to existing issue")] data: PatchIssue, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let issue = Issue::from_id(&context.db, &data.issue_id).unwrap(); - context - .account_access - .can_edit(issue.publisher_id(&context.db)?)?; - - data.imprints_match(&context.db)?; - - if data.work_id != issue.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - issue - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing language with the specified values")] - fn update_language( - context: &Context, - #[graphql(description = "Values to apply to existing language")] data: PatchLanguage, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let language = Language::from_id(&context.db, &data.language_id).unwrap(); - context - .account_access - .can_edit(language.publisher_id(&context.db)?)?; - - if data.work_id != language.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } - - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - language - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing institution with the specified values")] - fn update_institution( - context: &Context, - #[graphql(description = "Values to apply to existing institution")] data: PatchInstitution, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - Institution::from_id(&context.db, &data.institution_id) - .unwrap() - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing funding with the specified values")] - fn update_funding( - context: &Context, - #[graphql(description = "Values to apply to existing funding")] data: PatchFunding, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let funding = Funding::from_id(&context.db, &data.funding_id).unwrap(); - context - .account_access - .can_edit(funding.publisher_id(&context.db)?)?; - - if data.work_id != funding.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } - - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - funding - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing location with the specified values")] - fn update_location( - context: &Context, - #[graphql(description = "Values to apply to existing location")] data: PatchLocation, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let current_location = Location::from_id(&context.db, &data.location_id).unwrap(); - let has_canonical_thoth_location = Publication::from_id(&context.db, &data.publication_id)? - .locations( - context, - Some(1), - None, - None, - Some(vec![LocationPlatform::Thoth]), - )? - .first() - .is_some_and(|location| location.canonical); - // Only superusers can update the canonical location when a Thoth Location Platform canonical location already exists - if has_canonical_thoth_location && data.canonical && !context.account_access.is_superuser { - return Err(ThothError::ThothUpdateCanonicalError.into()); - } - - // Only superusers can edit locations where Location Platform is Thoth - if !context.account_access.is_superuser - && current_location.location_platform == LocationPlatform::Thoth - { - return Err(ThothError::ThothLocationError.into()); - } - context - .account_access - .can_edit(current_location.publisher_id(&context.db)?)?; - - if data.publication_id != current_location.publication_id { - context - .account_access - .can_edit(publisher_id_from_publication_id( - &context.db, - data.publication_id, - )?)?; - } - - if data.canonical { - data.canonical_record_complete(&context.db)?; - } - - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - current_location - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing price with the specified values")] - fn update_price( - context: &Context, - #[graphql(description = "Values to apply to existing price")] data: PatchPrice, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let price = Price::from_id(&context.db, &data.price_id).unwrap(); - context - .account_access - .can_edit(price.publisher_id(&context.db)?)?; - - if data.publication_id != price.publication_id { - context - .account_access - .can_edit(publisher_id_from_publication_id( - &context.db, - data.publication_id, - )?)?; - } - - if data.unit_price <= 0.0 { - // Prices must be non-zero (and non-negative). - return Err(ThothError::PriceZeroError.into()); - } - - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - price - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing subject with the specified values")] - fn update_subject( - context: &Context, - #[graphql(description = "Values to apply to existing subject")] data: PatchSubject, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let subject = Subject::from_id(&context.db, &data.subject_id).unwrap(); - context - .account_access - .can_edit(subject.publisher_id(&context.db)?)?; - - if data.work_id != subject.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } - - check_subject(&data.subject_type, &data.subject_code)?; - - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - subject - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing affiliation with the specified values")] - fn update_affiliation( - context: &Context, - #[graphql(description = "Values to apply to existing affiliation")] data: PatchAffiliation, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let affiliation = Affiliation::from_id(&context.db, &data.affiliation_id).unwrap(); - context - .account_access - .can_edit(affiliation.publisher_id(&context.db)?)?; - - if data.contribution_id != affiliation.contribution_id { - context - .account_access - .can_edit(publisher_id_from_contribution_id( - &context.db, - data.contribution_id, - )?)?; - } - - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - affiliation - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing work relation with the specified values")] - fn update_work_relation( - context: &Context, - #[graphql(description = "Values to apply to existing work relation")] - data: PatchWorkRelation, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work_relation = WorkRelation::from_id(&context.db, &data.work_relation_id).unwrap(); - // Work relations may link works from different publishers. - // User must have permissions for all relevant publishers. - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - work_relation.relator_work_id, - )?)?; - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - work_relation.related_work_id, - )?)?; - - if data.relator_work_id != work_relation.relator_work_id { - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - data.relator_work_id, - )?)?; - } - if data.related_work_id != work_relation.related_work_id { - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - data.related_work_id, - )?)?; - } - - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - work_relation - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Update an existing reference with the specified values")] - fn update_reference( - context: &Context, - #[graphql(description = "Values to apply to existing reference")] data: PatchReference, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let reference = Reference::from_id(&context.db, &data.reference_id).unwrap(); - context - .account_access - .can_edit(reference.publisher_id(&context.db)?)?; - - if data.work_id != reference.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } - - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - reference - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single work using its ID")] - fn delete_work( - context: &Context, - #[graphql(description = "Thoth ID of work to be deleted")] work_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work = Work::from_id(&context.db, &work_id).unwrap(); - context - .account_access - .can_edit(work.publisher_id(&context.db)?)?; - - if work.is_published() && !context.account_access.is_superuser { - return Err(ThothError::ThothDeleteWorkError.into()); - } - - work.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single publisher using its ID")] - fn delete_publisher( - context: &Context, - #[graphql(description = "Thoth ID of publisher to be deleted")] publisher_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let publisher = Publisher::from_id(&context.db, &publisher_id).unwrap(); - context.account_access.can_edit(publisher_id)?; - - publisher.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single imprint using its ID")] - fn delete_imprint( - context: &Context, - #[graphql(description = "Thoth ID of imprint to be deleted")] imprint_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let imprint = Imprint::from_id(&context.db, &imprint_id).unwrap(); - context.account_access.can_edit(imprint.publisher_id())?; - - imprint.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single contributor using its ID")] - fn delete_contributor( - context: &Context, - #[graphql(description = "Thoth ID of contributor to be deleted")] contributor_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let contributor = Contributor::from_id(&context.db, &contributor_id).unwrap(); - for linked_publisher_id in contributor.linked_publisher_ids(&context.db)? { - context.account_access.can_edit(linked_publisher_id)?; - } - - contributor.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single contribution using its ID")] - fn delete_contribution( - context: &Context, - #[graphql(description = "Thoth ID of contribution to be deleted")] contribution_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let contribution = Contribution::from_id(&context.db, &contribution_id).unwrap(); - context - .account_access - .can_edit(contribution.publisher_id(&context.db)?)?; - - contribution.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single publication using its ID")] - fn delete_publication( - context: &Context, - #[graphql(description = "Thoth ID of publication to be deleted")] publication_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let publication = Publication::from_id(&context.db, &publication_id).unwrap(); - context - .account_access - .can_edit(publication.publisher_id(&context.db)?)?; - - publication.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single series using its ID")] - fn delete_series( - context: &Context, - #[graphql(description = "Thoth ID of series to be deleted")] series_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let series = Series::from_id(&context.db, &series_id).unwrap(); - context - .account_access - .can_edit(series.publisher_id(&context.db)?)?; - - series.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single issue using its ID")] - fn delete_issue( - context: &Context, - #[graphql(description = "Thoth ID of issue to be deleted")] issue_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let issue = Issue::from_id(&context.db, &issue_id).unwrap(); - context - .account_access - .can_edit(issue.publisher_id(&context.db)?)?; - - issue.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single language using its ID")] - fn delete_language( - context: &Context, - #[graphql(description = "Thoth ID of language to be deleted")] language_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let language = Language::from_id(&context.db, &language_id).unwrap(); - context - .account_access - .can_edit(language.publisher_id(&context.db)?)?; - - language.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single institution using its ID")] - fn delete_institution( - context: &Context, - #[graphql(description = "Thoth ID of institution to be deleted")] institution_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let institution = Institution::from_id(&context.db, &institution_id).unwrap(); - for linked_publisher_id in institution.linked_publisher_ids(&context.db)? { - context.account_access.can_edit(linked_publisher_id)?; - } - - institution.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single funding using its ID")] - fn delete_funding( - context: &Context, - #[graphql(description = "Thoth ID of funding to be deleted")] funding_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let funding = Funding::from_id(&context.db, &funding_id).unwrap(); - context - .account_access - .can_edit(funding.publisher_id(&context.db)?)?; - - funding.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single location using its ID")] - fn delete_location( - context: &Context, - #[graphql(description = "Thoth ID of location to be deleted")] location_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let location = Location::from_id(&context.db, &location_id).unwrap(); - // Only superusers can delete locations where Location Platform is Thoth - if !context.account_access.is_superuser - && location.location_platform == LocationPlatform::Thoth - { - return Err(ThothError::ThothLocationError.into()); - } - context - .account_access - .can_edit(location.publisher_id(&context.db)?)?; - - location.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single price using its ID")] - fn delete_price( - context: &Context, - #[graphql(description = "Thoth ID of price to be deleted")] price_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let price = Price::from_id(&context.db, &price_id).unwrap(); - context - .account_access - .can_edit(price.publisher_id(&context.db)?)?; - - price.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single subject using its ID")] - fn delete_subject( - context: &Context, - #[graphql(description = "Thoth ID of subject to be deleted")] subject_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let subject = Subject::from_id(&context.db, &subject_id).unwrap(); - context - .account_access - .can_edit(subject.publisher_id(&context.db)?)?; - - subject.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single affiliation using its ID")] - fn delete_affiliation( - context: &Context, - #[graphql(description = "Thoth ID of affiliation to be deleted")] affiliation_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let affiliation = Affiliation::from_id(&context.db, &affiliation_id).unwrap(); - context - .account_access - .can_edit(affiliation.publisher_id(&context.db)?)?; - - affiliation.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single work relation using its ID")] - fn delete_work_relation( - context: &Context, - #[graphql(description = "Thoth ID of work relation to be deleted")] work_relation_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work_relation = WorkRelation::from_id(&context.db, &work_relation_id).unwrap(); - // Work relations may link works from different publishers. - // User must have permissions for all relevant publishers. - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - work_relation.relator_work_id, - )?)?; - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - work_relation.related_work_id, - )?)?; - - work_relation.delete(&context.db).map_err(|e| e.into()) - } - - #[graphql(description = "Delete a single reference using its ID")] - fn delete_reference( - context: &Context, - #[graphql(description = "Thoth ID of reference to be deleted")] reference_id: Uuid, - ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let reference = Reference::from_id(&context.db, &reference_id).unwrap(); - context - .account_access - .can_edit(reference.publisher_id(&context.db)?)?; - - reference.delete(&context.db).map_err(|e| e.into()) - } -} - -#[juniper::graphql_object(Context = Context, description = "A written text that can be published")] -impl Work { - #[graphql(description = "Thoth ID of the work")] - pub fn work_id(&self) -> &Uuid { - &self.work_id - } - - #[graphql(description = "Type of the work")] - pub fn work_type(&self) -> &WorkType { - &self.work_type - } - - #[graphql(description = "Publication status of the work")] - pub fn work_status(&self) -> &WorkStatus { - &self.work_status - } - - #[graphql(description = "Concatenation of title and subtitle with punctuation mark")] - pub fn full_title(&self) -> &str { - self.full_title.as_str() - } - - #[graphql(description = "Main title of the work (excluding subtitle)")] - pub fn title(&self) -> &str { - self.title.as_str() - } - - #[graphql(description = "Secondary title of the work (excluding main title)")] - pub fn subtitle(&self) -> Option<&String> { - self.subtitle.as_ref() - } - - #[graphql(description = "Internal reference code")] - pub fn reference(&self) -> Option<&String> { - self.reference.as_ref() - } - - #[graphql(description = "Edition number of the work (not applicable to chapters)")] - pub fn edition(&self) -> Option<&i32> { - self.edition.as_ref() - } - - #[graphql(description = "Thoth ID of the work's imprint")] - pub fn imprint_id(&self) -> Uuid { - self.imprint_id + None, + ) + .map_err(Into::into) } - #[graphql( - description = "Digital Object Identifier of the work as full URL, using the HTTPS scheme and the doi.org domain (e.g. https://doi.org/10.11647/obp.0001)" - )] - pub fn doi(&self) -> Option<&Doi> { - self.doi.as_ref() - } + #[graphql(description = "Get additional resources linked to this work")] + pub fn additional_resources( + &self, + context: &Context, + #[graphql(default = 50, description = "The number of items to return")] limit: Option, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option, + #[graphql( + default = MarkupFormat::JatsXml, + description = "Markup format used for rendering textual fields" + )] + markup_format: Option, + ) -> FieldResult> { + let mut additional_resources = AdditionalResource::all( + &context.db, + limit.unwrap_or(50), + offset.unwrap_or_default(), + None, + AdditionalResourceOrderBy::default(), + vec![], + Some(self.work_id), + None, + vec![], + vec![], + None, + None, + )?; - #[graphql(description = "Date the work was published")] - pub fn publication_date(&self) -> Option { - self.publication_date - } + let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + for additional_resource in &mut additional_resources { + additional_resource.title = + convert_from_jats(&additional_resource.title, markup, ConversionLimit::Title)?; + additional_resource.description = additional_resource + .description + .as_ref() + .map(|description| { + convert_from_jats(description, markup, ConversionLimit::Abstract) + }) + .transpose()?; + } - #[graphql( - description = "Date the work was withdrawn from publication. Only applies to out of print and withdrawn works." - )] - pub fn withdrawn_date(&self) -> Option { - self.withdrawn_date + Ok(additional_resources) } - #[graphql(description = "Place of publication of the work")] - pub fn place(&self) -> Option<&String> { - self.place.as_ref() + #[graphql(description = "Get awards linked to this work")] + pub fn awards( + &self, + context: &Context, + #[graphql(default = 50, description = "The number of items to return")] limit: Option, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option, + ) -> FieldResult> { + Award::all( + &context.db, + limit.unwrap_or(50), + offset.unwrap_or_default(), + None, + AwardOrderBy::default(), + vec![], + Some(self.work_id), + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) } - #[graphql( - description = "Total number of pages in the work. In most cases, unnumbered pages (e.g. endpapers) should be omitted from this count." - )] - pub fn page_count(&self) -> Option<&i32> { - self.page_count.as_ref() + #[graphql(description = "Get endorsements linked to this work")] + pub fn endorsements( + &self, + context: &Context, + #[graphql(default = 50, description = "The number of items to return")] limit: Option, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option, + ) -> FieldResult> { + Endorsement::all( + &context.db, + limit.unwrap_or(50), + offset.unwrap_or_default(), + None, + EndorsementOrderBy::default(), + vec![], + Some(self.work_id), + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) } - #[graphql( - description = "Breakdown of work's page count into front matter, main content, and/or back matter (e.g. 'xi + 140')" - )] - pub fn page_breakdown(&self) -> Option<&String> { - self.page_breakdown.as_ref() + #[graphql(description = "Get book reviews linked to this work")] + pub fn book_reviews( + &self, + context: &Context, + #[graphql(default = 50, description = "The number of items to return")] limit: Option, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option, + ) -> FieldResult> { + BookReview::all( + &context.db, + limit.unwrap_or(50), + offset.unwrap_or_default(), + None, + BookReviewOrderBy::default(), + vec![], + Some(self.work_id), + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) } - #[graphql(description = "Total number of images in the work")] - pub fn image_count(&self) -> Option<&i32> { - self.image_count.as_ref() + #[graphql(description = "Get the featured video linked to this work")] + pub fn featured_video(&self, context: &Context) -> FieldResult> { + WorkFeaturedVideo::from_work_id(&context.db, &self.work_id).map_err(Into::into) } +} - #[graphql(description = "Total number of tables in the work")] - pub fn table_count(&self) -> Option<&i32> { - self.table_count.as_ref() +#[juniper::graphql_object(Context = Context, description = "A manifestation of a written text")] +impl Publication { + #[graphql(description = "Thoth ID of the publication")] + pub fn publication_id(&self) -> Uuid { + self.publication_id } - #[graphql(description = "Total number of audio fragments in the work")] - pub fn audio_count(&self) -> Option<&i32> { - self.audio_count.as_ref() + #[graphql(description = "Format of this publication")] + pub fn publication_type(&self) -> &PublicationType { + &self.publication_type } - #[graphql(description = "Total number of video fragments in the work")] - pub fn video_count(&self) -> Option<&i32> { - self.video_count.as_ref() + #[graphql(description = "Thoth ID of the work to which this publication belongs")] + pub fn work_id(&self) -> Uuid { + self.work_id } #[graphql( - description = "URL of the license which applies to this work (frequently a Creative Commons license for open-access works)" + description = "International Standard Book Number of the publication, in ISBN-13 format" )] - pub fn license(&self) -> Option<&String> { - self.license.as_ref() + pub fn isbn(&self) -> Option<&Isbn> { + self.isbn.as_ref() } - #[graphql(description = "Copyright holder of the work")] - pub fn copyright_holder(&self) -> Option<&String> { - self.copyright_holder.as_ref() + #[graphql(description = "Date and time at which the publication record was created")] + pub fn created_at(&self) -> Timestamp { + self.created_at } - #[graphql(description = "URL of the web page of the work")] - pub fn landing_page(&self) -> Option<&String> { - self.landing_page.as_ref() + #[graphql(description = "Date and time at which the publication record was last updated")] + pub fn updated_at(&self) -> Timestamp { + self.updated_at } #[graphql( - description = "Library of Congress Control Number of the work (not applicable to chapters)" + description = "Width of the physical Publication (in mm, cm or in) (only applicable to non-Chapter Paperbacks and Hardbacks)" )] - pub fn lccn(&self) -> Option<&String> { - self.lccn.as_ref() + pub fn width( + &self, + #[graphql( + default = LengthUnit::default(), + description = "Unit of measurement in which to represent the width (mm, cm or in)", + )] + units: LengthUnit, + ) -> Option { + match units { + LengthUnit::Mm => self.width_mm, + LengthUnit::Cm => self + .width_mm + .map(|w| w.convert_length_from_to(&LengthUnit::Mm, &LengthUnit::Cm)), + LengthUnit::In => self.width_in, + } } #[graphql( - description = "OCLC (WorldCat) Control Number of the work (not applicable to chapters)" + description = "Height of the physical Publication (in mm, cm or in) (only applicable to non-Chapter Paperbacks and Hardbacks)" )] - pub fn oclc(&self) -> Option<&String> { - self.oclc.as_ref() + pub fn height( + &self, + #[graphql( + default = LengthUnit::default(), + description = "Unit of measurement in which to represent the height (mm, cm or in)", + )] + units: LengthUnit, + ) -> Option { + match units { + LengthUnit::Mm => self.height_mm, + LengthUnit::Cm => self + .height_mm + .map(|w| w.convert_length_from_to(&LengthUnit::Mm, &LengthUnit::Cm)), + LengthUnit::In => self.height_in, + } } #[graphql( - description = "Short abstract of the work. Where a work has two different versions of the abstract, the truncated version should be entered here. Otherwise, it can be left blank. This field is not output in metadata formats; where relevant, Long Abstract is used instead." + description = "Depth of the physical Publication (in mm, cm or in) (only applicable to non-Chapter Paperbacks and Hardbacks)" )] - pub fn short_abstract(&self) -> Option<&String> { - self.short_abstract.as_ref() + pub fn depth( + &self, + #[graphql( + default = LengthUnit::default(), + description = "Unit of measurement in which to represent the depth (mm, cm or in)", + )] + units: LengthUnit, + ) -> Option { + match units { + LengthUnit::Mm => self.depth_mm, + LengthUnit::Cm => self + .depth_mm + .map(|w| w.convert_length_from_to(&LengthUnit::Mm, &LengthUnit::Cm)), + LengthUnit::In => self.depth_in, + } } #[graphql( - description = "Abstract of the work. Where a work has only one abstract, it should be entered here, and Short Abstract can be left blank. Long Abstract is output in metadata formats, and Short Abstract is not." + description = "Weight of the physical Publication (in g or oz) (only applicable to non-Chapter Paperbacks and Hardbacks)" )] - pub fn long_abstract(&self) -> Option<&String> { - self.long_abstract.as_ref() + pub fn weight( + &self, + #[graphql( + default = WeightUnit::default(), + description = "Unit of measurement in which to represent the weight (grams or ounces)", + )] + units: WeightUnit, + ) -> Option { + match units { + WeightUnit::G => self.weight_g, + WeightUnit::Oz => self.weight_oz, + } } - #[graphql( - description = "A general-purpose field used to include information that does not have a specific designated field" - )] - pub fn general_note(&self) -> Option<&String> { - self.general_note.as_ref() + #[graphql(description = "WCAG standard accessibility level met by this publication (if any)")] + pub fn accessibility_standard(&self) -> Option<&AccessibilityStandard> { + self.accessibility_standard.as_ref() } #[graphql( - description = "Indicates that the work contains a bibliography or other similar information" + description = "EPUB- or PDF-specific standard accessibility level met by this publication, if applicable" )] - pub fn bibliography_note(&self) -> Option<&String> { - self.bibliography_note.as_ref() - } - - #[graphql(description = "Table of contents of the work (not applicable to chapters)")] - pub fn toc(&self) -> Option<&String> { - self.toc.as_ref() - } - - #[graphql(description = "URL of the work's cover image")] - pub fn cover_url(&self) -> Option<&String> { - self.cover_url.as_ref() - } - - #[graphql(description = "Caption describing the work's cover image")] - pub fn cover_caption(&self) -> Option<&String> { - self.cover_caption.as_ref() - } - - #[graphql(description = "Date and time at which the work record was created")] - pub fn created_at(&self) -> Timestamp { - self.created_at - } - - #[graphql(description = "Date and time at which the work record was last updated")] - pub fn updated_at(&self) -> Timestamp { - self.updated_at - } - - #[graphql(description = "Page number on which the work begins (only applicable to chapters)")] - pub fn first_page(&self) -> Option<&String> { - self.first_page.as_ref() - } - - #[graphql(description = "Page number on which the work ends (only applicable to chapters)")] - pub fn last_page(&self) -> Option<&String> { - self.last_page.as_ref() + pub fn accessibility_additional_standard(&self) -> Option<&AccessibilityStandard> { + self.accessibility_additional_standard.as_ref() } #[graphql( - description = "Concatenation of first page and last page with dash (only applicable to chapters)" + description = "Reason for this publication not being required to comply with accessibility standards (if any)" )] - pub fn page_interval(&self) -> Option<&String> { - self.page_interval.as_ref() + pub fn accessibility_exception(&self) -> Option<&AccessibilityException> { + self.accessibility_exception.as_ref() } #[graphql( - description = "Date and time at which the work record or any of its linked records was last updated" + description = "Link to a web page showing detailed accessibility information for this publication" )] - pub fn updated_at_with_relations(&self) -> Timestamp { - self.updated_at_with_relations + pub fn accessibility_report_url(&self) -> Option<&String> { + self.accessibility_report_url.as_ref() } - #[graphql(description = "Get this work's imprint")] - pub fn imprint(&self, context: &Context) -> FieldResult { - Imprint::from_id(&context.db, &self.imprint_id).map_err(|e| e.into()) - } - - #[graphql(description = "Get contributions linked to this work")] - pub fn contributions( + #[graphql(description = "Get prices linked to this publication")] + pub fn prices( &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( - default = ContributionOrderBy::default(), + default = PriceOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, + order: Option, #[graphql( default = vec![], - description = "Specific types to filter by", + description = "Specific currencies to filter by" )] - contribution_types: Option>, - ) -> FieldResult> { - Contribution::all( + currency_codes: Option>, + ) -> FieldResult> { + Price::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), None, order.unwrap_or_default(), vec![], - Some(self.work_id), + Some(self.publication_id), None, - contribution_types.unwrap_or_default(), + currency_codes.unwrap_or_default(), vec![], None, + None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } - #[allow(clippy::too_many_arguments)] - #[graphql(description = "Get languages linked to this work")] - pub fn languages( + #[graphql(description = "Get locations linked to this publication")] + pub fn locations( &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( - default = LanguageOrderBy::default(), + default = LocationOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, - #[graphql( - default = vec![], - description = "Specific languages to filter by" - )] - language_codes: Option>, - #[graphql( - description = "(deprecated) A specific relation to filter by" - )] - language_relation: Option, + order: Option, #[graphql( default = vec![], - description = "Specific relations to filter by" + description = "Specific platforms to filter by" )] - language_relations: Option>, - ) -> FieldResult> { - let mut relations = language_relations.unwrap_or_default(); - if let Some(relation) = language_relation { - relations.push(relation); - } - Language::all( + location_platforms: Option>, + ) -> FieldResult> { + Location::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), None, order.unwrap_or_default(), vec![], - Some(self.work_id), + Some(self.publication_id), + None, + location_platforms.unwrap_or_default(), + vec![], None, - language_codes.unwrap_or_default(), - relations, None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } - #[graphql(description = "Get publications linked to this work")] - pub fn publications( + #[graphql(description = "Get the publication file for this publication")] + pub fn file(&self, context: &Context) -> FieldResult> { + File::from_publication_id(&context.db, &self.publication_id).map_err(Into::into) + } + + #[graphql(description = "Get the work to which this publication belongs")] + pub fn work(&self, context: &Context) -> FieldResult { + Work::from_id(&context.db, &self.work_id).map_err(Into::into) + } +} + +#[juniper::graphql_object( + Context = Context, + description = "A file stored in the system (publication file, front cover, additional resource, or featured video)." +)] +impl File { + #[graphql(description = "Thoth ID of the file")] + pub fn file_id(&self) -> &Uuid { + &self.file_id + } + + #[graphql( + description = "Type of file (publication, frontcover, additional_resource, or work_featured_video)" + )] + pub fn file_type(&self) -> &FileType { + &self.file_type + } + + #[graphql(description = "Thoth ID of the work (for frontcovers)")] + pub fn work_id(&self) -> Option<&Uuid> { + self.work_id.as_ref() + } + + #[graphql(description = "Thoth ID of the publication (for publication files)")] + pub fn publication_id(&self) -> Option<&Uuid> { + self.publication_id.as_ref() + } + + #[graphql(description = "Thoth ID of the additional resource (for additional resource files)")] + pub fn additional_resource_id(&self) -> Option<&Uuid> { + self.additional_resource_id.as_ref() + } + + #[graphql(description = "Thoth ID of the featured video (for featured video files)")] + pub fn work_featured_video_id(&self) -> Option<&Uuid> { + self.work_featured_video_id.as_ref() + } + + #[graphql(description = "S3 object key (canonical DOI-based path)")] + pub fn object_key(&self) -> &String { + &self.object_key + } + + #[graphql(description = "Public CDN URL")] + pub fn cdn_url(&self) -> &String { + &self.cdn_url + } + + #[graphql(description = "MIME type used when serving the file")] + pub fn mime_type(&self) -> &String { + &self.mime_type + } + + #[graphql(description = "Size of the file in bytes")] + pub fn bytes(&self) -> i32 { + // GraphQL does not support i64; files larger than 2GB will overflow. + self.bytes as i32 + } + + #[graphql(description = "SHA-256 checksum of the stored file")] + pub fn sha256(&self) -> &String { + &self.sha256 + } + + #[graphql(description = "Date and time at which the file record was created")] + pub fn created_at(&self) -> Timestamp { + self.created_at + } + + #[graphql(description = "Date and time at which the file record was last updated")] + pub fn updated_at(&self) -> Timestamp { + self.updated_at + } +} + +#[juniper::graphql_object(Context = Context, description = "An organisation that produces and distributes written texts.")] +impl Publisher { + #[graphql(description = "Thoth ID of the publisher")] + pub fn publisher_id(&self) -> Uuid { + self.publisher_id + } + + #[graphql(description = "Name of the publisher")] + pub fn publisher_name(&self) -> &String { + &self.publisher_name + } + + #[graphql(description = "Short name of the publisher, if any (e.g. an abbreviation)")] + pub fn publisher_shortname(&self) -> Option<&String> { + self.publisher_shortname.as_ref() + } + + #[graphql(description = "URL of the publisher's website")] + pub fn publisher_url(&self) -> Option<&String> { + self.publisher_url.as_ref() + } + + #[graphql(description = "Zitadel organisation ID associated with the publisher")] + pub fn zitadel_id(&self) -> Option<&String> { + self.zitadel_id.as_ref() + } + + #[graphql( + description = "Statement from the publisher on the accessibility of its texts for readers with impairments" + )] + pub fn accessibility_statement(&self) -> Option<&String> { + self.accessibility_statement.as_ref() + } + + #[graphql( + description = "URL of the publisher's report on the accessibility of its texts for readers with impairments" + )] + pub fn accessibility_report_url(&self) -> Option<&String> { + self.accessibility_report_url.as_ref() + } + + #[graphql(description = "Date and time at which the publisher record was created")] + pub fn created_at(&self) -> Timestamp { + self.created_at + } + + #[graphql(description = "Date and time at which the publisher record was last updated")] + pub fn updated_at(&self) -> Timestamp { + self.updated_at + } + + #[graphql(description = "Get imprints linked to this publisher")] + pub fn imprints( &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on isbn" + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on imprint_name and imprint_url" )] filter: Option, #[graphql( - default = PublicationOrderBy::default(), + default = { + ImprintOrderBy { + field: ImprintField::ImprintName, + direction: Direction::Asc } + }, description = "The order in which to sort the results" )] - order: Option, - #[graphql( - default = vec![], - description = "Specific types to filter by", - )] - publication_types: Option>, - ) -> FieldResult> { - Publication::all( + order: Option, + ) -> FieldResult> { + Imprint::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), filter, order.unwrap_or_default(), vec![], - Some(self.work_id), + Some(self.publisher_id), None, - publication_types.unwrap_or_default(), vec![], + vec![], + None, None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } - #[graphql(description = "Get subjects linked to this work")] - pub fn subjects( + #[graphql(description = "Get contacts linked to this publisher")] + pub fn contacts( &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on subject_code" - )] - filter: Option, - #[graphql( - default = SubjectOrderBy::default(), + default = ContactOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, + order: Option, #[graphql( default = vec![], description = "Specific types to filter by", )] - subject_types: Option>, - ) -> FieldResult> { - Subject::all( + contact_types: Option>, + ) -> FieldResult> { + Contact::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), - filter, + None, order.unwrap_or_default(), vec![], - Some(self.work_id), + Some(self.publisher_id), None, - subject_types.unwrap_or_default(), + contact_types.unwrap_or_default(), vec![], None, + None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } +} - #[graphql(description = "Get fundings linked to this work")] - pub fn fundings( - &self, - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = FundingOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - ) -> FieldResult> { - Funding::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - None, - order.unwrap_or_default(), - vec![], - Some(self.work_id), - None, - vec![], - vec![], - None, - ) - .map_err(|e| e.into()) +#[juniper::graphql_object(Context = Context, description = "The brand under which a publisher issues works.")] +impl Imprint { + #[graphql(description = "Thoth ID of the imprint")] + pub fn imprint_id(&self) -> Uuid { + self.imprint_id + } + + #[graphql(description = "Thoth ID of the publisher to which this imprint belongs")] + pub fn publisher_id(&self) -> Uuid { + self.publisher_id + } + + #[graphql(description = "Name of the imprint")] + pub fn imprint_name(&self) -> &String { + &self.imprint_name + } + + #[graphql(description = "URL of the imprint's landing page")] + pub fn imprint_url(&self) -> Option<&String> { + self.imprint_url.as_ref() + } + + #[graphql( + description = "DOI of the imprint's Crossmark policy page, if publisher participates. Crossmark 'gives readers quick and easy access to the + current status of an item of content, including any corrections, retractions, or updates'. More: https://www.crossref.org/services/crossmark/" + )] + pub fn crossmark_doi(&self) -> Option<&Doi> { + self.crossmark_doi.as_ref() + } + + #[graphql(description = "S3 bucket used for files belonging to this imprint")] + pub fn s3_bucket(&self, context: &Context) -> FieldResult> { + context.require_superuser()?; + Ok(self.s3_bucket.as_deref()) + } + + #[graphql(description = "CDN domain used for files belonging to this imprint")] + pub fn cdn_domain(&self, context: &Context) -> FieldResult> { + context.require_superuser()?; + Ok(self.cdn_domain.as_deref()) + } + + #[graphql(description = "CloudFront distribution ID used for files belonging to this imprint")] + pub fn cloudfront_dist_id(&self, context: &Context) -> FieldResult> { + context.require_superuser()?; + Ok(self.cloudfront_dist_id.as_deref()) + } + + #[graphql(description = "Default currency code for works under this imprint")] + pub fn default_currency(&self) -> Option<&CurrencyCode> { + self.default_currency.as_ref() + } + + #[graphql(description = "Default publication place for works under this imprint")] + pub fn default_place(&self) -> Option<&String> { + self.default_place.as_ref() + } + + #[graphql(description = "Default locale code for works under this imprint")] + pub fn default_locale(&self) -> Option<&LocaleCode> { + self.default_locale.as_ref() + } + + #[graphql(description = "Date and time at which the imprint record was created")] + pub fn created_at(&self) -> Timestamp { + self.created_at + } + + #[graphql(description = "Date and time at which the imprint record was last updated")] + pub fn updated_at(&self) -> Timestamp { + self.updated_at } - #[graphql(description = "Get issues linked to this work")] - pub fn issues( + #[graphql(description = "Get the publisher to which this imprint belongs")] + pub fn publisher(&self, context: &Context) -> FieldResult { + Publisher::from_id(&context.db, &self.publisher_id).map_err(Into::into) + } + + #[allow(clippy::too_many_arguments)] + #[graphql(description = "Get works linked to this imprint")] + pub fn works( &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( - default = IssueOrderBy::default(), + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page" + )] + filter: Option, + #[graphql( + default = WorkOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, - ) -> FieldResult> { - Issue::all( + order: Option, + #[graphql( + default = vec![], + description = "Specific types to filter by", + )] + work_types: Option>, + #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< + WorkStatus, + >, + #[graphql( + default = vec![], + description = "Specific statuses to filter by" + )] + work_statuses: Option>, + #[graphql( + description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" + )] + publication_date: Option, + #[graphql( + description = "Only show results with a publication date either before (less than) or after (greater than) the specified timestamp" + )] + updated_at_with_relations: Option, + ) -> FieldResult> { + let mut statuses = work_statuses.unwrap_or_default(); + if let Some(status) = work_status { + statuses.push(status); + } + Work::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), - None, + filter, order.unwrap_or_default(), vec![], - Some(self.work_id), - None, - vec![], - vec![], + Some(self.imprint_id), None, + work_types.unwrap_or_default(), + statuses, + publication_date, + updated_at_with_relations, ) - .map_err(|e| e.into()) + .map_err(Into::into) } - #[graphql(description = "Get other works related to this work")] - pub fn relations( +} + +#[juniper::graphql_object(Context = Context, description = "A person who has been involved in the production of a written text.")] +impl Contributor { + #[graphql(description = "Thoth ID of the contributor")] + pub fn contributor_id(&self) -> Uuid { + self.contributor_id + } + + #[graphql(description = "Given or first name(s) of the contributor")] + pub fn first_name(&self) -> Option<&String> { + self.first_name.as_ref() + } + + #[graphql(description = "Family or surname of the contributor")] + pub fn last_name(&self) -> &String { + &self.last_name + } + + #[graphql( + description = "Full, serialized name of the contributor. Serialization is often culturally determined." + )] + pub fn full_name(&self) -> &String { + &self.full_name + } + + #[graphql( + description = "ORCID (Open Researcher and Contributor ID) of the contributor as full URL, using the HTTPS scheme and the orcid.org domain (e.g. https://orcid.org/0000-0002-1825-0097)" + )] + pub fn orcid(&self) -> Option<&Orcid> { + self.orcid.as_ref() + } + + #[graphql(description = "URL of the contributor's website")] + pub fn website(&self) -> Option<&String> { + self.website.as_ref() + } + + #[graphql(description = "Date and time at which the contributor record was created")] + pub fn created_at(&self) -> Timestamp { + self.created_at + } + + #[graphql(description = "Date and time at which the contributor record was last updated")] + pub fn updated_at(&self) -> Timestamp { + self.updated_at + } + + #[graphql(description = "Get contributions linked to this contributor")] + pub fn contributions( &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( - default = WorkRelationOrderBy::default(), + default = ContributionOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, + order: Option, #[graphql( default = vec![], description = "Specific types to filter by", )] - relation_types: Option>, - ) -> FieldResult> { - WorkRelation::all( + contribution_types: Option>, + ) -> FieldResult> { + Contribution::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), None, order.unwrap_or_default(), vec![], - Some(self.work_id), None, - relation_types.unwrap_or_default(), + Some(self.contributor_id), + contribution_types.unwrap_or_default(), vec![], None, + None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } - #[graphql(description = "Get references cited by this work")] - pub fn references( +} + +#[juniper::graphql_object(Context = Context, description = "A person's involvement in the production of a written text.")] +impl Contribution { + #[graphql(description = "Thoth ID of the contribution")] + pub fn contribution_id(&self) -> Uuid { + self.contribution_id + } + + #[graphql(description = "Thoth ID of the contributor who created the contribution")] + pub fn contributor_id(&self) -> Uuid { + self.contributor_id + } + + #[graphql(description = "Thoth ID of the work in which the contribution appears")] + pub fn work_id(&self) -> Uuid { + self.work_id + } + + #[graphql(description = "Nature of the contribution")] + pub fn contribution_type(&self) -> &ContributionType { + &self.contribution_type + } + + #[graphql( + description = "Whether this is a main contribution to the work (e.g. contributor credited on title page)" + )] + pub fn main_contribution(&self) -> bool { + self.main_contribution + } + + #[allow(clippy::too_many_arguments)] + #[graphql(description = "Query the full list of biographies")] + pub fn biographies( &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on doi, unstructured_citation, issn, isbn, journal_title, article_title, series_title, volume_title, author, standard_designator, standards_body_name, and standards_body_acronym" + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on title_, subtitle, full_title fields" )] filter: Option, #[graphql( - default = ReferenceOrderBy::default(), + default = BiographyOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, - ) -> FieldResult> { - Reference::all( + order: Option, + #[graphql( + default = vec![], + description = "If set, only shows results with these locale codes" + )] + locale_codes: Option>, + #[graphql( + default = MarkupFormat::JatsXml, + description = "If set, only shows results with this markup format" + )] + markup_format: Option, + ) -> FieldResult> { + let mut biographies = Biography::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), filter, order.unwrap_or_default(), vec![], - Some(self.work_id), + Some(self.contribution_id), None, + locale_codes.unwrap_or_default(), vec![], - vec![], + None, None, ) - .map_err(|e| e.into()) - } -} + .map_err(FieldError::from)?; -#[juniper::graphql_object(Context = Context, description = "A manifestation of a written text")] -impl Publication { - #[graphql(description = "Thoth ID of the publication")] - pub fn publication_id(&self) -> Uuid { - self.publication_id - } - - #[graphql(description = "Format of this publication")] - pub fn publication_type(&self) -> &PublicationType { - &self.publication_type - } + let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + for biography in &mut biographies { + biography.content = + convert_from_jats(&biography.content, markup, ConversionLimit::Biography)?; + } - #[graphql(description = "Thoth ID of the work to which this publication belongs")] - pub fn work_id(&self) -> Uuid { - self.work_id + Ok(biographies) } + #[graphql(description = "Biography of the contributor at the time of contribution")] #[graphql( - description = "International Standard Book Number of the publication, in ISBN-13 format" + deprecated = "Please use Contribution `biographies` field instead to get the correct biography in a multilingual manner" )] - pub fn isbn(&self) -> Option<&Isbn> { - self.isbn.as_ref() + pub fn biography(&self, ctx: &Context) -> FieldResult> { + Ok( + Biography::canonical_from_contribution_id(&ctx.db, &self.contribution_id) + .map(|a| a.content) + .ok(), + ) } - #[graphql(description = "Date and time at which the publication record was created")] + #[graphql(description = "Date and time at which the contribution record was created")] pub fn created_at(&self) -> Timestamp { self.created_at } - #[graphql(description = "Date and time at which the publication record was last updated")] + #[graphql(description = "Date and time at which the contribution record was last updated")] pub fn updated_at(&self) -> Timestamp { self.updated_at } #[graphql( - description = "Width of the physical Publication (in mm, cm or in) (only applicable to non-Chapter Paperbacks and Hardbacks)" + description = "Given or first name(s) of the contributor, as credited in this contribution" )] - pub fn width( - &self, - #[graphql( - default = LengthUnit::default(), - description = "Unit of measurement in which to represent the width (mm, cm or in)", - )] - units: LengthUnit, - ) -> Option { - match units { - LengthUnit::Mm => self.width_mm, - LengthUnit::Cm => self - .width_mm - .map(|w| w.convert_length_from_to(&LengthUnit::Mm, &LengthUnit::Cm)), - LengthUnit::In => self.width_in, - } + pub fn first_name(&self) -> Option<&String> { + self.first_name.as_ref() } #[graphql( - description = "Height of the physical Publication (in mm, cm or in) (only applicable to non-Chapter Paperbacks and Hardbacks)" + description = "Family or surname of the contributor, as credited in this contribution" )] - pub fn height( - &self, - #[graphql( - default = LengthUnit::default(), - description = "Unit of measurement in which to represent the height (mm, cm or in)", - )] - units: LengthUnit, - ) -> Option { - match units { - LengthUnit::Mm => self.height_mm, - LengthUnit::Cm => self - .height_mm - .map(|w| w.convert_length_from_to(&LengthUnit::Mm, &LengthUnit::Cm)), - LengthUnit::In => self.height_in, - } + pub fn last_name(&self) -> &String { + &self.last_name } #[graphql( - description = "Depth of the physical Publication (in mm, cm or in) (only applicable to non-Chapter Paperbacks and Hardbacks)" + description = "Full, serialized name of the contributor, as credited in this contribution" )] - pub fn depth( - &self, - #[graphql( - default = LengthUnit::default(), - description = "Unit of measurement in which to represent the depth (mm, cm or in)", - )] - units: LengthUnit, - ) -> Option { - match units { - LengthUnit::Mm => self.depth_mm, - LengthUnit::Cm => self - .depth_mm - .map(|w| w.convert_length_from_to(&LengthUnit::Mm, &LengthUnit::Cm)), - LengthUnit::In => self.depth_in, - } + pub fn full_name(&self) -> &String { + &self.full_name } #[graphql( - description = "Weight of the physical Publication (in g or oz) (only applicable to non-Chapter Paperbacks and Hardbacks)" + description = "Number representing this contribution's position in an ordered list of contributions within the work" )] - pub fn weight( - &self, - #[graphql( - default = WeightUnit::default(), - description = "Unit of measurement in which to represent the weight (grams or ounces)", - )] - units: WeightUnit, - ) -> Option { - match units { - WeightUnit::G => self.weight_g, - WeightUnit::Oz => self.weight_oz, - } + pub fn contribution_ordinal(&self) -> &i32 { + &self.contribution_ordinal } - #[graphql(description = "Get prices linked to this publication")] - pub fn prices( + #[graphql(description = "Get the work in which the contribution appears")] + pub fn work(&self, context: &Context) -> FieldResult { + Work::from_id(&context.db, &self.work_id).map_err(Into::into) + } + + #[graphql(description = "Get the contributor who created the contribution")] + pub fn contributor(&self, context: &Context) -> FieldResult { + Contributor::from_id(&context.db, &self.contributor_id).map_err(Into::into) + } + + #[graphql(description = "Get affiliations linked to this contribution")] + pub fn affiliations( &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( - default = PriceOrderBy::default(), + default = AffiliationOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, - #[graphql( - default = vec![], - description = "Specific currencies to filter by" - )] - currency_codes: Option>, - ) -> FieldResult> { - Price::all( + order: Option, + ) -> FieldResult> { + Affiliation::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), None, order.unwrap_or_default(), vec![], - Some(self.publication_id), None, - currency_codes.unwrap_or_default(), + Some(self.contribution_id), + vec![], vec![], None, + None, ) - .map_err(|e| e.into()) + .map_err(Into::into) + } +} + +#[juniper::graphql_object(Context = Context, description = "A periodical of publications about a particular subject.")] +impl Series { + #[graphql(description = "Thoth ID of the series")] + pub fn series_id(&self) -> Uuid { + self.series_id } - #[graphql(description = "Get locations linked to this publication")] - pub fn locations( + #[graphql(description = "Type of the series")] + pub fn series_type(&self) -> &SeriesType { + &self.series_type + } + + #[graphql(description = "Name of the series")] + pub fn series_name(&self) -> &String { + &self.series_name + } + + #[graphql( + description = "Print ISSN (International Standard Serial Number) of the series. This represents the print media version." + )] + pub fn issn_print(&self) -> Option<&String> { + self.issn_print.as_ref() + } + + #[graphql( + description = "Electronic ISSN (International Standard Serial Number) of the series. This represents the online version." + )] + pub fn issn_digital(&self) -> Option<&String> { + self.issn_digital.as_ref() + } + + #[graphql(description = "URL of the series' landing page")] + pub fn series_url(&self) -> Option<&String> { + self.series_url.as_ref() + } + + #[graphql(description = "Description of the series")] + pub fn series_description(&self) -> Option<&String> { + self.series_description.as_ref() + } + + #[graphql(description = "URL of the series' call for proposals page")] + pub fn series_cfp_url(&self) -> Option<&String> { + self.series_cfp_url.as_ref() + } + + #[graphql(description = "Thoth ID of the imprint to which this series belongs")] + pub fn imprint_id(&self) -> Uuid { + self.imprint_id + } + + #[graphql(description = "Date and time at which the series record was created")] + pub fn created_at(&self) -> Timestamp { + self.created_at + } + + #[graphql(description = "Date and time at which the series record was last updated")] + pub fn updated_at(&self) -> Timestamp { + self.updated_at + } + + #[graphql(description = "Get the imprint linked to this series")] + pub fn imprint(&self, context: &Context) -> FieldResult { + Imprint::from_id(&context.db, &self.imprint_id).map_err(Into::into) + } + + #[graphql(description = "Get issues linked to this series")] + pub fn issues( &self, context: &Context, #[graphql(default = 100, description = "The number of items to return")] limit: Option, #[graphql(default = 0, description = "The number of items to skip")] offset: Option, #[graphql( - default = LocationOrderBy::default(), + default = IssueOrderBy::default(), description = "The order in which to sort the results" )] - order: Option, - #[graphql( - default = vec![], - description = "Specific platforms to filter by" - )] - location_platforms: Option>, - ) -> FieldResult> { - Location::all( + order: Option, + ) -> FieldResult> { + Issue::all( &context.db, limit.unwrap_or_default(), offset.unwrap_or_default(), None, order.unwrap_or_default(), vec![], - Some(self.publication_id), None, - location_platforms.unwrap_or_default(), + Some(self.series_id), + vec![], vec![], None, + None, ) - .map_err(|e| e.into()) + .map_err(Into::into) + } +} + +#[juniper::graphql_object(Context = Context, description = "A work published as a number in a periodical.")] +impl Issue { + #[graphql(description = "Thoth ID of the issue")] + pub fn issue_id(&self) -> Uuid { + self.issue_id } - #[graphql(description = "Get the work to which this publication belongs")] + #[graphql(description = "Thoth ID of the work represented by the issue")] + pub fn work_id(&self) -> Uuid { + self.work_id + } + + #[graphql(description = "Thoth ID of the series to which the issue belongs")] + pub fn series_id(&self) -> Uuid { + self.series_id + } + + #[graphql( + description = "Number representing this issue's position in an ordered list of issues within the series (does not have to correspond to published issue number)" + )] + pub fn issue_ordinal(&self) -> &i32 { + &self.issue_ordinal + } + + #[graphql(description = "Published issue number given to this issue within the series, if any")] + pub fn issue_number(&self) -> Option<&i32> { + self.issue_number.as_ref() + } + + #[graphql(description = "Date and time at which the issue record was created")] + pub fn created_at(&self) -> Timestamp { + self.created_at + } + + #[graphql(description = "Date and time at which the issue record was last updated")] + pub fn updated_at(&self) -> Timestamp { + self.updated_at + } + + #[graphql(description = "Get the series to which the issue belongs")] + pub fn series(&self, context: &Context) -> FieldResult { + Series::from_id(&context.db, &self.series_id).map_err(Into::into) + } + + #[graphql(description = "Get the work represented by the issue")] pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } } -#[juniper::graphql_object(Context = Context, description = "An organisation that produces and distributes written texts.")] -impl Publisher { - #[graphql(description = "Thoth ID of the publisher")] - pub fn publisher_id(&self) -> Uuid { - self.publisher_id +#[juniper::graphql_object(Context = Context, description = "Description of a work's language.")] +impl Language { + #[graphql(description = "Thoth ID of the language")] + pub fn language_id(&self) -> Uuid { + self.language_id } - #[graphql(description = "Name of the publisher")] - pub fn publisher_name(&self) -> &String { - &self.publisher_name + #[graphql(description = "Thoth ID of the work which has this language")] + pub fn work_id(&self) -> Uuid { + self.work_id } - #[graphql(description = "Short name of the publisher, if any (e.g. an abbreviation)")] - pub fn publisher_shortname(&self) -> Option<&String> { - self.publisher_shortname.as_ref() + #[graphql(description = "Three-letter ISO 639 code representing the language")] + pub fn language_code(&self) -> &LanguageCode { + &self.language_code } - #[graphql(description = "URL of the publisher's website")] - pub fn publisher_url(&self) -> Option<&String> { - self.publisher_url.as_ref() + #[graphql(description = "Relation between this language and the original language of the text")] + pub fn language_relation(&self) -> &LanguageRelation { + &self.language_relation } - #[graphql(description = "Date and time at which the publisher record was created")] + #[graphql(description = "Date and time at which the language record was created")] pub fn created_at(&self) -> Timestamp { self.created_at } - #[graphql(description = "Date and time at which the publisher record was last updated")] + #[graphql(description = "Date and time at which the language record was last updated")] pub fn updated_at(&self) -> Timestamp { self.updated_at } - #[graphql(description = "Get imprints linked to this publisher")] - pub fn imprints( - &self, - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on imprint_name and imprint_url" - )] - filter: Option, - #[graphql( - default = { - ImprintOrderBy { - field: ImprintField::ImprintName, - direction: Direction::Asc, - } - }, - description = "The order in which to sort the results" - )] - order: Option, - ) -> FieldResult> { - Imprint::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - filter, - order.unwrap_or_default(), - vec![], - Some(self.publisher_id), - None, - vec![], - vec![], - None, - ) - .map_err(|e| e.into()) + #[graphql(description = "Get the work which has this language")] + pub fn work(&self, context: &Context) -> FieldResult { + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } } -#[juniper::graphql_object(Context = Context, description = "The brand under which a publisher issues works.")] -impl Imprint { - #[graphql(description = "Thoth ID of the imprint")] - pub fn imprint_id(&self) -> Uuid { - self.imprint_id +#[juniper::graphql_object(Context = Context, description = "A location, such as a web shop or distribution platform, where a publication can be acquired or viewed.")] +impl Location { + #[graphql(description = "Thoth ID of the location")] + pub fn location_id(&self) -> Uuid { + self.location_id + } + + #[graphql(description = "Thoth ID of the publication linked to this location")] + pub fn publication_id(&self) -> Uuid { + self.publication_id } - #[graphql(description = "Thoth ID of the publisher to which this imprint belongs")] - pub fn publisher_id(&self) -> Uuid { - self.publisher_id + #[graphql(description = "Public-facing URL via which the publication can be accessed")] + pub fn landing_page(&self) -> Option<&String> { + self.landing_page.as_ref() } - #[graphql(description = "Name of the imprint")] - pub fn imprint_name(&self) -> &String { - &self.imprint_name + #[graphql(description = "Direct link to the full text file")] + pub fn full_text_url(&self) -> Option<&String> { + self.full_text_url.as_ref() } - #[graphql(description = "URL of the imprint's landing page")] - pub fn imprint_url(&self) -> Option<&String> { - self.imprint_url.as_ref() + #[graphql(description = "Platform where the publication is hosted or can be acquired")] + pub fn location_platform(&self) -> &LocationPlatform { + &self.location_platform } #[graphql( - description = "DOI of the imprint's Crossmark policy page, if publisher participates. Crossmark 'gives readers quick and easy access to the - current status of an item of content, including any corrections, retractions, or updates'. More: https://www.crossref.org/services/crossmark/" + description = "Whether this is the canonical location for this specific publication (e.g. the main platform on which the print version is sold, or the official version of record hosted on the publisher's own web server)" )] - pub fn crossmark_doi(&self) -> Option<&Doi> { - self.crossmark_doi.as_ref() + pub fn canonical(&self) -> bool { + self.canonical } - #[graphql(description = "Date and time at which the imprint record was created")] + #[graphql(description = "Date and time at which the location record was created")] pub fn created_at(&self) -> Timestamp { self.created_at } - #[graphql(description = "Date and time at which the imprint record was last updated")] + #[graphql(description = "Date and time at which the location record was last updated")] pub fn updated_at(&self) -> Timestamp { self.updated_at } - #[graphql(description = "Get the publisher to which this imprint belongs")] - pub fn publisher(&self, context: &Context) -> FieldResult { - Publisher::from_id(&context.db, &self.publisher_id).map_err(|e| e.into()) - } - - #[allow(clippy::too_many_arguments)] - #[graphql(description = "Get works linked to this imprint")] - pub fn works( - &self, - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = "".to_string(), - description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page" - )] - filter: Option, - #[graphql( - default = WorkOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - #[graphql( - default = vec![], - description = "Specific types to filter by", - )] - work_types: Option>, - #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< - WorkStatus, - >, - #[graphql( - default = vec![], - description = "Specific statuses to filter by" - )] - work_statuses: Option>, - #[graphql( - description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" - )] - updated_at_with_relations: Option, - ) -> FieldResult> { - let mut statuses = work_statuses.unwrap_or_default(); - if let Some(status) = work_status { - statuses.push(status); - } - Work::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - filter, - order.unwrap_or_default(), - vec![], - Some(self.imprint_id), - None, - work_types.unwrap_or_default(), - statuses, - updated_at_with_relations, - ) - .map_err(|e| e.into()) + #[graphql(description = "Get the publication linked to this location")] + pub fn publication(&self, context: &Context) -> FieldResult { + Publication::from_id(&context.db, &self.publication_id).map_err(Into::into) } } -#[juniper::graphql_object(Context = Context, description = "A person who has been involved in the production of a written text.")] -impl Contributor { - #[graphql(description = "Thoth ID of the contributor")] - pub fn contributor_id(&self) -> Uuid { - self.contributor_id - } - - #[graphql(description = "Given or first name(s) of the contributor")] - pub fn first_name(&self) -> Option<&String> { - self.first_name.as_ref() - } - - #[graphql(description = "Family or surname of the contributor")] - pub fn last_name(&self) -> &String { - &self.last_name +#[juniper::graphql_object(Context = Context, description = "The amount of money, in any currency, that a publication costs.")] +impl Price { + #[graphql(description = "Thoth ID of the price")] + pub fn price_id(&self) -> Uuid { + self.price_id } - #[graphql( - description = "Full, serialized name of the contributor. Serialization is often culturally determined." - )] - pub fn full_name(&self) -> &String { - &self.full_name + #[graphql(description = "Thoth ID of the publication linked to this price")] + pub fn publication_id(&self) -> Uuid { + self.publication_id } #[graphql( - description = "ORCID (Open Researcher and Contributor ID) of the contributor as full URL, using the HTTPS scheme and the orcid.org domain (e.g. https://orcid.org/0000-0002-1825-0097)" + description = "Three-letter ISO 4217 code representing the currency used in this price" )] - pub fn orcid(&self) -> Option<&Orcid> { - self.orcid.as_ref() + pub fn currency_code(&self) -> &CurrencyCode { + &self.currency_code } - #[graphql(description = "URL of the contributor's website")] - pub fn website(&self) -> Option<&String> { - self.website.as_ref() + #[graphql(description = "Value of the publication in the specified currency")] + pub fn unit_price(&self) -> f64 { + self.unit_price } - #[graphql(description = "Date and time at which the contributor record was created")] + #[graphql(description = "Date and time at which the price record was created")] pub fn created_at(&self) -> Timestamp { self.created_at } - #[graphql(description = "Date and time at which the contributor record was last updated")] + #[graphql(description = "Date and time at which the price record was last updated")] pub fn updated_at(&self) -> Timestamp { self.updated_at } - #[graphql(description = "Get contributions linked to this contributor")] - pub fn contributions( - &self, - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = ContributionOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - #[graphql( - default = vec![], - description = "Specific types to filter by", - )] - contribution_types: Option>, - ) -> FieldResult> { - Contribution::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - None, - order.unwrap_or_default(), - vec![], - None, - Some(self.contributor_id), - contribution_types.unwrap_or_default(), - vec![], - None, - ) - .map_err(|e| e.into()) + #[graphql(description = "Get the publication linked to this price")] + pub fn publication(&self, context: &Context) -> FieldResult { + Publication::from_id(&context.db, &self.publication_id).map_err(Into::into) } } -#[juniper::graphql_object(Context = Context, description = "A person's involvement in the production of a written text.")] -impl Contribution { - #[graphql(description = "Thoth ID of the contribution")] - pub fn contribution_id(&self) -> Uuid { - self.contribution_id +#[juniper::graphql_object(Context = Context, description = "A significant discipline or term related to a work.")] +impl Subject { + #[graphql(description = "Thoth ID of the subject")] + pub fn subject_id(&self) -> &Uuid { + &self.subject_id } - #[graphql(description = "Thoth ID of the contributor who created the contribution")] - pub fn contributor_id(&self) -> Uuid { - self.contributor_id + #[graphql(description = "Thoth ID of the work to which the subject is linked")] + pub fn work_id(&self) -> &Uuid { + &self.work_id } - #[graphql(description = "Thoth ID of the work in which the contribution appears")] - pub fn work_id(&self) -> Uuid { - self.work_id + #[graphql(description = "Type of the subject (e.g. the subject category scheme being used)")] + pub fn subject_type(&self) -> &SubjectType { + &self.subject_type } - #[graphql(description = "Nature of the contribution")] - pub fn contribution_type(&self) -> &ContributionType { - &self.contribution_type + #[graphql(description = "Code representing the subject within the specified type")] + pub fn subject_code(&self) -> &String { + &self.subject_code } #[graphql( - description = "Whether this is a main contribution to the work (e.g. contributor credited on title page)" + description = "Number representing this subject's position in an ordered list of subjects of the same type within the work (subjects of equal prominence can have the same number)" )] - pub fn main_contribution(&self) -> bool { - self.main_contribution - } - - #[graphql(description = "Biography of the contributor at the time of contribution")] - pub fn biography(&self) -> Option<&String> { - self.biography.as_ref() + pub fn subject_ordinal(&self) -> &i32 { + &self.subject_ordinal } - #[graphql(description = "Date and time at which the contribution record was created")] + #[graphql(description = "Date and time at which the subject record was created")] pub fn created_at(&self) -> Timestamp { self.created_at } - #[graphql(description = "Date and time at which the contribution record was last updated")] + #[graphql(description = "Date and time at which the subject record was last updated")] pub fn updated_at(&self) -> Timestamp { self.updated_at } - #[graphql( - description = "Given or first name(s) of the contributor, as credited in this contribution" - )] - pub fn first_name(&self) -> Option<&String> { - self.first_name.as_ref() + #[graphql(description = "Get the work to which the subject is linked")] + pub fn work(&self, context: &Context) -> FieldResult { + Work::from_id(&context.db, &self.work_id).map_err(Into::into) + } +} + +#[juniper::graphql_object(Context = Context, description = "An organisation with which contributors may be affiliated or by which works may be funded.")] +impl Institution { + #[graphql(description = "Thoth ID of the institution")] + pub fn institution_id(&self) -> &Uuid { + &self.institution_id + } + + #[graphql(description = "Name of the institution")] + pub fn institution_name(&self) -> &String { + &self.institution_name } #[graphql( - description = "Family or surname of the contributor, as credited in this contribution" + description = "Digital Object Identifier of the organisation as full URL, using the HTTPS scheme and the doi.org domain (e.g. https://doi.org/10.13039/100014013)" )] - pub fn last_name(&self) -> &String { - &self.last_name + pub fn institution_doi(&self) -> Option<&Doi> { + self.institution_doi.as_ref() } #[graphql( - description = "Full, serialized name of the contributor, as credited in this contribution" + description = "Three-letter ISO 3166-1 code representing the country where this institution is based" )] - pub fn full_name(&self) -> &String { - &self.full_name + pub fn country_code(&self) -> Option<&CountryCode> { + self.country_code.as_ref() } #[graphql( - description = "Number representing this contribution's position in an ordered list of contributions within the work" + description = "Research Organisation Registry identifier of the organisation as full URL, using the HTTPS scheme and the ror.org domain (e.g. https://ror.org/051z6e826)" )] - pub fn contribution_ordinal(&self) -> &i32 { - &self.contribution_ordinal + pub fn ror(&self) -> Option<&Ror> { + self.ror.as_ref() } - #[graphql(description = "Get the work in which the contribution appears")] - pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + #[graphql(description = "Date and time at which the institution record was created")] + pub fn created_at(&self) -> Timestamp { + self.created_at + } + + #[graphql(description = "Date and time at which the institution record was last updated")] + pub fn updated_at(&self) -> Timestamp { + self.updated_at } - #[graphql(description = "Get the contributor who created the contribution")] - pub fn contributor(&self, context: &Context) -> FieldResult { - Contributor::from_id(&context.db, &self.contributor_id).map_err(|e| e.into()) + #[graphql(description = "Get fundings linked to this institution")] + pub fn fundings( + &self, + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option, + #[graphql( + default = FundingOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option, + ) -> FieldResult> { + Funding::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + vec![], + None, + Some(self.institution_id), + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) } - #[graphql(description = "Get affiliations linked to this contribution")] + #[graphql(description = "Get affiliations linked to this institution")] pub fn affiliations( &self, context: &Context, @@ -3449,496 +2091,576 @@ impl Contribution { None, order.unwrap_or_default(), vec![], + Some(self.institution_id), None, - Some(self.contribution_id), vec![], vec![], None, + None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } } -#[juniper::graphql_object(Context = Context, description = "A periodical of publications about a particular subject.")] -impl Series { - #[graphql(description = "Thoth ID of the series")] - pub fn series_id(&self) -> Uuid { - self.series_id - } - - #[graphql(description = "Type of the series")] - pub fn series_type(&self) -> &SeriesType { - &self.series_type - } - - #[graphql(description = "Name of the series")] - pub fn series_name(&self) -> &String { - &self.series_name +#[juniper::graphql_object(Context = Context, description = "A grant awarded for the publication of a work by an institution.")] +impl Funding { + #[graphql(description = "Thoth ID of the funding")] + pub fn funding_id(&self) -> &Uuid { + &self.funding_id } - #[graphql( - description = "Print ISSN (International Standard Serial Number) of the series. This represents the print media version." - )] - pub fn issn_print(&self) -> Option<&String> { - self.issn_print.as_ref() + #[graphql(description = "Thoth ID of the funded work")] + pub fn work_id(&self) -> &Uuid { + &self.work_id } - #[graphql( - description = "Electronic ISSN (International Standard Serial Number) of the series. This represents the online version." - )] - pub fn issn_digital(&self) -> Option<&String> { - self.issn_digital.as_ref() + #[graphql(description = "Thoth ID of the funding institution")] + pub fn institution_id(&self) -> &Uuid { + &self.institution_id } - #[graphql(description = "URL of the series' landing page")] - pub fn series_url(&self) -> Option<&String> { - self.series_url.as_ref() + #[graphql(description = "Name of the funding program")] + pub fn program(&self) -> Option<&String> { + self.program.as_ref() } - #[graphql(description = "Description of the series")] - pub fn series_description(&self) -> Option<&String> { - self.series_description.as_ref() + #[graphql(description = "Name of the funding project")] + pub fn project_name(&self) -> Option<&String> { + self.project_name.as_ref() } - #[graphql(description = "URL of the series' call for proposals page")] - pub fn series_cfp_url(&self) -> Option<&String> { - self.series_cfp_url.as_ref() + #[graphql(description = "Short name of the funding project")] + pub fn project_shortname(&self) -> Option<&String> { + self.project_shortname.as_ref() } - #[graphql(description = "Thoth ID of the imprint to which this series belongs")] - pub fn imprint_id(&self) -> Uuid { - self.imprint_id + #[graphql(description = "Grant number of the award")] + pub fn grant_number(&self) -> Option<&String> { + self.grant_number.as_ref() } - #[graphql(description = "Date and time at which the series record was created")] + #[graphql(description = "Date and time at which the funding record was created")] pub fn created_at(&self) -> Timestamp { self.created_at } - #[graphql(description = "Date and time at which the series record was last updated")] + #[graphql(description = "Date and time at which the funding record was last updated")] pub fn updated_at(&self) -> Timestamp { self.updated_at } - #[graphql(description = "Get the imprint linked to this series")] - pub fn imprint(&self, context: &Context) -> FieldResult { - Imprint::from_id(&context.db, &self.imprint_id).map_err(|e| e.into()) + #[graphql(description = "Get the funded work")] + pub fn work(&self, context: &Context) -> FieldResult { + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } - #[graphql(description = "Get issues linked to this series")] - pub fn issues( - &self, - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = IssueOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - ) -> FieldResult> { - Issue::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - None, - order.unwrap_or_default(), - vec![], - None, - Some(self.series_id), - vec![], - vec![], - None, - ) - .map_err(|e| e.into()) + #[graphql(description = "Get the funding institution")] + pub fn institution(&self, context: &Context) -> FieldResult { + Institution::from_id(&context.db, &self.institution_id).map_err(Into::into) } } -#[juniper::graphql_object(Context = Context, description = "A work published as a number in a periodical.")] -impl Issue { - #[graphql(description = "Thoth ID of the issue")] - pub fn issue_id(&self) -> Uuid { - self.issue_id +#[juniper::graphql_object( + Context = Context, + name = "WorkResource", + description = "A resource linked to a work but not embedded in the work text." +)] +impl AdditionalResource { + #[graphql(description = "Thoth ID of the work resource")] + pub fn work_resource_id(&self) -> Uuid { + self.additional_resource_id } - #[graphql(description = "Thoth ID of the work represented by the issue")] + #[graphql(description = "Thoth ID of the work to which this resource belongs")] pub fn work_id(&self) -> Uuid { self.work_id } - #[graphql(description = "Thoth ID of the series to which the issue belongs")] - pub fn series_id(&self) -> Uuid { - self.series_id + #[graphql(description = "Title of the additional resource")] + pub fn title( + &self, + #[graphql(description = "Markup format used for rendering title")] markup_format: Option< + MarkupFormat, + >, + ) -> FieldResult { + match markup_format { + Some(markup) => { + convert_from_jats(&self.title, markup, ConversionLimit::Title).map_err(Into::into) + } + None => Ok(self.title.clone()), + } + } + + #[graphql(description = "Description of the additional resource")] + pub fn description( + &self, + #[graphql(description = "Markup format used for rendering description")] + markup_format: Option, + ) -> FieldResult> { + match (&self.description, markup_format) { + (Some(description), Some(markup)) => { + convert_from_jats(description, markup, ConversionLimit::Abstract) + .map(Some) + .map_err(Into::into) + } + (Some(description), None) => Ok(Some(description.clone())), + (None, _) => Ok(None), + } + } + + #[graphql(description = "Attribution for the resource source/author")] + pub fn attribution(&self) -> Option<&String> { + self.attribution.as_ref() + } + + #[graphql(description = "Type of additional resource")] + pub fn resource_type(&self) -> String { + self.resource_type.to_string() } #[graphql( - description = "Number representing this issue's position in an ordered list of issues within the series (does not have to correspond to published issue number)" + description = "DOI of the resource as full URL, using the HTTPS scheme and the doi.org domain" )] - pub fn issue_ordinal(&self) -> &i32 { - &self.issue_ordinal + pub fn doi(&self) -> Option<&Doi> { + self.doi.as_ref() } - #[graphql(description = "Date and time at which the issue record was created")] + #[graphql(description = "Handle identifier of the resource")] + pub fn handle(&self) -> Option<&String> { + self.handle.as_ref() + } + + #[graphql(description = "URL of the additional resource")] + pub fn url(&self) -> Option<&String> { + self.url.as_ref() + } + + #[graphql(description = "Date associated with the additional resource")] + pub fn date(&self) -> Option { + self.date + } + + #[graphql( + description = "Number representing this resource's position in an ordered list of resources within the work" + )] + pub fn resource_ordinal(&self) -> i32 { + self.resource_ordinal + } + + #[graphql(description = "Date and time at which the resource record was created")] pub fn created_at(&self) -> Timestamp { self.created_at } - #[graphql(description = "Date and time at which the issue record was last updated")] + #[graphql(description = "Date and time at which the resource record was last updated")] pub fn updated_at(&self) -> Timestamp { self.updated_at } - #[graphql(description = "Get the series to which the issue belongs")] - pub fn series(&self, context: &Context) -> FieldResult { - Series::from_id(&context.db, &self.series_id).map_err(|e| e.into()) + #[graphql(description = "Get the work linked to this resource")] + pub fn work(&self, context: &Context) -> FieldResult { + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } - #[graphql(description = "Get the work represented by the issue")] - pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + #[graphql(description = "Get the hosted file linked to this resource")] + pub fn file(&self, context: &Context) -> FieldResult> { + File::from_additional_resource_id(&context.db, &self.additional_resource_id) + .map_err(Into::into) } } -#[juniper::graphql_object(Context = Context, description = "Description of a work's language.")] -impl Language { - #[graphql(description = "Thoth ID of the language")] - pub fn language_id(&self) -> Uuid { - self.language_id +#[juniper::graphql_object( + Context = Context, + description = "An award linked to a work." +)] +impl Award { + #[graphql(description = "Thoth ID of the award")] + pub fn award_id(&self) -> Uuid { + self.award_id } - #[graphql(description = "Thoth ID of the work which has this language")] + #[graphql(description = "Thoth ID of the work to which this award belongs")] pub fn work_id(&self) -> Uuid { self.work_id } - #[graphql(description = "Three-letter ISO 639 code representing the language")] - pub fn language_code(&self) -> &LanguageCode { - &self.language_code + #[graphql(description = "Title of the award")] + pub fn title( + &self, + #[graphql( + default = MarkupFormat::JatsXml, + description = "Markup format used for rendering title", + )] + markup_format: Option, + ) -> FieldResult { + convert_from_jats( + &self.title, + markup_format.ok_or(ThothError::MissingMarkupFormat)?, + ConversionLimit::Title, + ) + .map_err(Into::into) } - #[graphql(description = "Relation between this language and the original language of the text")] - pub fn language_relation(&self) -> &LanguageRelation { - &self.language_relation + #[graphql(description = "URL of the award page")] + pub fn url(&self) -> Option<&String> { + self.url.as_ref() + } + + #[graphql(description = "Category of the award")] + pub fn category(&self) -> Option<&String> { + self.category.as_ref() + } + + #[graphql(description = "Role of the work in this award")] + pub fn role(&self) -> Option { + self.role + } + + #[graphql(description = "Prize statement for this award")] + pub fn prize_statement( + &self, + #[graphql( + default = MarkupFormat::JatsXml, + description = "Markup format used for rendering prize statement", + )] + markup_format: Option, + ) -> FieldResult> { + self.prize_statement + .as_ref() + .map(|prize_statement| { + convert_from_jats( + prize_statement, + markup_format.ok_or(ThothError::MissingMarkupFormat)?, + ConversionLimit::Abstract, + ) + }) + .transpose() + .map_err(Into::into) } #[graphql( - description = "Whether this is a main language of the work (e.g. used for large sections of the text rather than just isolated quotations)" + description = "Number representing this award's position in an ordered list of awards within the work" )] - pub fn main_language(&self) -> bool { - self.main_language + pub fn award_ordinal(&self) -> i32 { + self.award_ordinal } - #[graphql(description = "Date and time at which the language record was created")] + #[graphql(description = "Date and time at which the award record was created")] pub fn created_at(&self) -> Timestamp { self.created_at } - #[graphql(description = "Date and time at which the language record was last updated")] + #[graphql(description = "Date and time at which the award record was last updated")] pub fn updated_at(&self) -> Timestamp { self.updated_at } - #[graphql(description = "Get the work which has this language")] + #[graphql(description = "Get the work linked to this award")] pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } } -#[juniper::graphql_object(Context = Context, description = "A location, such as a web shop or distribution platform, where a publication can be acquired or viewed.")] -impl Location { - #[graphql(description = "Thoth ID of the location")] - pub fn location_id(&self) -> Uuid { - self.location_id - } - - #[graphql(description = "Thoth ID of the publication linked to this location")] - pub fn publication_id(&self) -> Uuid { - self.publication_id +#[juniper::graphql_object( + Context = Context, + description = "An endorsement linked to a work." +)] +impl Endorsement { + #[graphql(description = "Thoth ID of the endorsement")] + pub fn endorsement_id(&self) -> Uuid { + self.endorsement_id } - #[graphql(description = "Public-facing URL via which the publication can be accessed")] - pub fn landing_page(&self) -> Option<&String> { - self.landing_page.as_ref() + #[graphql(description = "Thoth ID of the work to which this endorsement belongs")] + pub fn work_id(&self) -> Uuid { + self.work_id } - #[graphql(description = "Direct link to the full text file")] - pub fn full_text_url(&self) -> Option<&String> { - self.full_text_url.as_ref() + #[graphql(description = "Name of the endorsement author")] + pub fn author_name(&self) -> Option<&String> { + self.author_name.as_ref() } - #[graphql(description = "Platform where the publication is hosted or can be acquired")] - pub fn location_platform(&self) -> &LocationPlatform { - &self.location_platform + #[graphql(description = "Role of the endorsement author")] + pub fn author_role(&self) -> Option<&String> { + self.author_role.as_ref() } #[graphql( - description = "Whether this is the canonical location for this specific publication (e.g. the main platform on which the print version is sold, or the official version of record hosted on the publisher's own web server)" + description = "ORCID (Open Researcher and Contributor ID) of the endorsement author as full URL, using the HTTPS scheme and the orcid.org domain" )] - pub fn canonical(&self) -> bool { - self.canonical - } - - #[graphql(description = "Date and time at which the location record was created")] - pub fn created_at(&self) -> Timestamp { - self.created_at - } - - #[graphql(description = "Date and time at which the location record was last updated")] - pub fn updated_at(&self) -> Timestamp { - self.updated_at + pub fn author_orcid(&self) -> Option<&Orcid> { + self.author_orcid.as_ref() } - #[graphql(description = "Get the publication linked to this location")] - pub fn publication(&self, context: &Context) -> FieldResult { - Publication::from_id(&context.db, &self.publication_id).map_err(|e| e.into()) + #[graphql(description = "Thoth ID of the endorsement author's institution")] + pub fn author_institution_id(&self) -> Option<&Uuid> { + self.author_institution_id.as_ref() } -} -#[juniper::graphql_object(Context = Context, description = "The amount of money, in any currency, that a publication costs.")] -impl Price { - #[graphql(description = "Thoth ID of the price")] - pub fn price_id(&self) -> Uuid { - self.price_id + #[graphql(description = "URL associated with this endorsement")] + pub fn url(&self) -> Option<&String> { + self.url.as_ref() } - #[graphql(description = "Thoth ID of the publication linked to this price")] - pub fn publication_id(&self) -> Uuid { - self.publication_id + #[graphql(description = "Text of the endorsement")] + pub fn text( + &self, + #[graphql( + default = MarkupFormat::JatsXml, + description = "Markup format used for rendering endorsement text", + )] + markup_format: Option, + ) -> FieldResult> { + self.text + .as_ref() + .map(|text| { + convert_from_jats( + text, + markup_format.ok_or(ThothError::MissingMarkupFormat)?, + ConversionLimit::Abstract, + ) + }) + .transpose() + .map_err(Into::into) } #[graphql( - description = "Three-letter ISO 4217 code representing the currency used in this price" + description = "Number representing this endorsement's position in an ordered list of endorsements within the work" )] - pub fn currency_code(&self) -> &CurrencyCode { - &self.currency_code - } - - #[graphql(description = "Value of the publication in the specified currency")] - pub fn unit_price(&self) -> f64 { - self.unit_price + pub fn endorsement_ordinal(&self) -> i32 { + self.endorsement_ordinal } - #[graphql(description = "Date and time at which the price record was created")] + #[graphql(description = "Date and time at which the endorsement record was created")] pub fn created_at(&self) -> Timestamp { self.created_at } - #[graphql(description = "Date and time at which the price record was last updated")] + #[graphql(description = "Date and time at which the endorsement record was last updated")] pub fn updated_at(&self) -> Timestamp { self.updated_at } - #[graphql(description = "Get the publication linked to this price")] - pub fn publication(&self, context: &Context) -> FieldResult { - Publication::from_id(&context.db, &self.publication_id).map_err(|e| e.into()) + #[graphql(description = "Get the work linked to this endorsement")] + pub fn work(&self, context: &Context) -> FieldResult { + Work::from_id(&context.db, &self.work_id).map_err(Into::into) + } + + #[graphql(description = "Get the endorsement author's institution")] + pub fn author_institution(&self, context: &Context) -> FieldResult> { + self.author_institution_id + .as_ref() + .map(|institution_id| Institution::from_id(&context.db, institution_id)) + .transpose() + .map_err(Into::into) } } -#[juniper::graphql_object(Context = Context, description = "A significant discipline or term related to a work.")] -impl Subject { - #[graphql(description = "Thoth ID of the subject")] - pub fn subject_id(&self) -> &Uuid { - &self.subject_id +#[juniper::graphql_object( + Context = Context, + description = "A review of a work." +)] +impl BookReview { + #[graphql(description = "Thoth ID of the book review")] + pub fn book_review_id(&self) -> Uuid { + self.book_review_id } - #[graphql(description = "Thoth ID of the work to which the subject is linked")] - pub fn work_id(&self) -> &Uuid { - &self.work_id + #[graphql(description = "Thoth ID of the work to which this review belongs")] + pub fn work_id(&self) -> Uuid { + self.work_id } - #[graphql(description = "Type of the subject (e.g. the subject category scheme being used)")] - pub fn subject_type(&self) -> &SubjectType { - &self.subject_type + #[graphql(description = "Title of the review")] + pub fn title( + &self, + #[graphql( + default = MarkupFormat::JatsXml, + description = "Markup format used for rendering review title", + )] + markup_format: Option, + ) -> FieldResult> { + self.title + .as_ref() + .map(|title| { + convert_from_jats( + title, + markup_format.ok_or(ThothError::MissingMarkupFormat)?, + ConversionLimit::Title, + ) + }) + .transpose() + .map_err(Into::into) } - #[graphql(description = "Code representing the subject within the specified type")] - pub fn subject_code(&self) -> &String { - &self.subject_code + #[graphql(description = "Name of the review author")] + pub fn author_name(&self) -> Option<&String> { + self.author_name.as_ref() } #[graphql( - description = "Number representing this subject's position in an ordered list of subjects of the same type within the work (subjects of equal prominence can have the same number)" + description = "ORCID (Open Researcher and Contributor ID) of the reviewer as full URL, using the HTTPS scheme and the orcid.org domain" )] - pub fn subject_ordinal(&self) -> &i32 { - &self.subject_ordinal + pub fn reviewer_orcid(&self) -> Option<&Orcid> { + self.reviewer_orcid.as_ref() } - #[graphql(description = "Date and time at which the subject record was created")] - pub fn created_at(&self) -> Timestamp { - self.created_at + #[graphql(description = "Thoth ID of the reviewer's institution")] + pub fn reviewer_institution_id(&self) -> Option<&Uuid> { + self.reviewer_institution_id.as_ref() } - #[graphql(description = "Date and time at which the subject record was last updated")] - pub fn updated_at(&self) -> Timestamp { - self.updated_at + #[graphql(description = "URL of the review publication")] + pub fn url(&self) -> Option<&String> { + self.url.as_ref() } - #[graphql(description = "Get the work to which the subject is linked")] - pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + #[graphql( + description = "DOI of the review as full URL, using the HTTPS scheme and the doi.org domain" + )] + pub fn doi(&self) -> Option<&Doi> { + self.doi.as_ref() } -} -#[juniper::graphql_object(Context = Context, description = "An organisation with which contributors may be affiliated or by which works may be funded.")] -impl Institution { - #[graphql(description = "Thoth ID of the institution")] - pub fn institution_id(&self) -> &Uuid { - &self.institution_id + #[graphql(description = "Publication date of the review")] + pub fn review_date(&self) -> Option { + self.review_date } - #[graphql(description = "Name of the institution")] - pub fn institution_name(&self) -> &String { - &self.institution_name + #[graphql(description = "Name of the journal where the review was published")] + pub fn journal_name(&self) -> Option<&String> { + self.journal_name.as_ref() } - #[graphql( - description = "Digital Object Identifier of the organisation as full URL, using the HTTPS scheme and the doi.org domain (e.g. https://doi.org/10.13039/100014013)" - )] - pub fn institution_doi(&self) -> Option<&Doi> { - self.institution_doi.as_ref() + #[graphql(description = "Volume of the journal where the review was published")] + pub fn journal_volume(&self) -> Option<&String> { + self.journal_volume.as_ref() } - #[graphql( - description = "Three-letter ISO 3166-1 code representing the country where this institution is based" - )] - pub fn country_code(&self) -> Option<&CountryCode> { - self.country_code.as_ref() + #[graphql(description = "Number of the journal where the review was published")] + pub fn journal_number(&self) -> Option<&String> { + self.journal_number.as_ref() + } + + #[graphql(description = "ISSN of the journal where the review was published")] + pub fn journal_issn(&self) -> Option<&String> { + self.journal_issn.as_ref() + } + + #[graphql(description = "Page range of the review")] + pub fn page_range(&self) -> Option<&String> { + self.page_range.as_ref() + } + + #[graphql(description = "Text of the review")] + pub fn text( + &self, + #[graphql( + default = MarkupFormat::JatsXml, + description = "Markup format used for rendering review text", + )] + markup_format: Option, + ) -> FieldResult> { + self.text + .as_ref() + .map(|text| { + convert_from_jats( + text, + markup_format.ok_or(ThothError::MissingMarkupFormat)?, + ConversionLimit::Abstract, + ) + }) + .transpose() + .map_err(Into::into) } #[graphql( - description = "Research Organisation Registry identifier of the organisation as full URL, using the HTTPS scheme and the ror.org domain (e.g. https://ror.org/051z6e826)" + description = "Number representing this review's position in an ordered list of reviews within the work" )] - pub fn ror(&self) -> Option<&Ror> { - self.ror.as_ref() + pub fn review_ordinal(&self) -> i32 { + self.review_ordinal } - #[graphql(description = "Date and time at which the institution record was created")] + #[graphql(description = "Date and time at which the review record was created")] pub fn created_at(&self) -> Timestamp { self.created_at } - #[graphql(description = "Date and time at which the institution record was last updated")] + #[graphql(description = "Date and time at which the review record was last updated")] pub fn updated_at(&self) -> Timestamp { self.updated_at } - #[graphql(description = "Get fundings linked to this institution")] - pub fn fundings( - &self, - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = FundingOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - ) -> FieldResult> { - Funding::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - None, - order.unwrap_or_default(), - vec![], - None, - Some(self.institution_id), - vec![], - vec![], - None, - ) - .map_err(|e| e.into()) + #[graphql(description = "Get the work linked to this review")] + pub fn work(&self, context: &Context) -> FieldResult { + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } - #[graphql(description = "Get affiliations linked to this institution")] - pub fn affiliations( - &self, - context: &Context, - #[graphql(default = 100, description = "The number of items to return")] limit: Option, - #[graphql(default = 0, description = "The number of items to skip")] offset: Option, - #[graphql( - default = AffiliationOrderBy::default(), - description = "The order in which to sort the results" - )] - order: Option, - ) -> FieldResult> { - Affiliation::all( - &context.db, - limit.unwrap_or_default(), - offset.unwrap_or_default(), - None, - order.unwrap_or_default(), - vec![], - Some(self.institution_id), - None, - vec![], - vec![], - None, - ) - .map_err(|e| e.into()) + #[graphql(description = "Get the reviewer's institution")] + pub fn reviewer_institution(&self, context: &Context) -> FieldResult> { + self.reviewer_institution_id + .as_ref() + .map(|institution_id| Institution::from_id(&context.db, institution_id)) + .transpose() + .map_err(Into::into) } } -#[juniper::graphql_object(Context = Context, description = "A grant awarded for the publication of a work by an institution.")] -impl Funding { - #[graphql(description = "Thoth ID of the funding")] - pub fn funding_id(&self) -> &Uuid { - &self.funding_id - } - - #[graphql(description = "Thoth ID of the funded work")] - pub fn work_id(&self) -> &Uuid { - &self.work_id - } - - #[graphql(description = "Thoth ID of the funding institution")] - pub fn institution_id(&self) -> &Uuid { - &self.institution_id +#[juniper::graphql_object( + Context = Context, + description = "A featured video linked to a work." +)] +impl WorkFeaturedVideo { + #[graphql(description = "Thoth ID of the featured video")] + pub fn work_featured_video_id(&self) -> Uuid { + self.work_featured_video_id } - #[graphql(description = "Name of the funding program")] - pub fn program(&self) -> Option<&String> { - self.program.as_ref() + #[graphql(description = "Thoth ID of the work to which this featured video belongs")] + pub fn work_id(&self) -> Uuid { + self.work_id } - #[graphql(description = "Name of the funding project")] - pub fn project_name(&self) -> Option<&String> { - self.project_name.as_ref() + #[graphql(description = "Title or caption of the featured video")] + pub fn title(&self) -> Option<&String> { + self.title.as_ref() } - #[graphql(description = "Short name of the funding project")] - pub fn project_shortname(&self) -> Option<&String> { - self.project_shortname.as_ref() + #[graphql(description = "CDN URL of the featured video")] + pub fn url(&self) -> Option<&String> { + self.url.as_ref() } - #[graphql(description = "Grant number of the award")] - pub fn grant_number(&self) -> Option<&String> { - self.grant_number.as_ref() + #[graphql(description = "Rendered width of the featured video embed")] + pub fn width(&self) -> i32 { + self.width } - #[graphql(description = "Jurisdiction of the award")] - pub fn jurisdiction(&self) -> Option<&String> { - self.jurisdiction.as_ref() + #[graphql(description = "Rendered height of the featured video embed")] + pub fn height(&self) -> i32 { + self.height } - #[graphql(description = "Date and time at which the funding record was created")] + #[graphql(description = "Date and time at which the featured video record was created")] pub fn created_at(&self) -> Timestamp { self.created_at } - #[graphql(description = "Date and time at which the funding record was last updated")] + #[graphql(description = "Date and time at which the featured video record was last updated")] pub fn updated_at(&self) -> Timestamp { self.updated_at } - #[graphql(description = "Get the funded work")] + #[graphql(description = "Get the work linked to this featured video")] pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } - #[graphql(description = "Get the funding institution")] - pub fn institution(&self, context: &Context) -> FieldResult { - Institution::from_id(&context.db, &self.institution_id).map_err(|e| e.into()) + #[graphql(description = "Get the hosted file linked to this featured video")] + pub fn file(&self, context: &Context) -> FieldResult> { + File::from_work_featured_video_id(&context.db, &self.work_featured_video_id) + .map_err(Into::into) } } @@ -3985,12 +2707,12 @@ impl Affiliation { #[graphql(description = "Get the institution linked to this affiliation")] pub fn institution(&self, context: &Context) -> FieldResult { - Institution::from_id(&context.db, &self.institution_id).map_err(|e| e.into()) + Institution::from_id(&context.db, &self.institution_id).map_err(Into::into) } #[graphql(description = "Get the contribution linked to this affiliation")] pub fn contribution(&self, context: &Context) -> FieldResult { - Contribution::from_id(&context.db, &self.contribution_id).map_err(|e| e.into()) + Contribution::from_id(&context.db, &self.contribution_id).map_err(Into::into) } } @@ -4035,7 +2757,7 @@ impl WorkRelation { #[graphql(description = "Get the other work in the relationship")] pub fn related_work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.related_work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.related_work_id).map_err(Into::into) } } @@ -4185,34 +2907,158 @@ impl Reference { #[graphql(description = "The citing work.")] pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } } -pub type Schema = RootNode<'static, QueryRoot, MutationRoot, EmptySubscription>; +#[juniper::graphql_object(Context = Context, description = "A title associated with a work.")] +impl Title { + #[graphql(description = "Thoth ID of the title")] + pub fn title_id(&self) -> Uuid { + self.title_id + } -pub fn create_schema() -> Schema { - Schema::new(QueryRoot {}, MutationRoot {}, EmptySubscription::new()) -} + #[graphql(description = "Thoth ID of the work to which the title is linked")] + pub fn work_id(&self) -> Uuid { + self.work_id + } + + #[graphql(description = "Locale code of the title")] + pub fn locale_code(&self) -> &LocaleCode { + &self.locale_code + } + + #[graphql(description = "Full title including subtitle")] + pub fn full_title(&self) -> &String { + &self.full_title + } + + #[graphql(description = "Main title (excluding subtitle)")] + pub fn title(&self) -> &String { + &self.title + } + + #[graphql(description = "Subtitle of the work")] + pub fn subtitle(&self) -> Option<&String> { + self.subtitle.as_ref() + } + + #[graphql(description = "Whether this is the canonical title for the work")] + pub fn canonical(&self) -> bool { + self.canonical + } -fn publisher_id_from_imprint_id(db: &crate::db::PgPool, imprint_id: Uuid) -> ThothResult { - Ok(Imprint::from_id(db, &imprint_id)?.publisher_id) + #[graphql(description = "Get the work to which the title is linked")] + pub fn work(&self, context: &Context) -> FieldResult { + Work::from_id(&context.db, &self.work_id).map_err(Into::into) + } } -fn publisher_id_from_work_id(db: &crate::db::PgPool, work_id: Uuid) -> ThothResult { - Work::from_id(db, &work_id)?.publisher_id(db) +#[juniper::graphql_object(Context = Context, description = "An abstract associated with a work.")] +impl Abstract { + #[graphql(description = "Thoth ID of the abstract")] + pub fn abstract_id(&self) -> Uuid { + self.abstract_id + } + #[graphql(description = "Thoth ID of the work to which the abstract is linked")] + pub fn work_id(&self) -> Uuid { + self.work_id + } + #[graphql(description = "Locale code of the abstract")] + pub fn locale_code(&self) -> &LocaleCode { + &self.locale_code + } + #[graphql(description = "Content of the abstract")] + pub fn content(&self) -> &String { + &self.content + } + #[graphql(description = "Whether this is the canonical abstract for the work")] + pub fn canonical(&self) -> bool { + self.canonical + } + #[graphql(description = "Type of the abstract")] + pub fn abstract_type(&self) -> &AbstractType { + &self.abstract_type + } + #[graphql(description = "Get the work to which the abstract is linked")] + pub fn work(&self, context: &Context) -> FieldResult { + Work::from_id(&context.db, &self.work_id).map_err(Into::into) + } } -fn publisher_id_from_publication_id( - db: &crate::db::PgPool, - publication_id: Uuid, -) -> ThothResult { - Publication::from_id(db, &publication_id)?.publisher_id(db) +#[juniper::graphql_object(Context = Context, description = "A biography associated with a work and contribution.")] +impl Biography { + #[graphql(description = "Thoth ID of the biography")] + pub fn biography_id(&self) -> Uuid { + self.biography_id + } + + #[graphql(description = "Thoth ID of the contribution to which the biography is linked")] + pub fn contribution_id(&self) -> Uuid { + self.contribution_id + } + + #[graphql(description = "Locale code of the biography")] + pub fn locale_code(&self) -> &LocaleCode { + &self.locale_code + } + + #[graphql(description = "Content of the biography")] + pub fn content(&self) -> &String { + &self.content + } + + #[graphql(description = "Whether this is the canonical biography for the contribution/work")] + pub fn canonical(&self) -> bool { + self.canonical + } + + #[graphql(description = "Get the work to which the biography is linked via contribution")] + pub fn work(&self, context: &Context) -> FieldResult { + let contribution = Contribution::from_id(&context.db, &self.contribution_id)?; + Work::from_id(&context.db, &contribution.work_id).map_err(Into::into) + } + + #[graphql(description = "Get the contribution to which the biography is linked")] + pub fn contribution(&self, context: &Context) -> FieldResult { + Contribution::from_id(&context.db, &self.contribution_id).map_err(Into::into) + } } -fn publisher_id_from_contribution_id( - db: &crate::db::PgPool, - contribution_id: Uuid, -) -> ThothResult { - Contribution::from_id(db, &contribution_id)?.publisher_id(db) +#[juniper::graphql_object(Context = Context, description = "A way to get in touch with a publisher.")] +impl Contact { + #[graphql(description = "Thoth ID of the contact")] + pub fn contact_id(&self) -> Uuid { + self.contact_id + } + + #[graphql(description = "Thoth ID of the publisher to which this contact belongs")] + pub fn publisher_id(&self) -> Uuid { + self.publisher_id + } + + #[graphql(description = "Type of the contact")] + pub fn contact_type(&self) -> &ContactType { + &self.contact_type + } + + #[graphql(description = "Email address of the contact")] + pub fn email(&self) -> &String { + &self.email + } + + #[graphql(description = "Date and time at which the contact record was created")] + pub fn created_at(&self) -> Timestamp { + self.created_at + } + + #[graphql(description = "Date and time at which the contact record was last updated")] + pub fn updated_at(&self) -> Timestamp { + self.updated_at + } + + #[graphql(description = "Get the publisher to which this contact belongs")] + pub fn publisher(&self, context: &Context) -> FieldResult { + Publisher::from_id(&context.db, &self.publisher_id).map_err(Into::into) + } } diff --git a/thoth-api/src/graphql/mutation.rs b/thoth-api/src/graphql/mutation.rs new file mode 100644 index 00000000..47b5f212 --- /dev/null +++ b/thoth-api/src/graphql/mutation.rs @@ -0,0 +1,1469 @@ +use juniper::FieldResult; +use uuid::Uuid; + +use crate::graphql::Context; +use crate::markup::{convert_to_jats, ConversionLimit, MarkupFormat}; +use crate::model::{ + additional_resource::{ + AdditionalResource, AdditionalResourcePolicy, NewAdditionalResource, + PatchAdditionalResource, ResourceType, + }, + affiliation::{Affiliation, AffiliationPolicy, NewAffiliation, PatchAffiliation}, + award::{Award, AwardPolicy, NewAward, PatchAward}, + biography::{Biography, BiographyPolicy, NewBiography, PatchBiography}, + book_review::{BookReview, BookReviewPolicy, NewBookReview, PatchBookReview}, + contact::{Contact, ContactPolicy, NewContact, PatchContact}, + contribution::{Contribution, ContributionPolicy, NewContribution, PatchContribution}, + contributor::{Contributor, ContributorPolicy, NewContributor, PatchContributor}, + endorsement::{Endorsement, EndorsementPolicy, NewEndorsement, PatchEndorsement}, + file::{ + CompleteFileUpload, File, FilePolicy, FileUpload, FileUploadResponse, + NewAdditionalResourceFileUpload, NewFileUpload, NewFrontcoverFileUpload, + NewPublicationFileUpload, NewWorkFeaturedVideoFileUpload, + }, + funding::{Funding, FundingPolicy, NewFunding, PatchFunding}, + imprint::{Imprint, ImprintPolicy, NewImprint, PatchImprint}, + institution::{Institution, InstitutionPolicy, NewInstitution, PatchInstitution}, + issue::{Issue, IssuePolicy, NewIssue, PatchIssue}, + language::{Language, LanguagePolicy, NewLanguage, PatchLanguage}, + location::{Location, LocationPolicy, NewLocation, PatchLocation}, + price::{NewPrice, PatchPrice, Price, PricePolicy}, + publication::{NewPublication, PatchPublication, Publication, PublicationPolicy}, + publisher::{NewPublisher, PatchPublisher, Publisher, PublisherPolicy}, + r#abstract::{Abstract, AbstractPolicy, NewAbstract, PatchAbstract}, + reference::{NewReference, PatchReference, Reference, ReferencePolicy}, + series::{NewSeries, PatchSeries, Series, SeriesPolicy}, + subject::{NewSubject, PatchSubject, Subject, SubjectPolicy}, + title::{convert_title_to_jats, NewTitle, PatchTitle, Title, TitlePolicy}, + work::{NewWork, PatchWork, Work, WorkPolicy}, + work_featured_video::{ + NewWorkFeaturedVideo, PatchWorkFeaturedVideo, WorkFeaturedVideo, WorkFeaturedVideoPolicy, + }, + work_relation::{NewWorkRelation, PatchWorkRelation, WorkRelation, WorkRelationPolicy}, + Crud, Reorder, +}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use crate::storage::{ + additional_resource_cleanup_plan, build_cdn_url, copy_temp_object_to_final, delete_object, + head_object, probe_video_dimensions, publication_cleanup_plan, reconcile_replaced_object, + run_cleanup_plan_sync, temp_key, work_cleanup_plan, work_featured_video_cleanup_plan, + StorageConfig, +}; +use thoth_errors::ThothError; + +pub struct MutationRoot; + +#[juniper::graphql_object(Context = Context)] +impl MutationRoot { + #[graphql(description = "Create a new work with the specified values")] + fn create_work( + context: &Context, + #[graphql(description = "Values for work to be created")] data: NewWork, + ) -> FieldResult { + WorkPolicy::can_create(context, &data, ())?; + Work::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new publisher with the specified values")] + fn create_publisher( + context: &Context, + #[graphql(description = "Values for publisher to be created")] data: NewPublisher, + ) -> FieldResult { + PublisherPolicy::can_create(context, &data, ())?; + Publisher::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new imprint with the specified values")] + fn create_imprint( + context: &Context, + #[graphql(description = "Values for imprint to be created")] data: NewImprint, + ) -> FieldResult { + ImprintPolicy::can_create(context, &data, ())?; + Imprint::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new contributor with the specified values")] + fn create_contributor( + context: &Context, + #[graphql(description = "Values for contributor to be created")] data: NewContributor, + ) -> FieldResult { + ContributorPolicy::can_create(context, &data, ())?; + Contributor::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new contribution with the specified values")] + fn create_contribution( + context: &Context, + #[graphql(description = "Values for contribution to be created")] data: NewContribution, + ) -> FieldResult { + ContributionPolicy::can_create(context, &data, ())?; + Contribution::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new publication with the specified values")] + fn create_publication( + context: &Context, + #[graphql(description = "Values for publication to be created")] data: NewPublication, + ) -> FieldResult { + PublicationPolicy::can_create(context, &data, ())?; + Publication::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new series with the specified values")] + fn create_series( + context: &Context, + #[graphql(description = "Values for series to be created")] data: NewSeries, + ) -> FieldResult { + SeriesPolicy::can_create(context, &data, ())?; + Series::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new issue with the specified values")] + fn create_issue( + context: &Context, + #[graphql(description = "Values for issue to be created")] data: NewIssue, + ) -> FieldResult { + IssuePolicy::can_create(context, &data, ())?; + Issue::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new language with the specified values")] + fn create_language( + context: &Context, + #[graphql(description = "Values for language to be created")] data: NewLanguage, + ) -> FieldResult { + LanguagePolicy::can_create(context, &data, ())?; + Language::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new title with the specified values")] + fn create_title( + context: &Context, + #[graphql(description = "The markup format of the title")] markup_format: Option< + MarkupFormat, + >, + #[graphql(description = "Values for title to be created")] mut data: NewTitle, + ) -> FieldResult { + TitlePolicy::can_create(context, &data, markup_format)?; + + let markup = markup_format.expect("Validated by policy"); + convert_title_to_jats(&mut data, markup)?; + + Title::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new abstract with the specified values")] + fn create_abstract( + context: &Context, + #[graphql(description = "The markup format of the abstract")] markup_format: Option< + MarkupFormat, + >, + #[graphql(description = "Values for abstract to be created")] mut data: NewAbstract, + ) -> FieldResult<Abstract> { + AbstractPolicy::can_create(context, &data, markup_format)?; + + let markup = markup_format.expect("Validated by policy"); + data.content = convert_to_jats(data.content, markup, ConversionLimit::Abstract)?; + + Abstract::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new biography with the specified values")] + fn create_biography( + context: &Context, + #[graphql(description = "The markup format of the biography")] markup_format: Option< + MarkupFormat, + >, + #[graphql(description = "Values for biography to be created")] mut data: NewBiography, + ) -> FieldResult<Biography> { + BiographyPolicy::can_create(context, &data, markup_format)?; + + let markup = markup_format.expect("Validated by policy"); + data.content = convert_to_jats(data.content, markup, ConversionLimit::Biography)?; + + Biography::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new institution with the specified values")] + fn create_institution( + context: &Context, + #[graphql(description = "Values for institution to be created")] data: NewInstitution, + ) -> FieldResult<Institution> { + InstitutionPolicy::can_create(context, &data, ())?; + Institution::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new funding with the specified values")] + fn create_funding( + context: &Context, + #[graphql(description = "Values for funding to be created")] data: NewFunding, + ) -> FieldResult<Funding> { + FundingPolicy::can_create(context, &data, ())?; + Funding::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new location with the specified values")] + fn create_location( + context: &Context, + #[graphql(description = "Values for location to be created")] data: NewLocation, + ) -> FieldResult<Location> { + LocationPolicy::can_create(context, &data, ())?; + Location::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new price with the specified values")] + fn create_price( + context: &Context, + #[graphql(description = "Values for price to be created")] data: NewPrice, + ) -> FieldResult<Price> { + PricePolicy::can_create(context, &data, ())?; + Price::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new subject with the specified values")] + fn create_subject( + context: &Context, + #[graphql(description = "Values for subject to be created")] data: NewSubject, + ) -> FieldResult<Subject> { + SubjectPolicy::can_create(context, &data, ())?; + Subject::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new affiliation with the specified values")] + fn create_affiliation( + context: &Context, + #[graphql(description = "Values for affiliation to be created")] data: NewAffiliation, + ) -> FieldResult<Affiliation> { + AffiliationPolicy::can_create(context, &data, ())?; + Affiliation::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new work relation with the specified values")] + fn create_work_relation( + context: &Context, + #[graphql(description = "Values for work relation to be created")] data: NewWorkRelation, + ) -> FieldResult<WorkRelation> { + WorkRelationPolicy::can_create(context, &data, ())?; + WorkRelation::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new reference with the specified values")] + fn create_reference( + context: &Context, + #[graphql(description = "Values for reference to be created")] data: NewReference, + ) -> FieldResult<Reference> { + ReferencePolicy::can_create(context, &data, ())?; + Reference::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new additional resource with the specified values")] + fn create_additional_resource( + context: &Context, + #[graphql(description = "The markup format of the additional resource text fields")] + markup_format: Option<MarkupFormat>, + #[graphql(description = "Values for additional resource to be created")] + mut data: NewAdditionalResource, + ) -> FieldResult<AdditionalResource> { + AdditionalResourcePolicy::can_create(context, &data, ())?; + + let markup = markup_format.unwrap_or(MarkupFormat::JatsXml); + data.title = convert_to_jats(data.title, markup, ConversionLimit::Title)?; + data.description = data + .description + .map(|description| convert_to_jats(description, markup, ConversionLimit::Abstract)) + .transpose()?; + + AdditionalResource::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new award with the specified values")] + fn create_award( + context: &Context, + #[graphql(description = "The markup format of the award text fields")] + markup_format: Option<MarkupFormat>, + #[graphql(description = "Values for award to be created")] mut data: NewAward, + ) -> FieldResult<Award> { + AwardPolicy::can_create(context, &data, ())?; + + let markup = markup_format.unwrap_or(MarkupFormat::JatsXml); + data.title = convert_to_jats(data.title, markup, ConversionLimit::Title)?; + data.prize_statement = data + .prize_statement + .map(|prize_statement| { + convert_to_jats(prize_statement, markup, ConversionLimit::Abstract) + }) + .transpose()?; + + Award::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new endorsement with the specified values")] + fn create_endorsement( + context: &Context, + #[graphql(description = "The markup format of the endorsement text field")] + markup_format: Option<MarkupFormat>, + #[graphql(description = "Values for endorsement to be created")] mut data: NewEndorsement, + ) -> FieldResult<Endorsement> { + EndorsementPolicy::can_create(context, &data, ())?; + + let markup = markup_format.unwrap_or(MarkupFormat::JatsXml); + data.text = data + .text + .map(|text| convert_to_jats(text, markup, ConversionLimit::Abstract)) + .transpose()?; + + Endorsement::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new book review with the specified values")] + fn create_book_review( + context: &Context, + #[graphql(description = "The markup format of the book review text field")] + markup_format: Option<MarkupFormat>, + #[graphql(description = "Values for book review to be created")] mut data: NewBookReview, + ) -> FieldResult<BookReview> { + BookReviewPolicy::can_create(context, &data, ())?; + + let markup = markup_format.unwrap_or(MarkupFormat::JatsXml); + data.title = data + .title + .map(|title| convert_to_jats(title, markup, ConversionLimit::Title)) + .transpose()?; + data.text = data + .text + .map(|text| convert_to_jats(text, markup, ConversionLimit::Abstract)) + .transpose()?; + + BookReview::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new featured video with the specified values")] + fn create_work_featured_video( + context: &Context, + #[graphql(description = "Values for featured video to be created")] + data: NewWorkFeaturedVideo, + ) -> FieldResult<WorkFeaturedVideo> { + WorkFeaturedVideoPolicy::can_create(context, &data, ())?; + WorkFeaturedVideo::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Create a new contact with the specified values")] + fn create_contact( + context: &Context, + #[graphql(description = "Values for contact to be created")] data: NewContact, + ) -> FieldResult<Contact> { + ContactPolicy::can_create(context, &data, ())?; + Contact::create(&context.db, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing work with the specified values")] + fn update_work( + context: &Context, + #[graphql(description = "Values to apply to existing work")] data: PatchWork, + ) -> FieldResult<Work> { + let work = context.load_current(&data.work_id)?; + WorkPolicy::can_update(context, &work, &data, ())?; + + // update the work and, if it succeeds, synchronise its children statuses and pub. date + let w = work.update(context, &data)?; + for child in work.children(&context.db)? { + if child.publication_date != w.publication_date + || child.work_status != w.work_status + || child.withdrawn_date != w.withdrawn_date + { + let mut data: PatchWork = child.clone().into(); + data.publication_date = w.publication_date; + data.withdrawn_date = w.withdrawn_date; + data.work_status = w.work_status; + child.update(context, &data)?; + } + } + Ok(w) + } + + #[graphql(description = "Update an existing publisher with the specified values")] + fn update_publisher( + context: &Context, + #[graphql(description = "Values to apply to existing publisher")] data: PatchPublisher, + ) -> FieldResult<Publisher> { + let publisher = context.load_current(&data.publisher_id)?; + PublisherPolicy::can_update(context, &publisher, &data, ())?; + + publisher.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing imprint with the specified values")] + fn update_imprint( + context: &Context, + #[graphql(description = "Values to apply to existing imprint")] data: PatchImprint, + ) -> FieldResult<Imprint> { + let imprint = context.load_current(&data.imprint_id)?; + ImprintPolicy::can_update(context, &imprint, &data, ())?; + + imprint.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing contributor with the specified values")] + fn update_contributor( + context: &Context, + #[graphql(description = "Values to apply to existing contributor")] data: PatchContributor, + ) -> FieldResult<Contributor> { + let contributor = context.load_current(&data.contributor_id)?; + ContributorPolicy::can_update(context, &contributor, &data, ())?; + + contributor.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing contribution with the specified values")] + fn update_contribution( + context: &Context, + #[graphql(description = "Values to apply to existing contribution")] + data: PatchContribution, + ) -> FieldResult<Contribution> { + let contribution = context.load_current(&data.contribution_id)?; + ContributionPolicy::can_update(context, &contribution, &data, ())?; + + contribution.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing publication with the specified values")] + fn update_publication( + context: &Context, + #[graphql(description = "Values to apply to existing publication")] data: PatchPublication, + ) -> FieldResult<Publication> { + let publication = context.load_current(&data.publication_id)?; + PublicationPolicy::can_update(context, &publication, &data, ())?; + + publication.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing series with the specified values")] + fn update_series( + context: &Context, + #[graphql(description = "Values to apply to existing series")] data: PatchSeries, + ) -> FieldResult<Series> { + let series = context.load_current(&data.series_id)?; + SeriesPolicy::can_update(context, &series, &data, ())?; + + series.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing issue with the specified values")] + fn update_issue( + context: &Context, + #[graphql(description = "Values to apply to existing issue")] data: PatchIssue, + ) -> FieldResult<Issue> { + let issue = context.load_current(&data.issue_id)?; + IssuePolicy::can_update(context, &issue, &data, ())?; + + issue.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing language with the specified values")] + fn update_language( + context: &Context, + #[graphql(description = "Values to apply to existing language")] data: PatchLanguage, + ) -> FieldResult<Language> { + let language = context.load_current(&data.language_id)?; + LanguagePolicy::can_update(context, &language, &data, ())?; + + language.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing institution with the specified values")] + fn update_institution( + context: &Context, + #[graphql(description = "Values to apply to existing institution")] data: PatchInstitution, + ) -> FieldResult<Institution> { + let institution = context.load_current(&data.institution_id)?; + InstitutionPolicy::can_update(context, &institution, &data, ())?; + + institution.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing funding with the specified values")] + fn update_funding( + context: &Context, + #[graphql(description = "Values to apply to existing funding")] data: PatchFunding, + ) -> FieldResult<Funding> { + let funding = context.load_current(&data.funding_id)?; + FundingPolicy::can_update(context, &funding, &data, ())?; + + funding.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing location with the specified values")] + fn update_location( + context: &Context, + #[graphql(description = "Values to apply to existing location")] data: PatchLocation, + ) -> FieldResult<Location> { + let current_location = context.load_current(&data.location_id)?; + LocationPolicy::can_update(context, ¤t_location, &data, ())?; + + current_location.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing price with the specified values")] + fn update_price( + context: &Context, + #[graphql(description = "Values to apply to existing price")] data: PatchPrice, + ) -> FieldResult<Price> { + let price = context.load_current(&data.price_id)?; + PricePolicy::can_update(context, &price, &data, ())?; + + price.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing subject with the specified values")] + fn update_subject( + context: &Context, + #[graphql(description = "Values to apply to existing subject")] data: PatchSubject, + ) -> FieldResult<Subject> { + let subject = context.load_current(&data.subject_id)?; + SubjectPolicy::can_update(context, &subject, &data, ())?; + + subject.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing affiliation with the specified values")] + fn update_affiliation( + context: &Context, + #[graphql(description = "Values to apply to existing affiliation")] data: PatchAffiliation, + ) -> FieldResult<Affiliation> { + let affiliation = context.load_current(&data.affiliation_id)?; + AffiliationPolicy::can_update(context, &affiliation, &data, ())?; + + affiliation.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing work relation with the specified values")] + fn update_work_relation( + context: &Context, + #[graphql(description = "Values to apply to existing work relation")] + data: PatchWorkRelation, + ) -> FieldResult<WorkRelation> { + let work_relation = context.load_current(&data.work_relation_id)?; + WorkRelationPolicy::can_update(context, &work_relation, &data, ())?; + + work_relation.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing reference with the specified values")] + fn update_reference( + context: &Context, + #[graphql(description = "Values to apply to existing reference")] data: PatchReference, + ) -> FieldResult<Reference> { + let reference = context.load_current(&data.reference_id)?; + ReferencePolicy::can_update(context, &reference, &data, ())?; + + reference.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing additional resource with the specified values")] + fn update_additional_resource( + context: &Context, + #[graphql(description = "The markup format of the additional resource text fields")] + markup_format: Option<MarkupFormat>, + #[graphql(description = "Values to apply to existing additional resource")] + mut data: PatchAdditionalResource, + ) -> FieldResult<AdditionalResource> { + let additional_resource = context.load_current(&data.additional_resource_id)?; + AdditionalResourcePolicy::can_update(context, &additional_resource, &data, ())?; + + let markup = markup_format.unwrap_or(MarkupFormat::JatsXml); + data.title = convert_to_jats(data.title, markup, ConversionLimit::Title)?; + data.description = data + .description + .map(|description| convert_to_jats(description, markup, ConversionLimit::Abstract)) + .transpose()?; + + additional_resource + .update(context, &data) + .map_err(Into::into) + } + + #[graphql(description = "Update an existing award with the specified values")] + fn update_award( + context: &Context, + #[graphql(description = "The markup format of the award text fields")] + markup_format: Option<MarkupFormat>, + #[graphql(description = "Values to apply to existing award")] mut data: PatchAward, + ) -> FieldResult<Award> { + let award = context.load_current(&data.award_id)?; + AwardPolicy::can_update(context, &award, &data, ())?; + + let markup = markup_format.unwrap_or(MarkupFormat::JatsXml); + data.title = convert_to_jats(data.title, markup, ConversionLimit::Title)?; + data.prize_statement = data + .prize_statement + .map(|prize_statement| { + convert_to_jats(prize_statement, markup, ConversionLimit::Abstract) + }) + .transpose()?; + + award.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing endorsement with the specified values")] + fn update_endorsement( + context: &Context, + #[graphql(description = "The markup format of the endorsement text field")] + markup_format: Option<MarkupFormat>, + #[graphql(description = "Values to apply to existing endorsement")] + mut data: PatchEndorsement, + ) -> FieldResult<Endorsement> { + let endorsement = context.load_current(&data.endorsement_id)?; + EndorsementPolicy::can_update(context, &endorsement, &data, ())?; + + let markup = markup_format.unwrap_or(MarkupFormat::JatsXml); + data.text = data + .text + .map(|text| convert_to_jats(text, markup, ConversionLimit::Abstract)) + .transpose()?; + + endorsement.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing book review with the specified values")] + fn update_book_review( + context: &Context, + #[graphql(description = "The markup format of the book review text field")] + markup_format: Option<MarkupFormat>, + #[graphql(description = "Values to apply to existing book review")] + mut data: PatchBookReview, + ) -> FieldResult<BookReview> { + let book_review = context.load_current(&data.book_review_id)?; + BookReviewPolicy::can_update(context, &book_review, &data, ())?; + + let markup = markup_format.unwrap_or(MarkupFormat::JatsXml); + data.title = data + .title + .map(|title| convert_to_jats(title, markup, ConversionLimit::Title)) + .transpose()?; + data.text = data + .text + .map(|text| convert_to_jats(text, markup, ConversionLimit::Abstract)) + .transpose()?; + + book_review.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing featured video with the specified values")] + fn update_work_featured_video( + context: &Context, + #[graphql(description = "Values to apply to existing featured video")] + data: PatchWorkFeaturedVideo, + ) -> FieldResult<WorkFeaturedVideo> { + let work_featured_video = context.load_current(&data.work_featured_video_id)?; + WorkFeaturedVideoPolicy::can_update(context, &work_featured_video, &data, ())?; + + work_featured_video + .update(context, &data) + .map_err(Into::into) + } + + #[graphql(description = "Update an existing contact with the specified values")] + fn update_contact( + context: &Context, + #[graphql(description = "Values to apply to existing contact")] data: PatchContact, + ) -> FieldResult<Contact> { + let contact = context.load_current(&data.contact_id)?; + ContactPolicy::can_update(context, &contact, &data, ())?; + + contact.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing title with the specified values")] + fn update_title( + context: &Context, + #[graphql(description = "The markup format of the title")] markup_format: Option< + MarkupFormat, + >, + #[graphql(description = "Values to apply to existing title")] mut data: PatchTitle, + ) -> FieldResult<Title> { + let title = context.load_current(&data.title_id)?; + TitlePolicy::can_update(context, &title, &data, markup_format)?; + + let markup = markup_format.expect("Validated by policy"); + convert_title_to_jats(&mut data, markup)?; + + title.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing abstract with the specified values")] + fn update_abstract( + context: &Context, + #[graphql(description = "The markup format of the abstract")] markup_format: Option< + MarkupFormat, + >, + #[graphql(description = "Values to apply to existing abstract")] mut data: PatchAbstract, + ) -> FieldResult<Abstract> { + let r#abstract = context.load_current(&data.abstract_id)?; + AbstractPolicy::can_update(context, &r#abstract, &data, markup_format)?; + + let markup = markup_format.expect("Validated by policy"); + data.content = convert_to_jats(data.content, markup, ConversionLimit::Abstract)?; + + r#abstract.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Update an existing biography with the specified values")] + fn update_biography( + context: &Context, + #[graphql(description = "The markup format of the biography")] markup_format: Option< + MarkupFormat, + >, + #[graphql(description = "Values to apply to existing biography")] mut data: PatchBiography, + ) -> FieldResult<Biography> { + let biography = context.load_current(&data.biography_id)?; + BiographyPolicy::can_update(context, &biography, &data, markup_format)?; + + let markup = markup_format.expect("Validated by policy"); + data.content = convert_to_jats(data.content, markup, ConversionLimit::Biography)?; + + biography.update(context, &data).map_err(Into::into) + } + + #[graphql(description = "Delete a single work using its ID")] + fn delete_work( + context: &Context, + #[graphql(description = "Thoth ID of work to be deleted")] work_id: Uuid, + ) -> FieldResult<Work> { + let work = context.load_current(&work_id)?; + WorkPolicy::can_delete(context, &work)?; + let cleanup_plan = work_cleanup_plan(context.db(), &work)?; + + let deleted_work = work.delete(&context.db)?; + if let Some(plan) = cleanup_plan { + run_cleanup_plan_sync(context.s3_client(), context.cloudfront_client(), plan); + } + + Ok(deleted_work) + } + + #[graphql(description = "Delete a single publisher using its ID")] + fn delete_publisher( + context: &Context, + #[graphql(description = "Thoth ID of publisher to be deleted")] publisher_id: Uuid, + ) -> FieldResult<Publisher> { + let publisher = context.load_current(&publisher_id)?; + PublisherPolicy::can_delete(context, &publisher)?; + + publisher.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single imprint using its ID")] + fn delete_imprint( + context: &Context, + #[graphql(description = "Thoth ID of imprint to be deleted")] imprint_id: Uuid, + ) -> FieldResult<Imprint> { + let imprint = context.load_current(&imprint_id)?; + ImprintPolicy::can_delete(context, &imprint)?; + + imprint.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single contributor using its ID")] + fn delete_contributor( + context: &Context, + #[graphql(description = "Thoth ID of contributor to be deleted")] contributor_id: Uuid, + ) -> FieldResult<Contributor> { + let contributor = context.load_current(&contributor_id)?; + ContributorPolicy::can_delete(context, &contributor)?; + + contributor.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single contribution using its ID")] + fn delete_contribution( + context: &Context, + #[graphql(description = "Thoth ID of contribution to be deleted")] contribution_id: Uuid, + ) -> FieldResult<Contribution> { + let contribution = context.load_current(&contribution_id)?; + ContributionPolicy::can_delete(context, &contribution)?; + + contribution.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single publication using its ID")] + fn delete_publication( + context: &Context, + #[graphql(description = "Thoth ID of publication to be deleted")] publication_id: Uuid, + ) -> FieldResult<Publication> { + let publication = context.load_current(&publication_id)?; + PublicationPolicy::can_delete(context, &publication)?; + let cleanup_plan = publication_cleanup_plan(context.db(), &publication)?; + + let deleted_publication = publication.delete(&context.db)?; + if let Some(plan) = cleanup_plan { + run_cleanup_plan_sync(context.s3_client(), context.cloudfront_client(), plan); + } + + Ok(deleted_publication) + } + + #[graphql(description = "Delete a single series using its ID")] + fn delete_series( + context: &Context, + #[graphql(description = "Thoth ID of series to be deleted")] series_id: Uuid, + ) -> FieldResult<Series> { + let series = context.load_current(&series_id)?; + SeriesPolicy::can_delete(context, &series)?; + + series.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single issue using its ID")] + fn delete_issue( + context: &Context, + #[graphql(description = "Thoth ID of issue to be deleted")] issue_id: Uuid, + ) -> FieldResult<Issue> { + let issue = context.load_current(&issue_id)?; + IssuePolicy::can_delete(context, &issue)?; + + issue.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single language using its ID")] + fn delete_language( + context: &Context, + #[graphql(description = "Thoth ID of language to be deleted")] language_id: Uuid, + ) -> FieldResult<Language> { + let language = context.load_current(&language_id)?; + LanguagePolicy::can_delete(context, &language)?; + + language.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single title using its ID")] + fn delete_title( + context: &Context, + #[graphql(description = "Thoth ID of title to be deleted")] title_id: Uuid, + ) -> FieldResult<Title> { + let title = context.load_current(&title_id)?; + TitlePolicy::can_delete(context, &title)?; + + title.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single institution using its ID")] + fn delete_institution( + context: &Context, + #[graphql(description = "Thoth ID of institution to be deleted")] institution_id: Uuid, + ) -> FieldResult<Institution> { + let institution = context.load_current(&institution_id)?; + InstitutionPolicy::can_delete(context, &institution)?; + + institution.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single funding using its ID")] + fn delete_funding( + context: &Context, + #[graphql(description = "Thoth ID of funding to be deleted")] funding_id: Uuid, + ) -> FieldResult<Funding> { + let funding = context.load_current(&funding_id)?; + FundingPolicy::can_delete(context, &funding)?; + + funding.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single location using its ID")] + fn delete_location( + context: &Context, + #[graphql(description = "Thoth ID of location to be deleted")] location_id: Uuid, + ) -> FieldResult<Location> { + let location = context.load_current(&location_id)?; + LocationPolicy::can_delete(context, &location)?; + + location.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single price using its ID")] + fn delete_price( + context: &Context, + #[graphql(description = "Thoth ID of price to be deleted")] price_id: Uuid, + ) -> FieldResult<Price> { + let price = context.load_current(&price_id)?; + PricePolicy::can_delete(context, &price)?; + + price.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single subject using its ID")] + fn delete_subject( + context: &Context, + #[graphql(description = "Thoth ID of subject to be deleted")] subject_id: Uuid, + ) -> FieldResult<Subject> { + let subject = context.load_current(&subject_id)?; + SubjectPolicy::can_delete(context, &subject)?; + + subject.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single affiliation using its ID")] + fn delete_affiliation( + context: &Context, + #[graphql(description = "Thoth ID of affiliation to be deleted")] affiliation_id: Uuid, + ) -> FieldResult<Affiliation> { + let affiliation = context.load_current(&affiliation_id)?; + AffiliationPolicy::can_delete(context, &affiliation)?; + + affiliation.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single work relation using its ID")] + fn delete_work_relation( + context: &Context, + #[graphql(description = "Thoth ID of work relation to be deleted")] work_relation_id: Uuid, + ) -> FieldResult<WorkRelation> { + let work_relation = context.load_current(&work_relation_id)?; + WorkRelationPolicy::can_delete(context, &work_relation)?; + + work_relation.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single reference using its ID")] + fn delete_reference( + context: &Context, + #[graphql(description = "Thoth ID of reference to be deleted")] reference_id: Uuid, + ) -> FieldResult<Reference> { + let reference = context.load_current(&reference_id)?; + ReferencePolicy::can_delete(context, &reference)?; + + reference.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single additional resource using its ID")] + fn delete_additional_resource( + context: &Context, + #[graphql(description = "Thoth ID of additional resource to be deleted")] + additional_resource_id: Uuid, + ) -> FieldResult<AdditionalResource> { + let additional_resource = context.load_current(&additional_resource_id)?; + AdditionalResourcePolicy::can_delete(context, &additional_resource)?; + + let cleanup_plan = additional_resource_cleanup_plan(context.db(), &additional_resource)?; + let deleted_additional_resource = additional_resource.delete(&context.db)?; + if let Some(plan) = cleanup_plan { + run_cleanup_plan_sync(context.s3_client(), context.cloudfront_client(), plan); + } + + Ok(deleted_additional_resource) + } + + #[graphql(description = "Delete a single award using its ID")] + fn delete_award( + context: &Context, + #[graphql(description = "Thoth ID of award to be deleted")] award_id: Uuid, + ) -> FieldResult<Award> { + let award = context.load_current(&award_id)?; + AwardPolicy::can_delete(context, &award)?; + + award.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single endorsement using its ID")] + fn delete_endorsement( + context: &Context, + #[graphql(description = "Thoth ID of endorsement to be deleted")] endorsement_id: Uuid, + ) -> FieldResult<Endorsement> { + let endorsement = context.load_current(&endorsement_id)?; + EndorsementPolicy::can_delete(context, &endorsement)?; + + endorsement.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single book review using its ID")] + fn delete_book_review( + context: &Context, + #[graphql(description = "Thoth ID of book review to be deleted")] book_review_id: Uuid, + ) -> FieldResult<BookReview> { + let book_review = context.load_current(&book_review_id)?; + BookReviewPolicy::can_delete(context, &book_review)?; + + book_review.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single featured video using its ID")] + fn delete_work_featured_video( + context: &Context, + #[graphql(description = "Thoth ID of featured video to be deleted")] work_featured_video_id: Uuid, + ) -> FieldResult<WorkFeaturedVideo> { + let work_featured_video = context.load_current(&work_featured_video_id)?; + WorkFeaturedVideoPolicy::can_delete(context, &work_featured_video)?; + + let cleanup_plan = work_featured_video_cleanup_plan(context.db(), &work_featured_video)?; + let deleted_work_featured_video = work_featured_video.delete(&context.db)?; + if let Some(plan) = cleanup_plan { + run_cleanup_plan_sync(context.s3_client(), context.cloudfront_client(), plan); + } + + Ok(deleted_work_featured_video) + } + + #[graphql(description = "Delete a single abstract using its ID")] + fn delete_abstract( + context: &Context, + #[graphql(description = "Thoth ID of abstract to be deleted")] abstract_id: Uuid, + ) -> FieldResult<Abstract> { + let r#abstract = context.load_current(&abstract_id)?; + AbstractPolicy::can_delete(context, &r#abstract)?; + + r#abstract.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Delete a single biography using its ID")] + fn delete_biography( + context: &Context, + #[graphql(description = "Thoth ID of biography to be deleted")] biography_id: Uuid, + ) -> FieldResult<Biography> { + let biography = context.load_current(&biography_id)?; + BiographyPolicy::can_delete(context, &biography)?; + + biography.delete(&context.db).map_err(Into::into) + } + + #[graphql(description = "Change the ordering of an affiliation within a contribution")] + fn move_affiliation( + context: &Context, + #[graphql(description = "Thoth ID of affiliation to be moved")] affiliation_id: Uuid, + #[graphql( + description = "Ordinal representing position to which affiliation should be moved" + )] + new_ordinal: i32, + ) -> FieldResult<Affiliation> { + let affiliation = context.load_current(&affiliation_id)?; + AffiliationPolicy::can_move(context, &affiliation)?; + + if new_ordinal == affiliation.affiliation_ordinal { + // No action required + return Ok(affiliation); + } + + affiliation + .change_ordinal(context, affiliation.affiliation_ordinal, new_ordinal) + .map_err(Into::into) + } + + #[graphql(description = "Change the ordering of a contribution within a work")] + fn move_contribution( + context: &Context, + #[graphql(description = "Thoth ID of contribution to be moved")] contribution_id: Uuid, + #[graphql( + description = "Ordinal representing position to which contribution should be moved" + )] + new_ordinal: i32, + ) -> FieldResult<Contribution> { + let contribution = context.load_current(&contribution_id)?; + ContributionPolicy::can_move(context, &contribution)?; + + if new_ordinal == contribution.contribution_ordinal { + // No action required + return Ok(contribution); + } + + contribution + .change_ordinal(context, contribution.contribution_ordinal, new_ordinal) + .map_err(Into::into) + } + + #[graphql(description = "Change the ordering of an issue within a series")] + fn move_issue( + context: &Context, + #[graphql(description = "Thoth ID of issue to be moved")] issue_id: Uuid, + #[graphql(description = "Ordinal representing position to which issue should be moved")] + new_ordinal: i32, + ) -> FieldResult<Issue> { + let issue = context.load_current(&issue_id)?; + IssuePolicy::can_move(context, &issue)?; + + if new_ordinal == issue.issue_ordinal { + // No action required + return Ok(issue); + } + + issue + .change_ordinal(context, issue.issue_ordinal, new_ordinal) + .map_err(Into::into) + } + + #[graphql(description = "Change the ordering of a reference within a work")] + fn move_reference( + context: &Context, + #[graphql(description = "Thoth ID of reference to be moved")] reference_id: Uuid, + #[graphql( + description = "Ordinal representing position to which reference should be moved" + )] + new_ordinal: i32, + ) -> FieldResult<Reference> { + let reference = context.load_current(&reference_id)?; + ReferencePolicy::can_move(context, &reference)?; + + if new_ordinal == reference.reference_ordinal { + // No action required + return Ok(reference); + } + + reference + .change_ordinal(context, reference.reference_ordinal, new_ordinal) + .map_err(Into::into) + } + + #[graphql(description = "Change the ordering of an additional resource within a work")] + fn move_additional_resource( + context: &Context, + #[graphql(description = "Thoth ID of additional resource to be moved")] + additional_resource_id: Uuid, + #[graphql( + description = "Ordinal representing position to which additional resource should be moved" + )] + new_ordinal: i32, + ) -> FieldResult<AdditionalResource> { + let additional_resource = context.load_current(&additional_resource_id)?; + AdditionalResourcePolicy::can_move(context, &additional_resource)?; + + if new_ordinal == additional_resource.resource_ordinal { + return Ok(additional_resource); + } + + additional_resource + .change_ordinal(context, additional_resource.resource_ordinal, new_ordinal) + .map_err(Into::into) + } + + #[graphql(description = "Change the ordering of an award within a work")] + fn move_award( + context: &Context, + #[graphql(description = "Thoth ID of award to be moved")] award_id: Uuid, + #[graphql(description = "Ordinal representing position to which award should be moved")] + new_ordinal: i32, + ) -> FieldResult<Award> { + let award = context.load_current(&award_id)?; + AwardPolicy::can_move(context, &award)?; + + if new_ordinal == award.award_ordinal { + return Ok(award); + } + + award + .change_ordinal(context, award.award_ordinal, new_ordinal) + .map_err(Into::into) + } + + #[graphql(description = "Change the ordering of an endorsement within a work")] + fn move_endorsement( + context: &Context, + #[graphql(description = "Thoth ID of endorsement to be moved")] endorsement_id: Uuid, + #[graphql( + description = "Ordinal representing position to which endorsement should be moved" + )] + new_ordinal: i32, + ) -> FieldResult<Endorsement> { + let endorsement = context.load_current(&endorsement_id)?; + EndorsementPolicy::can_move(context, &endorsement)?; + + if new_ordinal == endorsement.endorsement_ordinal { + return Ok(endorsement); + } + + endorsement + .change_ordinal(context, endorsement.endorsement_ordinal, new_ordinal) + .map_err(Into::into) + } + + #[graphql(description = "Change the ordering of a book review within a work")] + fn move_book_review( + context: &Context, + #[graphql(description = "Thoth ID of book review to be moved")] book_review_id: Uuid, + #[graphql( + description = "Ordinal representing position to which book review should be moved" + )] + new_ordinal: i32, + ) -> FieldResult<BookReview> { + let book_review = context.load_current(&book_review_id)?; + BookReviewPolicy::can_move(context, &book_review)?; + + if new_ordinal == book_review.review_ordinal { + return Ok(book_review); + } + + book_review + .change_ordinal(context, book_review.review_ordinal, new_ordinal) + .map_err(Into::into) + } + + #[graphql(description = "Change the ordering of a subject within a work")] + fn move_subject( + context: &Context, + #[graphql(description = "Thoth ID of subject to be moved")] subject_id: Uuid, + #[graphql(description = "Ordinal representing position to which subject should be moved")] + new_ordinal: i32, + ) -> FieldResult<Subject> { + let subject = context.load_current(&subject_id)?; + SubjectPolicy::can_move(context, &subject)?; + + if new_ordinal == subject.subject_ordinal { + // No action required + return Ok(subject); + } + + subject + .change_ordinal(context, subject.subject_ordinal, new_ordinal) + .map_err(Into::into) + } + + #[graphql(description = "Change the ordering of a work relation within a work")] + fn move_work_relation( + context: &Context, + #[graphql(description = "Thoth ID of work relation to be moved")] work_relation_id: Uuid, + #[graphql( + description = "Ordinal representing position to which work relation should be moved" + )] + new_ordinal: i32, + ) -> FieldResult<WorkRelation> { + let work_relation = context.load_current(&work_relation_id)?; + WorkRelationPolicy::can_move(context, &work_relation)?; + + if new_ordinal == work_relation.relation_ordinal { + // No action required + return Ok(work_relation); + } + + work_relation + .change_ordinal(context, work_relation.relation_ordinal, new_ordinal) + .map_err(Into::into) + } + + #[graphql( + description = "Start uploading a publication file (e.g. PDF, EPUB, XML) for a given publication. Returns an upload session ID, a presigned S3 PUT URL, and required PUT headers." + )] + async fn init_publication_file_upload( + context: &Context, + #[graphql(description = "Input for starting a publication file upload")] + data: NewPublicationFileUpload, + ) -> FieldResult<FileUploadResponse> { + let publication: Publication = context.load_current(&data.publication_id)?; + + let new_upload: NewFileUpload = data.into(); + FilePolicy::can_create(context, &new_upload, Some(publication.publication_type))?; + + let work: Work = context.load_current(&publication.work_id)?; + work.doi.ok_or(ThothError::WorkMissingDoiForFileUpload)?; + + let imprint: Imprint = context.load_current(&work.imprint_id)?; + let storage_config = StorageConfig::from_imprint(&imprint)?; + + new_upload + .create_upload_response(&context.db, context.s3_client(), &storage_config, 30) + .await + .map_err(Into::into) + } + + #[graphql( + description = "Start uploading a front cover image for a given work. Returns an upload session ID, a presigned S3 PUT URL, and required PUT headers." + )] + async fn init_frontcover_file_upload( + context: &Context, + #[graphql(description = "Input for starting a front cover upload")] + data: NewFrontcoverFileUpload, + ) -> FieldResult<FileUploadResponse> { + let work: Work = context.load_current(&data.work_id)?; + + let new_upload: NewFileUpload = data.into(); + FilePolicy::can_create(context, &new_upload, None)?; + + work.doi.ok_or(ThothError::WorkMissingDoiForFileUpload)?; + + let imprint: Imprint = context.load_current(&work.imprint_id)?; + let storage_config = StorageConfig::from_imprint(&imprint)?; + + new_upload + .create_upload_response(&context.db, context.s3_client(), &storage_config, 30) + .await + .map_err(Into::into) + } + + #[graphql( + description = "Start uploading a file for an additional resource. Supported resource types include AUDIO, VIDEO, IMAGE, DOCUMENT, DATASET, and SPREADSHEET." + )] + async fn init_additional_resource_file_upload( + context: &Context, + #[graphql(description = "Input for starting an additional resource upload")] + data: NewAdditionalResourceFileUpload, + ) -> FieldResult<FileUploadResponse> { + let additional_resource: AdditionalResource = + context.load_current(&data.additional_resource_id)?; + context.require_cdn_write_for(&additional_resource)?; + + let new_upload: NewFileUpload = data.into(); + FilePolicy::validate_resource_file_extension( + &new_upload.declared_extension, + additional_resource.resource_type, + )?; + FilePolicy::validate_resource_file_mime_type( + additional_resource.resource_type, + &new_upload.declared_mime_type, + )?; + + let work: Work = context.load_current(&additional_resource.work_id)?; + work.doi.ok_or(ThothError::WorkMissingDoiForFileUpload)?; + + let imprint: Imprint = context.load_current(&work.imprint_id)?; + let storage_config = StorageConfig::from_imprint(&imprint)?; + + new_upload + .create_upload_response(&context.db, context.s3_client(), &storage_config, 30) + .await + .map_err(Into::into) + } + + #[graphql( + description = "Start uploading a hosted featured video for a work. The uploaded file is promoted to a DOI-scoped resource path." + )] + async fn init_work_featured_video_file_upload( + context: &Context, + #[graphql(description = "Input for starting a featured video upload")] + data: NewWorkFeaturedVideoFileUpload, + ) -> FieldResult<FileUploadResponse> { + let work_featured_video: WorkFeaturedVideo = + context.load_current(&data.work_featured_video_id)?; + context.require_cdn_write_for(&work_featured_video)?; + + let new_upload: NewFileUpload = data.into(); + FilePolicy::validate_resource_file_extension( + &new_upload.declared_extension, + ResourceType::Video, + )?; + FilePolicy::validate_resource_file_mime_type( + ResourceType::Video, + &new_upload.declared_mime_type, + )?; + + let work: Work = context.load_current(&work_featured_video.work_id)?; + work.doi.ok_or(ThothError::WorkMissingDoiForFileUpload)?; + + let imprint: Imprint = context.load_current(&work.imprint_id)?; + let storage_config = StorageConfig::from_imprint(&imprint)?; + + new_upload + .create_upload_response(&context.db, context.s3_client(), &storage_config, 30) + .await + .map_err(Into::into) + } + + #[graphql( + description = "Complete a file upload, validate it, and promote it to its final DOI-based location." + )] + async fn complete_file_upload( + context: &Context, + #[graphql(description = "Input for completing a file upload")] data: CompleteFileUpload, + ) -> FieldResult<File> { + let file_upload: FileUpload = context.load_current(&data.file_upload_id)?; + FilePolicy::can_delete(context, &file_upload)?; + + let (work, publication, additional_resource, work_featured_video) = + file_upload.load_scope(context)?; + let doi = work + .doi + .as_ref() + .ok_or(ThothError::WorkMissingDoiForFileUpload)?; + + let imprint: Imprint = context.load_current(&work.imprint_id)?; + let storage_config = StorageConfig::from_imprint(&imprint)?; + + let s3_client = context.s3_client(); + let cloudfront_client = context.cloudfront_client(); + + let temp_key = temp_key(&file_upload.file_upload_id); + let (bytes, mime_type) = + head_object(s3_client, &storage_config.s3_bucket, &temp_key).await?; + let resource_type = match file_upload.file_type { + crate::model::file::FileType::AdditionalResource => Some( + additional_resource + .as_ref() + .map(|resource| resource.resource_type) + .ok_or(ThothError::AdditionalResourceFileUploadMissingAdditionalResourceId)?, + ), + crate::model::file::FileType::WorkFeaturedVideo => { + work_featured_video + .as_ref() + .ok_or(ThothError::WorkFeaturedVideoFileUploadMissingWorkFeaturedVideoId)?; + Some(ResourceType::Video) + } + crate::model::file::FileType::Frontcover + | crate::model::file::FileType::Publication => None, + }; + FilePolicy::can_complete_upload( + context, + &file_upload, + publication.as_ref().map(|pubn| pubn.publication_type), + resource_type, + bytes, + &mime_type, + )?; + + let featured_video_dimensions = if matches!( + file_upload.file_type, + crate::model::file::FileType::WorkFeaturedVideo + ) { + probe_video_dimensions( + s3_client, + &storage_config.s3_bucket, + &temp_key, + &file_upload.declared_extension, + bytes, + ) + .await + } else { + None + }; + + let canonical_key = file_upload.canonical_key(doi)?; + + copy_temp_object_to_final( + s3_client, + &storage_config.s3_bucket, + &temp_key, + &canonical_key, + ) + .await?; + + let cdn_url = build_cdn_url(&storage_config.cdn_domain, &canonical_key); + let (file, old_object_key) = file_upload.persist_file_record( + context, + &canonical_key, + &cdn_url, + &mime_type, + bytes, + )?; + file_upload.sync_related_metadata(context, &work, &cdn_url, featured_video_dimensions)?; + + reconcile_replaced_object( + s3_client, + cloudfront_client, + &storage_config.s3_bucket, + &storage_config.cloudfront_dist_id, + old_object_key.as_deref(), + &canonical_key, + ) + .await?; + + file_upload.clone().delete(&context.db)?; + + delete_object(s3_client, &storage_config.s3_bucket, &temp_key).await?; + + Ok(file) + } + + #[graphql(description = "Delete a single contact using its ID")] + fn delete_contact( + context: &Context, + #[graphql(description = "Thoth ID of contact to be deleted")] contact_id: Uuid, + ) -> FieldResult<Contact> { + let contact = context.load_current(&contact_id)?; + ContactPolicy::can_delete(context, &contact)?; + + contact.delete(&context.db).map_err(Into::into) + } +} diff --git a/thoth-api/src/graphql/query.rs b/thoth-api/src/graphql/query.rs new file mode 100644 index 00000000..cb65b26e --- /dev/null +++ b/thoth-api/src/graphql/query.rs @@ -0,0 +1,1928 @@ +use juniper::{FieldError, FieldResult}; +use uuid::Uuid; + +use super::types::inputs::{ + ContributionOrderBy, FundingOrderBy, IssueOrderBy, LanguageOrderBy, PriceOrderBy, + SubjectOrderBy, TimeExpression, +}; +use crate::graphql::types::me::{Me, ToMe}; +use crate::graphql::Context; +use crate::markup::{convert_from_jats, ConversionLimit, MarkupFormat}; +use crate::model::{ + additional_resource::{AdditionalResource, AdditionalResourceOrderBy}, + affiliation::{Affiliation, AffiliationOrderBy}, + award::{Award, AwardOrderBy}, + biography::{Biography, BiographyOrderBy}, + book_review::{BookReview, BookReviewOrderBy}, + contact::{Contact, ContactOrderBy, ContactType}, + contribution::{Contribution, ContributionType}, + contributor::{Contributor, ContributorOrderBy}, + endorsement::{Endorsement, EndorsementOrderBy}, + file::File, + funding::Funding, + imprint::{Imprint, ImprintOrderBy}, + institution::{Institution, InstitutionOrderBy}, + issue::Issue, + language::{Language, LanguageCode, LanguageRelation}, + locale::LocaleCode, + location::{Location, LocationOrderBy, LocationPlatform}, + price::{CurrencyCode, Price}, + publication::{Publication, PublicationOrderBy, PublicationType}, + publisher::{Publisher, PublisherOrderBy}, + r#abstract::{Abstract, AbstractOrderBy}, + reference::{Reference, ReferenceOrderBy}, + series::{Series, SeriesOrderBy, SeriesType}, + subject::{Subject, SubjectType}, + title::{Title, TitleOrderBy}, + work::{Work, WorkOrderBy, WorkStatus, WorkType}, + work_featured_video::{WorkFeaturedVideo, WorkFeaturedVideoOrderBy}, + Crud, Doi, +}; +use crate::policy::PolicyContext; +use thoth_errors::ThothError; + +pub struct QueryRoot; + +#[juniper::graphql_object(Context = Context)] +impl QueryRoot { + #[allow(clippy::too_many_arguments)] + #[graphql(description = "Query the full list of works")] + fn works( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page" + )] + filter: Option<String>, + #[graphql( + default = WorkOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<WorkOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql( + default = vec![], + description = "Specific types to filter by", + )] + work_types: Option<Vec<WorkType>>, + #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< + WorkStatus, + >, + #[graphql( + default = vec![], + description = "Specific statuses to filter by" + )] + work_statuses: Option<Vec<WorkStatus>>, + #[graphql( + description = "Only show results with a publication date either before (less than) or after (greater than) the specified timestamp" + )] + publication_date: Option<TimeExpression>, + #[graphql( + description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" + )] + updated_at_with_relations: Option<TimeExpression>, + ) -> FieldResult<Vec<Work>> { + let mut statuses = work_statuses.unwrap_or_default(); + if let Some(status) = work_status { + statuses.push(status); + } + Work::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + work_types.unwrap_or_default(), + statuses, + publication_date, + updated_at_with_relations, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single work using its ID")] + fn work( + context: &Context, + #[graphql(description = "Thoth work ID to search on")] work_id: Uuid, + ) -> FieldResult<Work> { + Work::from_id(&context.db, &work_id).map_err(Into::into) + } + + #[graphql(description = "Query a single work using its DOI")] + fn work_by_doi( + context: &Context, + #[graphql(description = "Work DOI to search on")] doi: Doi, + ) -> FieldResult<Work> { + Work::from_doi(&context.db, doi, vec![]).map_err(Into::into) + } + + #[allow(clippy::too_many_arguments)] + #[graphql(description = "Get the total number of works")] + fn work_count( + context: &Context, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page", + )] + filter: Option<String>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs", + )] + publishers: Option<Vec<Uuid>>, + #[graphql( + default = vec![], + description = "Specific types to filter by", + )] + work_types: Option<Vec<WorkType>>, + #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< + WorkStatus, + >, + #[graphql( + default = vec![], + description = "Specific statuses to filter by" + )] + work_statuses: Option<Vec<WorkStatus>>, + #[graphql( + description = "Only show results with a publication date either before (less than) or after (greater than) the specified timestamp" + )] + publication_date: Option<TimeExpression>, + #[graphql( + description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" + )] + updated_at_with_relations: Option<TimeExpression>, + ) -> FieldResult<i32> { + let mut statuses = work_statuses.unwrap_or_default(); + if let Some(status) = work_status { + statuses.push(status); + } + Work::count( + &context.db, + filter, + publishers.unwrap_or_default(), + work_types.unwrap_or_default(), + statuses, + publication_date, + updated_at_with_relations, + ) + .map_err(Into::into) + } + + #[allow(clippy::too_many_arguments)] + #[graphql(description = "Query the full list of books (a subset of the full list of works)")] + fn books( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page" + )] + filter: Option<String>, + #[graphql( + default = WorkOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<WorkOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< + WorkStatus, + >, + #[graphql( + default = vec![], + description = "Specific statuses to filter by" + )] + work_statuses: Option<Vec<WorkStatus>>, + #[graphql( + description = "Only show results with a publication date either before (less than) or after (greater than) the specified timestamp" + )] + publication_date: Option<TimeExpression>, + #[graphql( + description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" + )] + updated_at_with_relations: Option<TimeExpression>, + ) -> FieldResult<Vec<Work>> { + let mut statuses = work_statuses.unwrap_or_default(); + if let Some(status) = work_status { + statuses.push(status); + } + Work::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![ + WorkType::Monograph, + WorkType::EditedBook, + WorkType::Textbook, + WorkType::JournalIssue, + ], + statuses, + publication_date, + updated_at_with_relations, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single book using its DOI")] + fn book_by_doi( + context: &Context, + #[graphql(description = "Book DOI to search on")] doi: Doi, + ) -> FieldResult<Work> { + Work::from_doi( + &context.db, + doi, + vec![ + WorkType::Monograph, + WorkType::EditedBook, + WorkType::Textbook, + WorkType::JournalIssue, + ], + ) + .map_err(Into::into) + } + + #[graphql( + description = "Get the total number of books (a subset of the total number of works)" + )] + fn book_count( + context: &Context, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page" + )] + filter: Option<String>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< + WorkStatus, + >, + #[graphql( + default = vec![], + description = "Specific statuses to filter by" + )] + work_statuses: Option<Vec<WorkStatus>>, + #[graphql( + description = "Only show results with a publication date either before (less than) or after (greater than) the specified timestamp" + )] + publication_date: Option<TimeExpression>, + #[graphql( + description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" + )] + updated_at_with_relations: Option<TimeExpression>, + ) -> FieldResult<i32> { + let mut statuses = work_statuses.unwrap_or_default(); + if let Some(status) = work_status { + statuses.push(status); + } + Work::count( + &context.db, + filter, + publishers.unwrap_or_default(), + vec![ + WorkType::Monograph, + WorkType::EditedBook, + WorkType::Textbook, + WorkType::JournalIssue, + ], + statuses, + publication_date, + updated_at_with_relations, + ) + .map_err(Into::into) + } + + #[allow(clippy::too_many_arguments)] + #[graphql(description = "Query the full list of chapters (a subset of the full list of works)")] + fn chapters( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page" + )] + filter: Option<String>, + #[graphql( + default = WorkOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<WorkOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< + WorkStatus, + >, + #[graphql( + default = vec![], + description = "Specific statuses to filter by" + )] + work_statuses: Option<Vec<WorkStatus>>, + #[graphql( + description = "Only show results with a publication date either before (less than) or after (greater than) the specified timestamp" + )] + publication_date: Option<TimeExpression>, + #[graphql( + description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" + )] + updated_at_with_relations: Option<TimeExpression>, + ) -> FieldResult<Vec<Work>> { + let mut statuses = work_statuses.unwrap_or_default(); + if let Some(status) = work_status { + statuses.push(status); + } + Work::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![WorkType::BookChapter], + statuses, + publication_date, + updated_at_with_relations, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single chapter using its DOI")] + fn chapter_by_doi( + context: &Context, + #[graphql(description = "Chapter DOI to search on")] doi: Doi, + ) -> FieldResult<Work> { + Work::from_doi(&context.db, doi, vec![WorkType::BookChapter]).map_err(Into::into) + } + + #[graphql( + description = "Get the total number of chapters (a subset of the total number of works)" + )] + fn chapter_count( + context: &Context, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_title, doi, reference, short_abstract, long_abstract, and landing_page" + )] + filter: Option<String>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql(description = "(deprecated) A specific status to filter by")] work_status: Option< + WorkStatus, + >, + #[graphql( + default = vec![], + description = "Specific statuses to filter by" + )] + work_statuses: Option<Vec<WorkStatus>>, + #[graphql( + description = "Only show results with a publication date either before (less than) or after (greater than) the specified timestamp" + )] + publication_date: Option<TimeExpression>, + #[graphql( + description = "Only show results updated either before (less than) or after (greater than) the specified timestamp" + )] + updated_at_with_relations: Option<TimeExpression>, + ) -> FieldResult<i32> { + let mut statuses = work_statuses.unwrap_or_default(); + if let Some(status) = work_status { + statuses.push(status); + } + Work::count( + &context.db, + filter, + publishers.unwrap_or_default(), + vec![WorkType::BookChapter], + statuses, + publication_date, + updated_at_with_relations, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of publications")] + fn publications( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on isbn" + )] + filter: Option<String>, + #[graphql( + default = PublicationOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<PublicationOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql( + default = vec![], + description = "Specific types to filter by", + )] + publication_types: Option<Vec<PublicationType>>, + ) -> FieldResult<Vec<Publication>> { + Publication::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + publication_types.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single publication using its ID")] + fn publication( + context: &Context, + #[graphql(description = "Thoth publication ID to search on")] publication_id: Uuid, + ) -> FieldResult<Publication> { + Publication::from_id(&context.db, &publication_id).map_err(Into::into) + } + + #[graphql(description = "Query a single file using its ID")] + fn file( + context: &Context, + #[graphql(description = "Thoth file ID to search on")] file_id: Uuid, + ) -> FieldResult<File> { + File::from_id(&context.db, &file_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of publications")] + fn publication_count( + context: &Context, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on isbn" + )] + filter: Option<String>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql( + default = vec![], + description = "Specific types to filter by", + )] + publication_types: Option<Vec<PublicationType>>, + ) -> FieldResult<i32> { + Publication::count( + &context.db, + filter, + publishers.unwrap_or_default(), + publication_types.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of publishers")] + fn publishers( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on publisher_name and publisher_shortname" + )] + filter: Option<String>, + #[graphql( + default = PublisherOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<PublisherOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<Vec<Publisher>> { + Publisher::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single publisher using its ID")] + fn publisher( + context: &Context, + #[graphql(description = "Thoth publisher ID to search on")] publisher_id: Uuid, + ) -> FieldResult<Publisher> { + Publisher::from_id(&context.db, &publisher_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of publishers")] + fn publisher_count( + context: &Context, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on publisher_name and publisher_shortname" + )] + filter: Option<String>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<i32> { + Publisher::count( + &context.db, + filter, + publishers.unwrap_or_default(), + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of imprints")] + fn imprints( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on imprint_name and imprint_url" + )] + filter: Option<String>, + #[graphql( + default = ImprintOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<ImprintOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<Vec<Imprint>> { + Imprint::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single imprint using its ID")] + fn imprint( + context: &Context, + #[graphql(description = "Thoth imprint ID to search on")] imprint_id: Uuid, + ) -> FieldResult<Imprint> { + Imprint::from_id(&context.db, &imprint_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of imprints")] + fn imprint_count( + context: &Context, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on imprint_name and imprint_url" + )] + filter: Option<String>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<i32> { + Imprint::count( + &context.db, + filter, + publishers.unwrap_or_default(), + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of contributors")] + fn contributors( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_name, last_name and orcid" + )] + filter: Option<String>, + #[graphql( + default = ContributorOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<ContributorOrderBy>, + ) -> FieldResult<Vec<Contributor>> { + Contributor::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single contributor using its ID")] + fn contributor( + context: &Context, + #[graphql(description = "Thoth contributor ID to search on")] contributor_id: Uuid, + ) -> FieldResult<Contributor> { + Contributor::from_id(&context.db, &contributor_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of contributors")] + fn contributor_count( + context: &Context, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on full_name, last_name and orcid" + )] + filter: Option<String>, + ) -> FieldResult<i32> { + Contributor::count(&context.db, filter, vec![], vec![], vec![], None, None) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of contributions")] + fn contributions( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = ContributionOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<ContributionOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql( + default = vec![], + description = "Specific types to filter by", + )] + contribution_types: Option<Vec<ContributionType>>, + ) -> FieldResult<Vec<Contribution>> { + Contribution::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + contribution_types.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single contribution using its ID")] + fn contribution( + context: &Context, + #[graphql(description = "Thoth contribution ID to search on")] contribution_id: Uuid, + ) -> FieldResult<Contribution> { + Contribution::from_id(&context.db, &contribution_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of contributions")] + fn contribution_count( + context: &Context, + #[graphql( + default = vec![], + description = "Specific types to filter by", + )] + contribution_types: Option<Vec<ContributionType>>, + ) -> FieldResult<i32> { + Contribution::count( + &context.db, + None, + vec![], + contribution_types.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of series")] + fn serieses( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on series_name, issn_print, issn_digital, series_url and series_description" + )] + filter: Option<String>, + #[graphql( + default = SeriesOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<SeriesOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql( + default = vec![], + description = "Specific types to filter by", + )] + series_types: Option<Vec<SeriesType>>, + ) -> FieldResult<Vec<Series>> { + Series::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + series_types.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single series using its ID")] + fn series( + context: &Context, + #[graphql(description = "Thoth series ID to search on")] series_id: Uuid, + ) -> FieldResult<Series> { + Series::from_id(&context.db, &series_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of series")] + fn series_count( + context: &Context, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on series_name, issn_print, issn_digital, series_url and series_description" + )] + filter: Option<String>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql( + default = vec![], + description = "Specific types to filter by", + )] + series_types: Option<Vec<SeriesType>>, + ) -> FieldResult<i32> { + Series::count( + &context.db, + filter, + publishers.unwrap_or_default(), + series_types.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of issues")] + fn issues( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = IssueOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<IssueOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<Vec<Issue>> { + Issue::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single issue using its ID")] + fn issue( + context: &Context, + #[graphql(description = "Thoth issue ID to search on")] issue_id: Uuid, + ) -> FieldResult<Issue> { + Issue::from_id(&context.db, &issue_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of issues")] + fn issue_count(context: &Context) -> FieldResult<i32> { + Issue::count(&context.db, None, vec![], vec![], vec![], None, None).map_err(Into::into) + } + + #[allow(clippy::too_many_arguments)] + #[graphql(description = "Query the full list of languages")] + fn languages( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = LanguageOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<LanguageOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql( + default = vec![], + description = "Specific languages to filter by" + )] + language_codes: Option<Vec<LanguageCode>>, + #[graphql( + description = "(deprecated) A specific relation to filter by" + )] + language_relation: Option<LanguageRelation>, + #[graphql( + default = vec![], + description = "Specific relations to filter by" + )] + language_relations: Option<Vec<LanguageRelation>>, + ) -> FieldResult<Vec<Language>> { + let mut relations = language_relations.unwrap_or_default(); + if let Some(relation) = language_relation { + relations.push(relation); + } + Language::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + language_codes.unwrap_or_default(), + relations, + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single language using its ID")] + fn language( + context: &Context, + #[graphql(description = "Thoth language ID to search on")] language_id: Uuid, + ) -> FieldResult<Language> { + Language::from_id(&context.db, &language_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of languages associated to works")] + fn language_count( + context: &Context, + #[graphql( + default = vec![], + description = "Specific languages to filter by" + )] + language_codes: Option<Vec<LanguageCode>>, + #[graphql( + description = "(deprecated) A specific relation to filter by" + )] + language_relation: Option<LanguageRelation>, + #[graphql( + default = vec![], + description = "Specific relations to filter by" + )] + language_relations: Option<Vec<LanguageRelation>>, + ) -> FieldResult<i32> { + let mut relations = language_relations.unwrap_or_default(); + if let Some(relation) = language_relation { + relations.push(relation); + } + Language::count( + &context.db, + None, + vec![], + language_codes.unwrap_or_default(), + relations, + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of locations")] + fn locations( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = LocationOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<LocationOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql( + default = vec![], + description = "Specific platforms to filter by" + )] + location_platforms: Option<Vec<LocationPlatform>>, + ) -> FieldResult<Vec<Location>> { + Location::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + location_platforms.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single location using its ID")] + fn location( + context: &Context, + #[graphql(description = "Thoth location ID to search on")] location_id: Uuid, + ) -> FieldResult<Location> { + Location::from_id(&context.db, &location_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of locations associated to works")] + fn location_count( + context: &Context, + #[graphql( + default = vec![], + description = "Specific platforms to filter by" + )] + location_platforms: Option<Vec<LocationPlatform>>, + ) -> FieldResult<i32> { + Location::count( + &context.db, + None, + vec![], + location_platforms.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of prices")] + fn prices( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = PriceOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<PriceOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql( + default = vec![], + description = "Specific currencies to filter by" + )] + currency_codes: Option<Vec<CurrencyCode>>, + ) -> FieldResult<Vec<Price>> { + Price::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + currency_codes.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single price using its ID")] + fn price( + context: &Context, + #[graphql(description = "Thoth price ID to search on")] price_id: Uuid, + ) -> FieldResult<Price> { + Price::from_id(&context.db, &price_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of prices associated to works")] + fn price_count( + context: &Context, + #[graphql( + default = vec![], + description = "Specific currencies to filter by" + )] + currency_codes: Option<Vec<CurrencyCode>>, + ) -> FieldResult<i32> { + Price::count( + &context.db, + None, + vec![], + currency_codes.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of subjects")] + fn subjects( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on subject_code" + )] + filter: Option<String>, + #[graphql( + default = SubjectOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<SubjectOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql( + default = vec![], + description = "Specific types to filter by", + )] + subject_types: Option<Vec<SubjectType>>, + ) -> FieldResult<Vec<Subject>> { + Subject::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + subject_types.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single subject using its ID")] + fn subject( + context: &Context, + #[graphql(description = "Thoth subject ID to search on")] subject_id: Uuid, + ) -> FieldResult<Subject> { + Subject::from_id(&context.db, &subject_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of subjects associated to works")] + fn subject_count( + context: &Context, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on subject_code" + )] + filter: Option<String>, + #[graphql( + default = vec![], + description = "Specific types to filter by", + )] + subject_types: Option<Vec<SubjectType>>, + ) -> FieldResult<i32> { + Subject::count( + &context.db, + filter, + vec![], + subject_types.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of institutions")] + fn institutions( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on institution_name, ror and institution_doi" + )] + filter: Option<String>, + #[graphql( + default = InstitutionOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<InstitutionOrderBy>, + ) -> FieldResult<Vec<Institution>> { + Institution::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single institution using its ID")] + fn institution( + context: &Context, + #[graphql(description = "Thoth institution ID to search on")] institution_id: Uuid, + ) -> FieldResult<Institution> { + Institution::from_id(&context.db, &institution_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of institutions")] + fn institution_count( + context: &Context, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on institution_name, ror and institution_doi" + )] + filter: Option<String>, + ) -> FieldResult<i32> { + Institution::count(&context.db, filter, vec![], vec![], vec![], None, None) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of fundings")] + fn fundings( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = FundingOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<FundingOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<Vec<Funding>> { + Funding::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single funding using its ID")] + fn funding( + context: &Context, + #[graphql(description = "Thoth funding ID to search on")] funding_id: Uuid, + ) -> FieldResult<Funding> { + Funding::from_id(&context.db, &funding_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of funding instances associated to works")] + fn funding_count(context: &Context) -> FieldResult<i32> { + Funding::count(&context.db, None, vec![], vec![], vec![], None, None).map_err(Into::into) + } + + #[graphql(description = "Query the full list of affiliations")] + fn affiliations( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = AffiliationOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<AffiliationOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<Vec<Affiliation>> { + Affiliation::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single affiliation using its ID")] + fn affiliation( + context: &Context, + #[graphql(description = "Thoth affiliation ID to search on")] affiliation_id: Uuid, + ) -> FieldResult<Affiliation> { + Affiliation::from_id(&context.db, &affiliation_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of affiliations")] + fn affiliation_count(context: &Context) -> FieldResult<i32> { + Affiliation::count(&context.db, None, vec![], vec![], vec![], None, None) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of references")] + fn references( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = ReferenceOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<ReferenceOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<Vec<Reference>> { + Reference::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single reference using its ID")] + fn reference( + context: &Context, + #[graphql(description = "Thoth reference ID to search on")] reference_id: Uuid, + ) -> FieldResult<Reference> { + Reference::from_id(&context.db, &reference_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of references")] + fn reference_count(context: &Context) -> FieldResult<i32> { + Reference::count(&context.db, None, vec![], vec![], vec![], None, None).map_err(Into::into) + } + + #[graphql(description = "Query the full list of additional resources")] + fn additional_resources( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = AdditionalResourceOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<AdditionalResourceOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<Vec<AdditionalResource>> { + AdditionalResource::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single additional resource using its ID")] + fn additional_resource( + context: &Context, + #[graphql(description = "Thoth additional resource ID to search on")] + additional_resource_id: Uuid, + ) -> FieldResult<AdditionalResource> { + AdditionalResource::from_id(&context.db, &additional_resource_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of additional resources")] + fn additional_resource_count(context: &Context) -> FieldResult<i32> { + AdditionalResource::count(&context.db, None, vec![], vec![], vec![], None, None) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of awards")] + fn awards( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = AwardOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<AwardOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<Vec<Award>> { + Award::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single award using its ID")] + fn award( + context: &Context, + #[graphql(description = "Thoth award ID to search on")] award_id: Uuid, + ) -> FieldResult<Award> { + Award::from_id(&context.db, &award_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of awards")] + fn award_count(context: &Context) -> FieldResult<i32> { + Award::count(&context.db, None, vec![], vec![], vec![], None, None).map_err(Into::into) + } + + #[graphql(description = "Query the full list of endorsements")] + fn endorsements( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = EndorsementOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<EndorsementOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<Vec<Endorsement>> { + Endorsement::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single endorsement using its ID")] + fn endorsement( + context: &Context, + #[graphql(description = "Thoth endorsement ID to search on")] endorsement_id: Uuid, + ) -> FieldResult<Endorsement> { + Endorsement::from_id(&context.db, &endorsement_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of endorsements")] + fn endorsement_count(context: &Context) -> FieldResult<i32> { + Endorsement::count(&context.db, None, vec![], vec![], vec![], None, None) + .map_err(Into::into) + } + + #[graphql(description = "Query the full list of book reviews")] + fn book_reviews( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = BookReviewOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<BookReviewOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<Vec<BookReview>> { + BookReview::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single book review using its ID")] + fn book_review( + context: &Context, + #[graphql(description = "Thoth book review ID to search on")] book_review_id: Uuid, + ) -> FieldResult<BookReview> { + BookReview::from_id(&context.db, &book_review_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of book reviews")] + fn book_review_count(context: &Context) -> FieldResult<i32> { + BookReview::count(&context.db, None, vec![], vec![], vec![], None, None).map_err(Into::into) + } + + #[graphql(description = "Query the full list of featured videos")] + fn work_featured_videos( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = WorkFeaturedVideoOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<WorkFeaturedVideoOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + ) -> FieldResult<Vec<WorkFeaturedVideo>> { + WorkFeaturedVideo::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + vec![], + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single featured video using its ID")] + fn work_featured_video( + context: &Context, + #[graphql(description = "Thoth featured video ID to search on")] + work_featured_video_id: Uuid, + ) -> FieldResult<WorkFeaturedVideo> { + WorkFeaturedVideo::from_id(&context.db, &work_featured_video_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of featured videos")] + fn work_featured_video_count(context: &Context) -> FieldResult<i32> { + WorkFeaturedVideo::count(&context.db, None, vec![], vec![], vec![], None, None) + .map_err(Into::into) + } + + #[graphql(description = "Query a title by its ID")] + fn title( + context: &Context, + title_id: Uuid, + markup_format: Option<MarkupFormat>, + ) -> FieldResult<Title> { + let mut title = Title::from_id(&context.db, &title_id).map_err(FieldError::from)?; + let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + title.title = convert_from_jats(&title.title, markup, ConversionLimit::Title)?; + if let Some(subtitle) = &title.subtitle { + title.subtitle = Some(convert_from_jats(subtitle, markup, ConversionLimit::Title)?); + } + title.full_title = convert_from_jats(&title.full_title, markup, ConversionLimit::Title)?; + Ok(title) + } + + #[graphql(description = "Query the full list of titles")] + fn titles( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on title_, subtitle, full_title fields" + )] + filter: Option<String>, + #[graphql( + default = TitleOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<TitleOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results with these locale codes" + )] + locale_codes: Option<Vec<LocaleCode>>, + #[graphql( + default = MarkupFormat::JatsXml, + description = "If set shows result with this markup format" + )] + markup_format: Option<MarkupFormat>, + ) -> FieldResult<Vec<Title>> { + let mut titles = Title::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + vec![], + None, + None, + locale_codes.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(FieldError::from)?; + + let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + for title in &mut titles { + title.title = convert_from_jats(&title.title, markup, ConversionLimit::Title)?; + if let Some(subtitle) = &title.subtitle { + title.subtitle = Some(convert_from_jats(subtitle, markup, ConversionLimit::Title)?); + } + title.full_title = + convert_from_jats(&title.full_title, markup, ConversionLimit::Title)?; + } + Ok(titles) + } + + #[graphql(description = "Query an abstract by its ID")] + fn r#abstract( + context: &Context, + abstract_id: Uuid, + #[graphql( + default = MarkupFormat::JatsXml, + description = "If set shows results with this markup format" + )] + markup_format: Option<MarkupFormat>, + ) -> FieldResult<Abstract> { + let mut r#abstract = + Abstract::from_id(&context.db, &abstract_id).map_err(FieldError::from)?; + let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + r#abstract.content = + convert_from_jats(&r#abstract.content, markup, ConversionLimit::Abstract)?; + Ok(r#abstract) + } + + #[allow(clippy::too_many_arguments)] + #[graphql(description = "Query the full list of abstracts")] + fn abstracts( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on content fields" + )] + filter: Option<String>, + #[graphql( + default = AbstractOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<AbstractOrderBy>, + #[graphql( + default = vec![], + description = "If set only shows results with these locale codes" + )] + locale_codes: Option<Vec<LocaleCode>>, + #[graphql( + default = MarkupFormat::JatsXml, + description = "If set shows result with this markup format" + )] + markup_format: Option<MarkupFormat>, + ) -> FieldResult<Vec<Abstract>> { + let mut abstracts = Abstract::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + vec![], + None, + None, + locale_codes.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(FieldError::from)?; + + let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + for r#abstract in &mut abstracts { + r#abstract.content = + convert_from_jats(&r#abstract.content, markup, ConversionLimit::Abstract)?; + } + + Ok(abstracts) + } + + #[graphql(description = "Query an biography by it's ID")] + fn biography( + context: &Context, + biography_id: Uuid, + #[graphql( + default = MarkupFormat::JatsXml, + description = "If set shows result with this markup format" + )] + markup_format: Option<MarkupFormat>, + ) -> FieldResult<Biography> { + let mut biography = + Biography::from_id(&context.db, &biography_id).map_err(FieldError::from)?; + let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + biography.content = + convert_from_jats(&biography.content, markup, ConversionLimit::Biography)?; + Ok(biography) + } + + #[allow(clippy::too_many_arguments)] + #[graphql(description = "Query biographies by work ID")] + fn biographies( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = "".to_string(), + description = "A query string to search. This argument is a test, do not rely on it. At present it simply searches for case insensitive literals on content fields" + )] + filter: Option<String>, + #[graphql( + default = BiographyOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<BiographyOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results with these locale codes" + )] + locale_codes: Option<Vec<LocaleCode>>, + #[graphql( + default = MarkupFormat::JatsXml, + description = "If set shows result with this markup format" + )] + markup_format: Option<MarkupFormat>, + ) -> FieldResult<Vec<Biography>> { + let mut biographies = Biography::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + filter, + order.unwrap_or_default(), + vec![], + None, + None, + locale_codes.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(FieldError::from)?; + + let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + for biography in &mut biographies { + biography.content = + convert_from_jats(&biography.content, markup, ConversionLimit::Biography)?; + } + + Ok(biographies) + } + + #[graphql(description = "Query the full list of contacts")] + fn contacts( + context: &Context, + #[graphql(default = 100, description = "The number of items to return")] limit: Option<i32>, + #[graphql(default = 0, description = "The number of items to skip")] offset: Option<i32>, + #[graphql( + default = ContactOrderBy::default(), + description = "The order in which to sort the results" + )] + order: Option<ContactOrderBy>, + #[graphql( + default = vec![], + description = "If set, only shows results connected to publishers with these IDs" + )] + publishers: Option<Vec<Uuid>>, + #[graphql( + default = vec![], + description = "Specific types to filter by", + )] + contact_types: Option<Vec<ContactType>>, + ) -> FieldResult<Vec<Contact>> { + Contact::all( + &context.db, + limit.unwrap_or_default(), + offset.unwrap_or_default(), + None, + order.unwrap_or_default(), + publishers.unwrap_or_default(), + None, + None, + contact_types.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Query a single contact using its ID")] + fn contact( + context: &Context, + #[graphql(description = "Thoth contact ID to search on")] contact_id: Uuid, + ) -> FieldResult<Contact> { + Contact::from_id(&context.db, &contact_id).map_err(Into::into) + } + + #[graphql(description = "Get the total number of contacts")] + fn contact_count( + context: &Context, + #[graphql( + default = vec![], + description = "Specific types to filter by" + )] + contact_types: Option<Vec<ContactType>>, + ) -> FieldResult<i32> { + Contact::count( + &context.db, + None, + vec![], + contact_types.unwrap_or_default(), + vec![], + None, + None, + ) + .map_err(Into::into) + } + + #[graphql(description = "Get the total number of contacts")] + fn me(context: &Context) -> FieldResult<Me> { + let user = context.require_authentication()?; + user.to_me(context) + } +} diff --git a/thoth-api/src/graphql/tests.rs b/thoth-api/src/graphql/tests.rs new file mode 100644 index 00000000..19c7c064 --- /dev/null +++ b/thoth-api/src/graphql/tests.rs @@ -0,0 +1,3074 @@ +#![cfg(feature = "backend")] + +use super::*; + +use crate::graphql::types::inputs::{Convert, LengthUnit, WeightUnit}; +use crate::markup::MarkupFormat; +use crate::model::tests::db as test_db; +use crate::model::{ + additional_resource::{AdditionalResource, NewAdditionalResource, ResourceType}, + affiliation::{Affiliation, NewAffiliation, PatchAffiliation}, + award::{Award, AwardRole, NewAward}, + biography::{Biography, NewBiography, PatchBiography}, + book_review::{BookReview, NewBookReview}, + contact::{Contact, ContactType, NewContact, PatchContact}, + contribution::{Contribution, ContributionType, NewContribution, PatchContribution}, + contributor::{Contributor, NewContributor, PatchContributor}, + endorsement::{Endorsement, NewEndorsement}, + funding::{Funding, NewFunding, PatchFunding}, + imprint::{Imprint, NewImprint, PatchImprint}, + institution::{CountryCode, Institution, NewInstitution, PatchInstitution}, + issue::{Issue, NewIssue, PatchIssue}, + language::{Language, LanguageCode, LanguageRelation, NewLanguage, PatchLanguage}, + locale::LocaleCode, + location::{Location, LocationPlatform, NewLocation, PatchLocation}, + price::{CurrencyCode, NewPrice, PatchPrice, Price}, + publication::{NewPublication, PatchPublication, Publication, PublicationType}, + publisher::{NewPublisher, PatchPublisher, Publisher}, + r#abstract::{Abstract, AbstractType, NewAbstract, PatchAbstract}, + reference::{NewReference, PatchReference, Reference}, + series::{NewSeries, PatchSeries, Series, SeriesType}, + subject::{NewSubject, PatchSubject, Subject, SubjectType}, + title::{NewTitle, PatchTitle, Title}, + work::{NewWork, PatchWork, Work, WorkStatus, WorkType}, + work_relation::{NewWorkRelation, PatchWorkRelation, RelationType, WorkRelation}, + Crud, Doi, Isbn, Orcid, Ror, +}; +use crate::policy::{PolicyContext, Role}; +use chrono::NaiveDate; +use juniper::{DefaultScalarValue, ToInputValue, Variables}; +use serde_json::Value as JsonValue; +use std::str::FromStr; +use uuid::Uuid; + +fn execute_graphql( + schema: &Schema, + context: &Context, + query: &str, + variables: Option<Variables>, +) -> JsonValue { + let vars = variables.unwrap_or_default(); + let (value, errors) = juniper::execute_sync(query, None, schema, &vars, context) + .expect("GraphQL execution failed"); + if !errors.is_empty() { + panic!("GraphQL errors: {errors:?}"); + } + serde_json::to_value(value).expect("Failed to serialize GraphQL response") +} + +fn insert_var<T>(vars: &mut Variables, name: &str, value: T) +where + T: ToInputValue<DefaultScalarValue>, +{ + vars.insert(name.to_string(), value.to_input_value()); +} + +fn json_uuid(value: &JsonValue) -> Uuid { + let raw = value + .as_str() + .unwrap_or_else(|| panic!("Expected uuid string, got {value:?}")); + Uuid::parse_str(raw).expect("Failed to parse uuid") +} + +fn create_with_data<T>( + schema: &Schema, + context: &Context, + mutation: &str, + input_type: &str, + return_fields: &str, + data: T, +) -> JsonValue +where + T: ToInputValue<DefaultScalarValue>, +{ + let query = format!( + "mutation($data: {input_type}!) {{ {mutation}(data: $data) {{ {return_fields} }} }}" + ); + let mut vars = Variables::new(); + insert_var(&mut vars, "data", data); + let data = execute_graphql(schema, context, &query, Some(vars)); + data.get(mutation) + .cloned() + .unwrap_or_else(|| panic!("Missing mutation result for {mutation}")) +} + +fn create_with_data_and_markup<T>( + schema: &Schema, + context: &Context, + mutation: &str, + input_type: &str, + return_fields: &str, + data: T, + markup_format: MarkupFormat, +) -> JsonValue +where + T: ToInputValue<DefaultScalarValue>, +{ + let query = format!( + "mutation($data: {input_type}!, $markup: MarkupFormat!) {{ {mutation}(markupFormat: $markup, data: $data) {{ {return_fields} }} }}" + ); + let mut vars = Variables::new(); + insert_var(&mut vars, "data", data); + insert_var(&mut vars, "markup", markup_format); + let data = execute_graphql(schema, context, &query, Some(vars)); + data.get(mutation) + .cloned() + .unwrap_or_else(|| panic!("Missing mutation result for {mutation}")) +} + +fn update_with_data<T>( + schema: &Schema, + context: &Context, + mutation: &str, + input_type: &str, + return_fields: &str, + data: T, +) -> JsonValue +where + T: ToInputValue<DefaultScalarValue>, +{ + let query = format!( + "mutation($data: {input_type}!) {{ {mutation}(data: $data) {{ {return_fields} }} }}" + ); + let mut vars = Variables::new(); + insert_var(&mut vars, "data", data); + let data = execute_graphql(schema, context, &query, Some(vars)); + data.get(mutation) + .cloned() + .unwrap_or_else(|| panic!("Missing mutation result for {mutation}")) +} + +fn update_with_data_and_markup<T>( + schema: &Schema, + context: &Context, + mutation: &str, + input_type: &str, + return_fields: &str, + data: T, + markup_format: MarkupFormat, +) -> JsonValue +where + T: ToInputValue<DefaultScalarValue>, +{ + let query = format!( + "mutation($data: {input_type}!, $markup: MarkupFormat!) {{ {mutation}(markupFormat: $markup, data: $data) {{ {return_fields} }} }}" + ); + let mut vars = Variables::new(); + insert_var(&mut vars, "data", data); + insert_var(&mut vars, "markup", markup_format); + let data = execute_graphql(schema, context, &query, Some(vars)); + data.get(mutation) + .cloned() + .unwrap_or_else(|| panic!("Missing mutation result for {mutation}")) +} + +fn delete_with_id( + schema: &Schema, + context: &Context, + mutation: &str, + arg_name: &str, + id: Uuid, + return_fields: &str, +) -> JsonValue { + let query = + format!("mutation($id: Uuid!) {{ {mutation}({arg_name}: $id) {{ {return_fields} }} }}"); + let mut vars = Variables::new(); + insert_var(&mut vars, "id", id); + let data = execute_graphql(schema, context, &query, Some(vars)); + data.get(mutation) + .cloned() + .unwrap_or_else(|| panic!("Missing mutation result for {mutation}")) +} + +fn move_with_ordinal( + schema: &Schema, + context: &Context, + mutation: &str, + arg_name: &str, + id: Uuid, + new_ordinal: i32, + return_fields: &str, +) -> JsonValue { + let query = format!( + "mutation($id: Uuid!, $ordinal: Int!) {{ {mutation}({arg_name}: $id, newOrdinal: $ordinal) {{ {return_fields} }} }}" + ); + let mut vars = Variables::new(); + insert_var(&mut vars, "id", id); + insert_var(&mut vars, "ordinal", new_ordinal); + let data = execute_graphql(schema, context, &query, Some(vars)); + data.get(mutation) + .cloned() + .unwrap_or_else(|| panic!("Missing mutation result for {mutation}")) +} + +fn unique(label: &str) -> String { + format!("{label}-{}", Uuid::new_v4()) +} + +fn make_new_publisher(org_id: &str) -> NewPublisher { + NewPublisher { + publisher_name: unique("Publisher"), + publisher_shortname: Some("TP".to_string()), + publisher_url: Some("https://example.com/publisher".to_string()), + zitadel_id: Some(org_id.to_string()), + accessibility_statement: Some("Accessibility statement".to_string()), + accessibility_report_url: Some("https://example.com/report".to_string()), + } +} + +fn make_new_imprint(publisher_id: Uuid) -> NewImprint { + NewImprint { + publisher_id, + imprint_name: unique("Imprint"), + imprint_url: Some("https://example.com/imprint".to_string()), + crossmark_doi: None, + s3_bucket: None, + cdn_domain: None, + cloudfront_dist_id: None, + default_currency: None, + default_place: None, + default_locale: None, + } +} + +fn make_new_book_work(imprint_id: Uuid, doi: Doi) -> NewWork { + NewWork { + work_type: WorkType::Monograph, + work_status: WorkStatus::Active, + reference: Some("REF-001".to_string()), + edition: Some(1), + imprint_id, + doi: Some(doi), + publication_date: Some(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()), + withdrawn_date: None, + place: Some("Test Place".to_string()), + page_count: Some(123), + page_breakdown: Some("xii+123".to_string()), + image_count: Some(10), + table_count: Some(2), + audio_count: Some(0), + video_count: Some(0), + license: Some("https://creativecommons.org/licenses/by/4.0/".to_string()), + copyright_holder: Some("Test Holder".to_string()), + landing_page: Some("https://example.com/book".to_string()), + lccn: Some("LCCN123".to_string()), + oclc: Some("OCLC123".to_string()), + general_note: Some("General note".to_string()), + bibliography_note: Some("Bibliography note".to_string()), + toc: Some("TOC".to_string()), + resources_description: None, + cover_url: Some("https://example.com/cover".to_string()), + cover_caption: Some("Cover caption".to_string()), + first_page: None, + last_page: None, + page_interval: None, + } +} + +fn make_new_work(imprint_id: Uuid, work_type: WorkType, doi: Doi) -> NewWork { + let edition = match work_type { + WorkType::BookChapter => None, + _ => Some(1), + }; + + NewWork { + work_type, + work_status: WorkStatus::Active, + reference: None, + edition, + imprint_id, + doi: Some(doi), + publication_date: Some(NaiveDate::from_ymd_opt(2024, 2, 1).unwrap()), + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: None, + last_page: None, + page_interval: None, + } +} + +fn make_new_title(work_id: Uuid, canonical: bool, subtitle: Option<&str>) -> NewTitle { + let title = unique("Title"); + let subtitle = subtitle.map(|s| s.to_string()); + let full_title = match &subtitle { + Some(sub) => format!("{title}: {sub}"), + None => title.clone(), + }; + + NewTitle { + work_id, + locale_code: LocaleCode::En, + full_title, + title, + subtitle, + canonical, + } +} + +fn make_new_abstract( + work_id: Uuid, + abstract_type: AbstractType, + canonical: bool, + content: &str, +) -> NewAbstract { + NewAbstract { + work_id, + content: content.to_string(), + locale_code: LocaleCode::En, + abstract_type, + canonical, + } +} + +fn make_new_contributor() -> NewContributor { + let suffix = unique("Contributor"); + NewContributor { + first_name: Some("Test".to_string()), + last_name: suffix.clone(), + full_name: format!("Test {suffix}"), + orcid: None, + website: Some("https://example.com/contributor".to_string()), + } +} + +fn make_new_contribution( + work_id: Uuid, + contributor_id: Uuid, + contribution_type: ContributionType, + contribution_ordinal: i32, +) -> NewContribution { + let suffix = unique("Contribution"); + NewContribution { + work_id, + contributor_id, + contribution_type, + main_contribution: contribution_ordinal == 1, + first_name: Some("Test".to_string()), + last_name: suffix.clone(), + full_name: format!("Test {suffix}"), + contribution_ordinal, + } +} + +fn make_new_biography(contribution_id: Uuid, canonical: bool, content: &str) -> NewBiography { + NewBiography { + contribution_id, + content: content.to_string(), + canonical, + locale_code: LocaleCode::En, + } +} + +fn make_new_institution() -> NewInstitution { + NewInstitution { + institution_name: unique("Institution"), + institution_doi: None, + ror: None, + country_code: Some(CountryCode::Gbr), + } +} + +fn make_new_funding(work_id: Uuid, institution_id: Uuid) -> NewFunding { + NewFunding { + work_id, + institution_id, + program: Some("Program".to_string()), + project_name: Some("Project".to_string()), + project_shortname: Some("Proj".to_string()), + grant_number: Some("Grant".to_string()), + } +} + +fn make_new_affiliation( + contribution_id: Uuid, + institution_id: Uuid, + affiliation_ordinal: i32, +) -> NewAffiliation { + NewAffiliation { + contribution_id, + institution_id, + affiliation_ordinal, + position: Some("Position".to_string()), + } +} + +fn make_new_series(imprint_id: Uuid) -> NewSeries { + NewSeries { + series_type: SeriesType::Journal, + series_name: unique("Series"), + issn_print: None, + issn_digital: None, + series_url: Some("https://example.com/series".to_string()), + series_description: Some("Series description".to_string()), + series_cfp_url: Some("https://example.com/cfp".to_string()), + imprint_id, + } +} + +fn make_new_issue(series_id: Uuid, work_id: Uuid, issue_ordinal: i32) -> NewIssue { + NewIssue { + series_id, + work_id, + issue_ordinal, + issue_number: None, + } +} + +fn make_new_language(work_id: Uuid) -> NewLanguage { + NewLanguage { + work_id, + language_code: LanguageCode::Eng, + language_relation: LanguageRelation::Original, + } +} + +fn make_new_publication(work_id: Uuid) -> NewPublication { + NewPublication { + publication_type: PublicationType::Paperback, + work_id, + isbn: Some(Isbn::from_str("978-3-16-148410-0").unwrap()), + width_mm: Some(100.0), + width_in: Some(3.94), + height_mm: Some(200.0), + height_in: Some(7.87), + depth_mm: Some(30.0), + depth_in: Some(1.18), + weight_g: Some(500.0), + weight_oz: Some(17.64), + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + } +} + +fn make_new_location(publication_id: Uuid, canonical: bool) -> NewLocation { + NewLocation { + publication_id, + landing_page: Some("https://example.com/location".to_string()), + full_text_url: Some("https://example.com/full".to_string()), + location_platform: LocationPlatform::Other, + canonical, + } +} + +fn make_new_price(publication_id: Uuid) -> NewPrice { + NewPrice { + publication_id, + currency_code: CurrencyCode::Usd, + unit_price: 12.34, + } +} + +fn make_new_subject(work_id: Uuid, subject_ordinal: i32) -> NewSubject { + NewSubject { + work_id, + subject_type: SubjectType::Bic, + subject_code: format!("CODE-{subject_ordinal}"), + subject_ordinal, + } +} + +fn make_new_work_relation( + relator_work_id: Uuid, + related_work_id: Uuid, + relation_ordinal: i32, +) -> NewWorkRelation { + NewWorkRelation { + relator_work_id, + related_work_id, + relation_type: RelationType::HasPart, + relation_ordinal, + } +} + +fn make_new_reference(work_id: Uuid, reference_ordinal: i32) -> NewReference { + NewReference { + work_id, + reference_ordinal, + doi: None, + unstructured_citation: Some("Citation".to_string()), + issn: None, + isbn: None, + journal_title: Some("Journal".to_string()), + article_title: Some("Article".to_string()), + series_title: None, + volume_title: None, + edition: Some(1), + author: Some("Author".to_string()), + volume: Some("1".to_string()), + issue: Some("2".to_string()), + first_page: Some("1".to_string()), + component_number: None, + standard_designator: None, + standards_body_name: None, + standards_body_acronym: None, + url: Some("https://example.com/ref".to_string()), + publication_date: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), + retrieval_date: None, + } +} + +fn make_new_contact(publisher_id: Uuid) -> NewContact { + NewContact { + publisher_id, + contact_type: ContactType::Accessibility, + email: "access@example.com".to_string(), + } +} + +struct SeedData { + publisher_id: Uuid, + publisher_org: String, + imprint_id: Uuid, + book_work_id: Uuid, + chapter_work_id: Uuid, + other_chapter_work_id: Uuid, + issue_work_id: Uuid, + issue_work_id_two: Uuid, + title_id: Uuid, + abstract_short_id: Uuid, + abstract_long_id: Uuid, + biography_id: Uuid, + contributor_id: Uuid, + contributor_id_two: Uuid, + contribution_id: Uuid, + contribution_id_two: Uuid, + series_id: Uuid, + issue_id: Uuid, + issue_id_two: Uuid, + language_id: Uuid, + publication_id: Uuid, + location_id: Uuid, + price_id: Uuid, + subject_id: Uuid, + subject_id_two: Uuid, + institution_id: Uuid, + funding_id: Uuid, + affiliation_id: Uuid, + affiliation_id_two: Uuid, + work_relation_id: Uuid, + work_relation_id_two: Uuid, + reference_id: Uuid, + reference_id_two: Uuid, + contact_id: Uuid, + book_doi: Doi, + chapter_doi: Doi, +} + +fn seed_data(schema: &Schema, context: &Context) -> SeedData { + let publisher_org = format!("org-{}", Uuid::new_v4()); + let publisher = create_with_data( + schema, + context, + "createPublisher", + "NewPublisher", + "publisherId", + make_new_publisher(&publisher_org), + ); + let publisher_id = json_uuid(&publisher["publisherId"]); + + let imprint = create_with_data( + schema, + context, + "createImprint", + "NewImprint", + "imprintId", + make_new_imprint(publisher_id), + ); + let imprint_id = json_uuid(&imprint["imprintId"]); + + let book_doi = Doi::from_str("10.1234/book").unwrap(); + let chapter_doi = Doi::from_str("10.1234/chapter").unwrap(); + let other_chapter_doi = Doi::from_str("10.1234/chapter-two").unwrap(); + let issue_doi = Doi::from_str("10.1234/issue-one").unwrap(); + let issue_doi_two = Doi::from_str("10.1234/issue-two").unwrap(); + + let book_work = create_with_data( + schema, + context, + "createWork", + "NewWork", + "workId", + make_new_book_work(imprint_id, book_doi.clone()), + ); + let book_work_id = json_uuid(&book_work["workId"]); + + let chapter_work = create_with_data( + schema, + context, + "createWork", + "NewWork", + "workId", + make_new_work(imprint_id, WorkType::BookChapter, chapter_doi.clone()), + ); + let chapter_work_id = json_uuid(&chapter_work["workId"]); + + let other_chapter_work = create_with_data( + schema, + context, + "createWork", + "NewWork", + "workId", + make_new_work(imprint_id, WorkType::BookChapter, other_chapter_doi), + ); + let other_chapter_work_id = json_uuid(&other_chapter_work["workId"]); + + let issue_work = create_with_data( + schema, + context, + "createWork", + "NewWork", + "workId", + make_new_work(imprint_id, WorkType::JournalIssue, issue_doi), + ); + let issue_work_id = json_uuid(&issue_work["workId"]); + + let issue_work_two = create_with_data( + schema, + context, + "createWork", + "NewWork", + "workId", + make_new_work(imprint_id, WorkType::JournalIssue, issue_doi_two), + ); + let issue_work_id_two = json_uuid(&issue_work_two["workId"]); + + let title = create_with_data_and_markup( + schema, + context, + "createTitle", + "NewTitle", + "titleId", + make_new_title(book_work_id, true, Some("Subtitle")), + MarkupFormat::PlainText, + ); + let title_id = json_uuid(&title["titleId"]); + + let abstract_short = create_with_data_and_markup( + schema, + context, + "createAbstract", + "NewAbstract", + "abstractId", + make_new_abstract(book_work_id, AbstractType::Short, true, "Short abstract"), + MarkupFormat::PlainText, + ); + let abstract_short_id = json_uuid(&abstract_short["abstractId"]); + + let abstract_long = create_with_data_and_markup( + schema, + context, + "createAbstract", + "NewAbstract", + "abstractId", + make_new_abstract(book_work_id, AbstractType::Long, false, "Long abstract"), + MarkupFormat::PlainText, + ); + let abstract_long_id = json_uuid(&abstract_long["abstractId"]); + + let contributor = create_with_data( + schema, + context, + "createContributor", + "NewContributor", + "contributorId", + make_new_contributor(), + ); + let contributor_id = json_uuid(&contributor["contributorId"]); + + let contributor_two = create_with_data( + schema, + context, + "createContributor", + "NewContributor", + "contributorId", + make_new_contributor(), + ); + let contributor_id_two = json_uuid(&contributor_two["contributorId"]); + + let contribution = create_with_data( + schema, + context, + "createContribution", + "NewContribution", + "contributionId", + make_new_contribution(book_work_id, contributor_id, ContributionType::Author, 1), + ); + let contribution_id = json_uuid(&contribution["contributionId"]); + + let contribution_two = create_with_data( + schema, + context, + "createContribution", + "NewContribution", + "contributionId", + make_new_contribution( + book_work_id, + contributor_id_two, + ContributionType::Editor, + 2, + ), + ); + let contribution_id_two = json_uuid(&contribution_two["contributionId"]); + + let biography = create_with_data_and_markup( + schema, + context, + "createBiography", + "NewBiography", + "biographyId", + make_new_biography(contribution_id, true, "Biography content"), + MarkupFormat::PlainText, + ); + let biography_id = json_uuid(&biography["biographyId"]); + + let institution = create_with_data( + schema, + context, + "createInstitution", + "NewInstitution", + "institutionId", + make_new_institution(), + ); + let institution_id = json_uuid(&institution["institutionId"]); + + let funding = create_with_data( + schema, + context, + "createFunding", + "NewFunding", + "fundingId", + make_new_funding(book_work_id, institution_id), + ); + let funding_id = json_uuid(&funding["fundingId"]); + + let affiliation = create_with_data( + schema, + context, + "createAffiliation", + "NewAffiliation", + "affiliationId", + make_new_affiliation(contribution_id, institution_id, 1), + ); + let affiliation_id = json_uuid(&affiliation["affiliationId"]); + + let affiliation_two = create_with_data( + schema, + context, + "createAffiliation", + "NewAffiliation", + "affiliationId", + make_new_affiliation(contribution_id, institution_id, 2), + ); + let affiliation_id_two = json_uuid(&affiliation_two["affiliationId"]); + + let series = create_with_data( + schema, + context, + "createSeries", + "NewSeries", + "seriesId", + make_new_series(imprint_id), + ); + let series_id = json_uuid(&series["seriesId"]); + + let issue = create_with_data( + schema, + context, + "createIssue", + "NewIssue", + "issueId", + make_new_issue(series_id, issue_work_id, 1), + ); + let issue_id = json_uuid(&issue["issueId"]); + + let issue_two = create_with_data( + schema, + context, + "createIssue", + "NewIssue", + "issueId", + make_new_issue(series_id, issue_work_id_two, 2), + ); + let issue_id_two = json_uuid(&issue_two["issueId"]); + + let language = create_with_data( + schema, + context, + "createLanguage", + "NewLanguage", + "languageId", + make_new_language(book_work_id), + ); + let language_id = json_uuid(&language["languageId"]); + + let publication = create_with_data( + schema, + context, + "createPublication", + "NewPublication", + "publicationId", + make_new_publication(book_work_id), + ); + let publication_id = json_uuid(&publication["publicationId"]); + + let location = create_with_data( + schema, + context, + "createLocation", + "NewLocation", + "locationId", + make_new_location(publication_id, true), + ); + let location_id = json_uuid(&location["locationId"]); + + let price = create_with_data( + schema, + context, + "createPrice", + "NewPrice", + "priceId", + make_new_price(publication_id), + ); + let price_id = json_uuid(&price["priceId"]); + + let subject = create_with_data( + schema, + context, + "createSubject", + "NewSubject", + "subjectId", + make_new_subject(book_work_id, 1), + ); + let subject_id = json_uuid(&subject["subjectId"]); + + let subject_two = create_with_data( + schema, + context, + "createSubject", + "NewSubject", + "subjectId", + make_new_subject(book_work_id, 2), + ); + let subject_id_two = json_uuid(&subject_two["subjectId"]); + + let work_relation = create_with_data( + schema, + context, + "createWorkRelation", + "NewWorkRelation", + "workRelationId", + make_new_work_relation(book_work_id, chapter_work_id, 1), + ); + let work_relation_id = json_uuid(&work_relation["workRelationId"]); + + let work_relation_two = create_with_data( + schema, + context, + "createWorkRelation", + "NewWorkRelation", + "workRelationId", + make_new_work_relation(book_work_id, other_chapter_work_id, 2), + ); + let work_relation_id_two = json_uuid(&work_relation_two["workRelationId"]); + + let reference = create_with_data( + schema, + context, + "createReference", + "NewReference", + "referenceId", + make_new_reference(book_work_id, 1), + ); + let reference_id = json_uuid(&reference["referenceId"]); + + let reference_two = create_with_data( + schema, + context, + "createReference", + "NewReference", + "referenceId", + make_new_reference(book_work_id, 2), + ); + let reference_id_two = json_uuid(&reference_two["referenceId"]); + + let contact = create_with_data( + schema, + context, + "createContact", + "NewContact", + "contactId", + make_new_contact(publisher_id), + ); + let contact_id = json_uuid(&contact["contactId"]); + + SeedData { + publisher_id, + publisher_org, + imprint_id, + book_work_id, + chapter_work_id, + other_chapter_work_id, + issue_work_id, + issue_work_id_two, + title_id, + abstract_short_id, + abstract_long_id, + biography_id, + contributor_id, + contributor_id_two, + contribution_id, + contribution_id_two, + series_id, + issue_id, + issue_id_two, + language_id, + publication_id, + location_id, + price_id, + subject_id, + subject_id_two, + institution_id, + funding_id, + affiliation_id, + affiliation_id_two, + work_relation_id, + work_relation_id_two, + reference_id, + reference_id_two, + contact_id, + book_doi, + chapter_doi, + } +} + +fn patch_publisher(publisher: &Publisher) -> PatchPublisher { + PatchPublisher { + publisher_id: publisher.publisher_id, + publisher_name: format!("{} Updated", publisher.publisher_name), + publisher_shortname: publisher.publisher_shortname.clone(), + publisher_url: publisher.publisher_url.clone(), + zitadel_id: publisher.zitadel_id.clone(), + accessibility_statement: publisher.accessibility_statement.clone(), + accessibility_report_url: publisher.accessibility_report_url.clone(), + } +} + +fn patch_imprint(imprint: &Imprint) -> PatchImprint { + PatchImprint { + imprint_id: imprint.imprint_id, + publisher_id: imprint.publisher_id, + imprint_name: format!("{} Updated", imprint.imprint_name), + imprint_url: imprint.imprint_url.clone(), + crossmark_doi: imprint.crossmark_doi.clone(), + s3_bucket: imprint.s3_bucket.clone(), + cdn_domain: imprint.cdn_domain.clone(), + cloudfront_dist_id: imprint.cloudfront_dist_id.clone(), + default_currency: imprint.default_currency, + default_place: imprint.default_place.clone(), + default_locale: imprint.default_locale, + } +} + +fn patch_contributor(contributor: &Contributor) -> PatchContributor { + PatchContributor { + contributor_id: contributor.contributor_id, + first_name: contributor.first_name.clone(), + last_name: contributor.last_name.clone(), + full_name: format!("{} Updated", contributor.full_name), + orcid: contributor.orcid.clone(), + website: contributor.website.clone(), + } +} + +fn patch_contribution(contribution: &Contribution) -> PatchContribution { + PatchContribution { + contribution_id: contribution.contribution_id, + work_id: contribution.work_id, + contributor_id: contribution.contributor_id, + contribution_type: contribution.contribution_type, + main_contribution: contribution.main_contribution, + first_name: contribution.first_name.clone(), + last_name: contribution.last_name.clone(), + full_name: format!("{} Updated", contribution.full_name), + contribution_ordinal: contribution.contribution_ordinal, + } +} + +fn patch_publication(publication: &Publication) -> PatchPublication { + PatchPublication { + publication_id: publication.publication_id, + publication_type: publication.publication_type, + work_id: publication.work_id, + isbn: publication.isbn.clone(), + width_mm: publication.width_mm.map(|w| w + 1.0), + width_in: publication.width_in, + height_mm: publication.height_mm, + height_in: publication.height_in, + depth_mm: publication.depth_mm, + depth_in: publication.depth_in, + weight_g: publication.weight_g, + weight_oz: publication.weight_oz, + accessibility_standard: publication.accessibility_standard, + accessibility_additional_standard: publication.accessibility_additional_standard, + accessibility_exception: publication.accessibility_exception, + accessibility_report_url: publication.accessibility_report_url.clone(), + } +} + +fn patch_series(series: &Series) -> PatchSeries { + PatchSeries { + series_id: series.series_id, + series_type: series.series_type, + series_name: format!("{} Updated", series.series_name), + issn_print: series.issn_print.clone(), + issn_digital: series.issn_digital.clone(), + series_url: series.series_url.clone(), + series_description: series.series_description.clone(), + series_cfp_url: series.series_cfp_url.clone(), + imprint_id: series.imprint_id, + } +} + +fn patch_issue(issue: &Issue) -> PatchIssue { + PatchIssue { + issue_id: issue.issue_id, + series_id: issue.series_id, + work_id: issue.work_id, + issue_ordinal: issue.issue_ordinal, + issue_number: issue.issue_number, + } +} + +fn patch_language(language: &Language) -> PatchLanguage { + PatchLanguage { + language_id: language.language_id, + work_id: language.work_id, + language_code: language.language_code, + language_relation: language.language_relation, + } +} + +fn patch_institution(institution: &Institution) -> PatchInstitution { + PatchInstitution { + institution_id: institution.institution_id, + institution_name: format!("{} Updated", institution.institution_name), + institution_doi: institution.institution_doi.clone(), + ror: institution.ror.clone(), + country_code: institution.country_code, + } +} + +fn patch_funding(funding: &Funding) -> PatchFunding { + PatchFunding { + funding_id: funding.funding_id, + work_id: funding.work_id, + institution_id: funding.institution_id, + program: funding.program.clone(), + project_name: funding.project_name.clone(), + project_shortname: funding.project_shortname.clone(), + grant_number: funding.grant_number.clone(), + } +} + +fn patch_location(location: &Location) -> PatchLocation { + PatchLocation { + location_id: location.location_id, + publication_id: location.publication_id, + landing_page: location + .landing_page + .as_ref() + .map(|url| format!("{url}?updated=1")), + full_text_url: location.full_text_url.clone(), + location_platform: location.location_platform, + canonical: location.canonical, + } +} + +fn patch_price(price: &Price) -> PatchPrice { + PatchPrice { + price_id: price.price_id, + publication_id: price.publication_id, + currency_code: price.currency_code, + unit_price: price.unit_price + 1.0, + } +} + +fn patch_subject(subject: &Subject) -> PatchSubject { + PatchSubject { + subject_id: subject.subject_id, + work_id: subject.work_id, + subject_type: subject.subject_type, + subject_code: format!("{}-UPDATED", subject.subject_code), + subject_ordinal: subject.subject_ordinal, + } +} + +fn patch_affiliation(affiliation: &Affiliation) -> PatchAffiliation { + PatchAffiliation { + affiliation_id: affiliation.affiliation_id, + contribution_id: affiliation.contribution_id, + institution_id: affiliation.institution_id, + affiliation_ordinal: affiliation.affiliation_ordinal, + position: affiliation.position.clone(), + } +} + +fn patch_work_relation(work_relation: &WorkRelation) -> PatchWorkRelation { + PatchWorkRelation { + work_relation_id: work_relation.work_relation_id, + relator_work_id: work_relation.relator_work_id, + related_work_id: work_relation.related_work_id, + relation_type: work_relation.relation_type, + relation_ordinal: work_relation.relation_ordinal, + } +} + +fn patch_reference(reference: &Reference) -> PatchReference { + PatchReference { + reference_id: reference.reference_id, + work_id: reference.work_id, + reference_ordinal: reference.reference_ordinal, + doi: reference.doi.clone(), + unstructured_citation: reference.unstructured_citation.clone(), + issn: reference.issn.clone(), + isbn: reference.isbn.clone(), + journal_title: reference.journal_title.clone(), + article_title: reference.article_title.clone(), + series_title: reference.series_title.clone(), + volume_title: reference.volume_title.clone(), + edition: reference.edition, + author: reference.author.clone(), + volume: reference.volume.clone(), + issue: reference.issue.clone(), + first_page: reference.first_page.clone(), + component_number: reference.component_number.clone(), + standard_designator: reference.standard_designator.clone(), + standards_body_name: reference.standards_body_name.clone(), + standards_body_acronym: reference.standards_body_acronym.clone(), + url: reference.url.clone(), + publication_date: reference.publication_date, + retrieval_date: reference.retrieval_date, + } +} + +fn patch_contact(contact: &Contact) -> PatchContact { + PatchContact { + contact_id: contact.contact_id, + publisher_id: contact.publisher_id, + contact_type: contact.contact_type, + email: format!("updated-{}", contact.email), + } +} + +fn patch_title(title: &Title) -> PatchTitle { + PatchTitle { + title_id: title.title_id, + work_id: title.work_id, + locale_code: title.locale_code, + full_title: format!("{} Updated", title.full_title), + title: format!("{} Updated", title.title), + subtitle: title.subtitle.clone(), + canonical: title.canonical, + } +} + +fn patch_abstract(abstract_item: &Abstract) -> PatchAbstract { + PatchAbstract { + abstract_id: abstract_item.abstract_id, + work_id: abstract_item.work_id, + content: format!("{} Updated", abstract_item.content), + locale_code: abstract_item.locale_code, + abstract_type: abstract_item.abstract_type, + canonical: abstract_item.canonical, + } +} + +fn patch_biography(biography: &Biography) -> PatchBiography { + PatchBiography { + biography_id: biography.biography_id, + contribution_id: biography.contribution_id, + content: format!("{} Updated", biography.content), + canonical: biography.canonical, + locale_code: biography.locale_code, + } +} + +fn assert_work_resolvers( + work: &Work, + context: &Context, + title: &Title, + short_abs: &Abstract, + long_abs: &Abstract, + expected_imprint_id: Uuid, +) { + assert_eq!(work.work_id(), &work.work_id); + assert_eq!(work.work_type(), &work.work_type); + assert_eq!(work.work_status(), &work.work_status); + assert_eq!(work.full_title(context).unwrap(), title.full_title); + assert_eq!(work.title(context).unwrap(), title.title); + assert_eq!(work.subtitle(context).unwrap(), title.subtitle); + let expected_short = short_abs.canonical.then(|| short_abs.content.clone()); + let expected_long = long_abs.canonical.then(|| long_abs.content.clone()); + assert_eq!(work.short_abstract(context).unwrap(), expected_short); + assert_eq!(work.long_abstract(context).unwrap(), expected_long); + assert_eq!(work.reference(), work.reference.as_ref()); + assert_eq!(work.edition(), work.edition.as_ref()); + assert_eq!(work.imprint_id(), work.imprint_id); + assert_eq!(work.doi(), work.doi.as_ref()); + assert_eq!(work.publication_date(), work.publication_date); + assert_eq!(work.withdrawn_date(), work.withdrawn_date); + assert_eq!(work.place(), work.place.as_ref()); + assert_eq!(work.page_count(), work.page_count.as_ref()); + assert_eq!(work.page_breakdown(), work.page_breakdown.as_ref()); + assert_eq!(work.image_count(), work.image_count.as_ref()); + assert_eq!(work.table_count(), work.table_count.as_ref()); + assert_eq!(work.audio_count(), work.audio_count.as_ref()); + assert_eq!(work.video_count(), work.video_count.as_ref()); + assert_eq!(work.license(), work.license.as_ref()); + assert_eq!(work.copyright_holder(), work.copyright_holder.as_ref()); + assert_eq!(work.landing_page(), work.landing_page.as_ref()); + assert_eq!(work.lccn(), work.lccn.as_ref()); + assert_eq!(work.oclc(), work.oclc.as_ref()); + assert_eq!(work.general_note(), work.general_note.as_ref()); + assert_eq!(work.bibliography_note(), work.bibliography_note.as_ref()); + assert_eq!(work.toc(), work.toc.as_ref()); + assert_eq!(work.cover_url(), work.cover_url.as_ref()); + assert_eq!(work.cover_caption(), work.cover_caption.as_ref()); + assert_eq!(work.created_at(), work.created_at); + assert_eq!(work.updated_at(), work.updated_at); + assert_eq!(work.first_page(), work.first_page.as_ref()); + assert_eq!(work.last_page(), work.last_page.as_ref()); + assert_eq!(work.page_interval(), work.page_interval.as_ref()); + assert_eq!( + work.updated_at_with_relations(), + work.updated_at_with_relations + ); + + let imprint = work.imprint(context).unwrap(); + assert_eq!(imprint.imprint_id, expected_imprint_id); + + assert!(!work + .contributions(context, Some(10), Some(0), None, None) + .unwrap() + .is_empty()); + assert!(!work + .languages( + context, + Some(10), + Some(0), + None, + None, + Some(LanguageRelation::Original), + None + ) + .unwrap() + .is_empty()); + assert!(!work + .publications(context, Some(10), Some(0), None, None, None) + .unwrap() + .is_empty()); + assert!(!work + .subjects(context, Some(10), Some(0), None, None, None) + .unwrap() + .is_empty()); + assert!(!work + .fundings(context, Some(10), Some(0), None) + .unwrap() + .is_empty()); + let _ = work.issues(context, Some(10), Some(0), None).unwrap(); + assert!(!work + .relations(context, Some(10), Some(0), None, None) + .unwrap() + .is_empty()); + assert!(!work + .references(context, Some(10), Some(0), None, None) + .unwrap() + .is_empty()); +} + +fn assert_publication_resolvers(publication: &Publication, context: &Context) { + assert_eq!(publication.publication_id(), publication.publication_id); + assert_eq!( + publication.publication_type(), + &publication.publication_type + ); + assert_eq!(publication.work_id(), publication.work_id); + assert_eq!(publication.isbn(), publication.isbn.as_ref()); + assert_eq!(publication.created_at(), publication.created_at); + assert_eq!(publication.updated_at(), publication.updated_at); + assert_eq!(publication.width(LengthUnit::Mm), publication.width_mm); + assert_eq!(publication.width(LengthUnit::In), publication.width_in); + assert_eq!( + publication.width(LengthUnit::Cm), + publication + .width_mm + .map(|w| w.convert_length_from_to(&LengthUnit::Mm, &LengthUnit::Cm)) + ); + assert_eq!(publication.height(LengthUnit::Mm), publication.height_mm); + assert_eq!(publication.height(LengthUnit::In), publication.height_in); + assert_eq!( + publication.height(LengthUnit::Cm), + publication + .height_mm + .map(|w| w.convert_length_from_to(&LengthUnit::Mm, &LengthUnit::Cm)) + ); + assert_eq!(publication.depth(LengthUnit::Mm), publication.depth_mm); + assert_eq!(publication.depth(LengthUnit::In), publication.depth_in); + assert_eq!( + publication.depth(LengthUnit::Cm), + publication + .depth_mm + .map(|w| w.convert_length_from_to(&LengthUnit::Mm, &LengthUnit::Cm)) + ); + assert_eq!(publication.weight(WeightUnit::G), publication.weight_g); + assert_eq!(publication.weight(WeightUnit::Oz), publication.weight_oz); + assert_eq!( + publication.accessibility_standard(), + publication.accessibility_standard.as_ref() + ); + assert_eq!( + publication.accessibility_additional_standard(), + publication.accessibility_additional_standard.as_ref() + ); + assert_eq!( + publication.accessibility_exception(), + publication.accessibility_exception.as_ref() + ); + assert_eq!( + publication.accessibility_report_url(), + publication.accessibility_report_url.as_ref() + ); + assert!(!publication + .prices(context, Some(10), Some(0), None, None) + .unwrap() + .is_empty()); + assert!(!publication + .locations(context, Some(10), Some(0), None, None) + .unwrap() + .is_empty()); + let work = publication.work(context).unwrap(); + assert_eq!(work.work_id, publication.work_id); +} + +fn assert_publisher_resolvers(publisher: &Publisher, context: &Context) { + assert_eq!(publisher.publisher_id(), publisher.publisher_id); + assert_eq!(publisher.publisher_name(), &publisher.publisher_name); + assert_eq!( + publisher.publisher_shortname(), + publisher.publisher_shortname.as_ref() + ); + assert_eq!(publisher.publisher_url(), publisher.publisher_url.as_ref()); + assert_eq!(publisher.zitadel_id(), publisher.zitadel_id.as_ref()); + assert_eq!( + publisher.accessibility_statement(), + publisher.accessibility_statement.as_ref() + ); + assert_eq!( + publisher.accessibility_report_url(), + publisher.accessibility_report_url.as_ref() + ); + assert_eq!(publisher.created_at(), publisher.created_at); + assert_eq!(publisher.updated_at(), publisher.updated_at); + assert!(!publisher + .imprints(context, Some(10), Some(0), None, None) + .unwrap() + .is_empty()); + assert!(!publisher + .contacts(context, Some(10), Some(0), None, None) + .unwrap() + .is_empty()); +} + +fn assert_imprint_resolvers(imprint: &Imprint, context: &Context) { + assert_eq!(imprint.imprint_id(), imprint.imprint_id); + assert_eq!(imprint.publisher_id(), imprint.publisher_id); + assert_eq!(imprint.imprint_name(), &imprint.imprint_name); + assert_eq!(imprint.imprint_url(), imprint.imprint_url.as_ref()); + assert_eq!(imprint.crossmark_doi(), imprint.crossmark_doi.as_ref()); + assert_eq!(imprint.created_at(), imprint.created_at); + assert_eq!(imprint.updated_at(), imprint.updated_at); + let publisher = imprint.publisher(context).unwrap(); + assert_eq!(publisher.publisher_id, imprint.publisher_id); + assert!(!imprint + .works( + context, + Some(10), + Some(0), + None, + None, + None, + Some(WorkStatus::Active), + None, + None, + None + ) + .unwrap() + .is_empty()); +} + +fn assert_contributor_resolvers(contributor: &Contributor, context: &Context) { + assert_eq!(contributor.contributor_id(), contributor.contributor_id); + assert_eq!(contributor.first_name(), contributor.first_name.as_ref()); + assert_eq!(contributor.last_name(), &contributor.last_name); + assert_eq!(contributor.full_name(), &contributor.full_name); + assert_eq!(contributor.orcid(), contributor.orcid.as_ref()); + assert_eq!(contributor.website(), contributor.website.as_ref()); + assert_eq!(contributor.created_at(), contributor.created_at); + assert_eq!(contributor.updated_at(), contributor.updated_at); + assert!(!contributor + .contributions(context, Some(10), Some(0), None, None) + .unwrap() + .is_empty()); +} + +fn assert_contribution_resolvers( + contribution: &Contribution, + context: &Context, + biography_content: &str, +) { + assert_eq!(contribution.contribution_id(), contribution.contribution_id); + assert_eq!(contribution.contributor_id(), contribution.contributor_id); + assert_eq!(contribution.work_id(), contribution.work_id); + assert_eq!( + contribution.contribution_type(), + &contribution.contribution_type + ); + assert_eq!( + contribution.main_contribution(), + contribution.main_contribution + ); + assert!(!contribution + .biographies( + context, + Some(10), + Some(0), + None, + None, + None, + Some(MarkupFormat::PlainText) + ) + .unwrap() + .is_empty()); + let biography = contribution.biography(context).unwrap(); + assert_eq!(biography, Some(biography_content.to_string())); + assert_eq!(contribution.created_at(), contribution.created_at); + assert_eq!(contribution.updated_at(), contribution.updated_at); + assert_eq!(contribution.first_name(), contribution.first_name.as_ref()); + assert_eq!(contribution.last_name(), &contribution.last_name); + assert_eq!(contribution.full_name(), &contribution.full_name); + assert_eq!( + contribution.contribution_ordinal(), + &contribution.contribution_ordinal + ); + let work = contribution.work(context).unwrap(); + assert_eq!(work.work_id, contribution.work_id); + let contributor = contribution.contributor(context).unwrap(); + assert_eq!(contributor.contributor_id, contribution.contributor_id); + assert!(!contribution + .affiliations(context, Some(10), Some(0), None) + .unwrap() + .is_empty()); +} + +fn assert_series_resolvers(series: &Series, context: &Context) { + assert_eq!(series.series_id(), series.series_id); + assert_eq!(series.series_type(), &series.series_type); + assert_eq!(series.series_name(), &series.series_name); + assert_eq!(series.issn_print(), series.issn_print.as_ref()); + assert_eq!(series.issn_digital(), series.issn_digital.as_ref()); + assert_eq!(series.series_url(), series.series_url.as_ref()); + assert_eq!( + series.series_description(), + series.series_description.as_ref() + ); + assert_eq!(series.series_cfp_url(), series.series_cfp_url.as_ref()); + assert_eq!(series.imprint_id(), series.imprint_id); + assert_eq!(series.created_at(), series.created_at); + assert_eq!(series.updated_at(), series.updated_at); + let imprint = series.imprint(context).unwrap(); + assert_eq!(imprint.imprint_id, series.imprint_id); + assert!(!series + .issues(context, Some(10), Some(0), None) + .unwrap() + .is_empty()); +} + +fn assert_issue_resolvers(issue: &Issue, context: &Context) { + assert_eq!(issue.issue_id(), issue.issue_id); + assert_eq!(issue.work_id(), issue.work_id); + assert_eq!(issue.series_id(), issue.series_id); + assert_eq!(issue.issue_ordinal(), &issue.issue_ordinal); + assert_eq!(issue.issue_number(), issue.issue_number.as_ref()); + assert_eq!(issue.created_at(), issue.created_at); + assert_eq!(issue.updated_at(), issue.updated_at); + let series = issue.series(context).unwrap(); + assert_eq!(series.series_id, issue.series_id); + let work = issue.work(context).unwrap(); + assert_eq!(work.work_id, issue.work_id); +} + +fn assert_language_resolvers(language: &Language, context: &Context) { + assert_eq!(language.language_id(), language.language_id); + assert_eq!(language.work_id(), language.work_id); + assert_eq!(language.language_code(), &language.language_code); + assert_eq!(language.language_relation(), &language.language_relation); + assert_eq!(language.created_at(), language.created_at); + assert_eq!(language.updated_at(), language.updated_at); + let work = language.work(context).unwrap(); + assert_eq!(work.work_id, language.work_id); +} + +fn assert_location_resolvers(location: &Location, context: &Context) { + assert_eq!(location.location_id(), location.location_id); + assert_eq!(location.publication_id(), location.publication_id); + assert_eq!(location.landing_page(), location.landing_page.as_ref()); + assert_eq!(location.full_text_url(), location.full_text_url.as_ref()); + assert_eq!(location.location_platform(), &location.location_platform); + assert_eq!(location.canonical(), location.canonical); + assert_eq!(location.created_at(), location.created_at); + assert_eq!(location.updated_at(), location.updated_at); + let publication = location.publication(context).unwrap(); + assert_eq!(publication.publication_id, location.publication_id); +} + +fn assert_price_resolvers(price: &Price, context: &Context) { + assert_eq!(price.price_id(), price.price_id); + assert_eq!(price.publication_id(), price.publication_id); + assert_eq!(price.currency_code(), &price.currency_code); + assert_eq!(price.unit_price(), price.unit_price); + assert_eq!(price.created_at(), price.created_at); + assert_eq!(price.updated_at(), price.updated_at); + let publication = price.publication(context).unwrap(); + assert_eq!(publication.publication_id, price.publication_id); +} + +fn assert_subject_resolvers(subject: &Subject, context: &Context) { + assert_eq!(subject.subject_id(), &subject.subject_id); + assert_eq!(subject.work_id(), &subject.work_id); + assert_eq!(subject.subject_type(), &subject.subject_type); + assert_eq!(subject.subject_code(), &subject.subject_code); + assert_eq!(subject.subject_ordinal(), &subject.subject_ordinal); + assert_eq!(subject.created_at(), subject.created_at); + assert_eq!(subject.updated_at(), subject.updated_at); + let work = subject.work(context).unwrap(); + assert_eq!(work.work_id, subject.work_id); +} + +fn assert_institution_resolvers(institution: &Institution, context: &Context) { + assert_eq!(institution.institution_id(), &institution.institution_id); + assert_eq!( + institution.institution_name(), + &institution.institution_name + ); + assert_eq!( + institution.institution_doi(), + institution.institution_doi.as_ref() + ); + assert_eq!( + institution.country_code(), + institution.country_code.as_ref() + ); + assert_eq!(institution.ror(), institution.ror.as_ref()); + assert_eq!(institution.created_at(), institution.created_at); + assert_eq!(institution.updated_at(), institution.updated_at); + assert!(!institution + .fundings(context, Some(10), Some(0), None) + .unwrap() + .is_empty()); + assert!(!institution + .affiliations(context, Some(10), Some(0), None) + .unwrap() + .is_empty()); +} + +fn assert_funding_resolvers(funding: &Funding, context: &Context) { + assert_eq!(funding.funding_id(), &funding.funding_id); + assert_eq!(funding.work_id(), &funding.work_id); + assert_eq!(funding.institution_id(), &funding.institution_id); + assert_eq!(funding.program(), funding.program.as_ref()); + assert_eq!(funding.project_name(), funding.project_name.as_ref()); + assert_eq!( + funding.project_shortname(), + funding.project_shortname.as_ref() + ); + assert_eq!(funding.grant_number(), funding.grant_number.as_ref()); + assert_eq!(funding.created_at(), funding.created_at); + assert_eq!(funding.updated_at(), funding.updated_at); + let work = funding.work(context).unwrap(); + assert_eq!(work.work_id, funding.work_id); + let institution = funding.institution(context).unwrap(); + assert_eq!(institution.institution_id, funding.institution_id); +} + +fn assert_affiliation_resolvers(affiliation: &Affiliation, context: &Context) { + assert_eq!(affiliation.affiliation_id(), affiliation.affiliation_id); + assert_eq!(affiliation.contribution_id(), affiliation.contribution_id); + assert_eq!(affiliation.institution_id(), affiliation.institution_id); + assert_eq!( + affiliation.affiliation_ordinal(), + &affiliation.affiliation_ordinal + ); + assert_eq!(affiliation.position(), affiliation.position.as_ref()); + assert_eq!(affiliation.created_at(), affiliation.created_at); + assert_eq!(affiliation.updated_at(), affiliation.updated_at); + let institution = affiliation.institution(context).unwrap(); + assert_eq!(institution.institution_id, affiliation.institution_id); + let contribution = affiliation.contribution(context).unwrap(); + assert_eq!(contribution.contribution_id, affiliation.contribution_id); +} + +fn assert_work_relation_resolvers(work_relation: &WorkRelation, context: &Context) { + assert_eq!( + work_relation.work_relation_id(), + &work_relation.work_relation_id + ); + assert_eq!( + work_relation.relator_work_id(), + &work_relation.relator_work_id + ); + assert_eq!( + work_relation.related_work_id(), + &work_relation.related_work_id + ); + assert_eq!(work_relation.relation_type(), &work_relation.relation_type); + assert_eq!( + work_relation.relation_ordinal(), + &work_relation.relation_ordinal + ); + assert_eq!(work_relation.created_at(), work_relation.created_at); + assert_eq!(work_relation.updated_at(), work_relation.updated_at); + let related = work_relation.related_work(context).unwrap(); + assert_eq!(related.work_id, work_relation.related_work_id); +} + +fn assert_reference_resolvers(reference: &Reference, context: &Context) { + assert_eq!(reference.reference_id(), reference.reference_id); + assert_eq!(reference.work_id(), reference.work_id); + assert_eq!(reference.reference_ordinal(), &reference.reference_ordinal); + assert_eq!(reference.doi(), reference.doi.as_ref()); + assert_eq!( + reference.unstructured_citation(), + reference.unstructured_citation.as_ref() + ); + assert_eq!(reference.issn(), reference.issn.as_ref()); + assert_eq!(reference.isbn(), reference.isbn.as_ref()); + assert_eq!(reference.journal_title(), reference.journal_title.as_ref()); + assert_eq!(reference.article_title(), reference.article_title.as_ref()); + assert_eq!(reference.series_title(), reference.series_title.as_ref()); + assert_eq!(reference.volume_title(), reference.volume_title.as_ref()); + assert_eq!(reference.edition(), reference.edition.as_ref()); + assert_eq!(reference.author(), reference.author.as_ref()); + assert_eq!(reference.volume(), reference.volume.as_ref()); + assert_eq!(reference.issue(), reference.issue.as_ref()); + assert_eq!(reference.first_page(), reference.first_page.as_ref()); + assert_eq!( + reference.component_number(), + reference.component_number.as_ref() + ); + assert_eq!( + reference.standard_designator(), + reference.standard_designator.as_ref() + ); + assert_eq!( + reference.standards_body_name(), + reference.standards_body_name.as_ref() + ); + assert_eq!( + reference.standards_body_acronym(), + reference.standards_body_acronym.as_ref() + ); + assert_eq!(reference.url(), reference.url.as_ref()); + assert_eq!(reference.publication_date(), reference.publication_date); + assert_eq!(reference.retrieval_date(), reference.retrieval_date); + assert_eq!(reference.created_at(), reference.created_at); + assert_eq!(reference.updated_at(), reference.updated_at); + let work = reference.work(context).unwrap(); + assert_eq!(work.work_id, reference.work_id); +} + +fn assert_title_resolvers(title: &Title, context: &Context) { + assert_eq!(title.title_id(), title.title_id); + assert_eq!(title.work_id(), title.work_id); + assert_eq!(title.locale_code(), &title.locale_code); + assert_eq!(title.full_title(), &title.full_title); + assert_eq!(title.title(), &title.title); + assert_eq!(title.subtitle(), title.subtitle.as_ref()); + assert_eq!(title.canonical(), title.canonical); + let work = title.work(context).unwrap(); + assert_eq!(work.work_id, title.work_id); +} + +fn assert_abstract_resolvers(abstract_item: &Abstract, context: &Context) { + assert_eq!(abstract_item.abstract_id(), abstract_item.abstract_id); + assert_eq!(abstract_item.work_id(), abstract_item.work_id); + assert_eq!(abstract_item.locale_code(), &abstract_item.locale_code); + assert_eq!(abstract_item.content(), &abstract_item.content); + assert_eq!(abstract_item.canonical(), abstract_item.canonical); + assert_eq!(abstract_item.abstract_type(), &abstract_item.abstract_type); + let work = abstract_item.work(context).unwrap(); + assert_eq!(work.work_id, abstract_item.work_id); +} + +fn assert_biography_resolvers(biography: &Biography, context: &Context, expected_work_id: Uuid) { + assert_eq!(biography.biography_id(), biography.biography_id); + assert_eq!(biography.contribution_id(), biography.contribution_id); + assert_eq!(biography.locale_code(), &biography.locale_code); + assert_eq!(biography.content(), &biography.content); + assert_eq!(biography.canonical(), biography.canonical); + let work = biography.work(context).unwrap(); + assert_eq!(work.work_id, expected_work_id); + let contribution = biography.contribution(context).unwrap(); + assert_eq!(contribution.contribution_id, biography.contribution_id); +} + +fn assert_contact_resolvers(contact: &Contact, context: &Context) { + assert_eq!(contact.contact_id(), contact.contact_id); + assert_eq!(contact.publisher_id(), contact.publisher_id); + assert_eq!(contact.contact_type(), &contact.contact_type); + assert_eq!(contact.email(), &contact.email); + assert_eq!(contact.created_at(), contact.created_at); + assert_eq!(contact.updated_at(), contact.updated_at); + let publisher = contact.publisher(context).unwrap(); + assert_eq!(publisher.publisher_id, contact.publisher_id); +} + +#[test] +fn graphql_query_and_model_resolvers_cover_all() { + let (_guard, pool) = test_db::setup_test_db(); + let schema = create_schema(); + let superuser = test_db::test_superuser("user-1"); + let context = test_db::test_context_with_user(pool.clone(), superuser); + + let seed = seed_data(&schema, &context); + + let query = r#" +query Root( + $workId: Uuid!, + $bookDoi: Doi!, + $chapterDoi: Doi!, + $publicationId: Uuid!, + $publisherId: Uuid!, + $imprintId: Uuid!, + $contributorId: Uuid!, + $contributionId: Uuid!, + $seriesId: Uuid!, + $issueId: Uuid!, + $languageId: Uuid!, + $locationId: Uuid!, + $priceId: Uuid!, + $subjectId: Uuid!, + $institutionId: Uuid!, + $fundingId: Uuid!, + $affiliationId: Uuid!, + $referenceId: Uuid!, + $titleId: Uuid!, + $abstractId: Uuid!, + $biographyId: Uuid!, + $contactId: Uuid!, + $workStatus: WorkStatus!, + $titleMarkup: MarkupFormat!, + $abstractMarkup: MarkupFormat!, + $biographyMarkup: MarkupFormat! +) { + works(limit: 10, workStatus: $workStatus) { workId } + work(workId: $workId) { + workId + titles(limit: 10, markupFormat: $titleMarkup) { titleId fullTitle title subtitle } + abstracts(limit: 10, markupFormat: $abstractMarkup) { abstractId content } + } + workByDoi(doi: $bookDoi) { workId } + workCount(workStatus: $workStatus) + books(limit: 10, workStatus: $workStatus) { workId } + bookByDoi(doi: $bookDoi) { workId } + bookCount(workStatus: $workStatus) + chapters(limit: 10, workStatus: $workStatus) { workId } + chapterByDoi(doi: $chapterDoi) { workId } + chapterCount(workStatus: $workStatus) + publications(limit: 10) { publicationId } + publication(publicationId: $publicationId) { publicationId } + publicationCount + publishers(limit: 10) { publisherId } + publisher(publisherId: $publisherId) { publisherId } + publisherCount + imprints(limit: 10) { imprintId } + imprint(imprintId: $imprintId) { imprintId } + imprintCount + contributors(limit: 10) { contributorId } + contributor(contributorId: $contributorId) { contributorId } + contributorCount + contributions(limit: 10) { contributionId } + contribution(contributionId: $contributionId) { contributionId } + contributionCount + serieses(limit: 10) { seriesId } + series(seriesId: $seriesId) { seriesId } + seriesCount + issues(limit: 10) { issueId } + issue(issueId: $issueId) { issueId } + issueCount + languages(limit: 10) { languageId } + language(languageId: $languageId) { languageId } + languageCount + locations(limit: 10) { locationId } + location(locationId: $locationId) { locationId } + locationCount + prices(limit: 10) { priceId } + price(priceId: $priceId) { priceId } + priceCount + subjects(limit: 10) { subjectId } + subject(subjectId: $subjectId) { subjectId } + subjectCount + institutions(limit: 10) { institutionId } + institution(institutionId: $institutionId) { institutionId } + institutionCount + fundings(limit: 10) { fundingId } + funding(fundingId: $fundingId) { fundingId } + fundingCount + affiliations(limit: 10) { affiliationId } + affiliation(affiliationId: $affiliationId) { affiliationId } + affiliationCount + references(limit: 10) { referenceId } + reference(referenceId: $referenceId) { referenceId } + referenceCount + title(titleId: $titleId, markupFormat: $titleMarkup) { titleId fullTitle title subtitle } + titles(limit: 10, markupFormat: $titleMarkup) { titleId fullTitle title subtitle } + abstract(abstractId: $abstractId, markupFormat: $abstractMarkup) { abstractId content } + abstracts(limit: 10, markupFormat: $abstractMarkup) { abstractId content } + biography(biographyId: $biographyId, markupFormat: $biographyMarkup) { biographyId content } + biographies(limit: 10, markupFormat: $biographyMarkup) { biographyId content } + contacts(limit: 10) { contactId } + contact(contactId: $contactId) { contactId } + contactCount + me { + userId + isSuperuser + publisherContexts { + publisher { publisherId } + permissions { publisherAdmin workLifecycle cdnWrite } + } + } +} +"#; + + let mut vars = Variables::new(); + insert_var(&mut vars, "workId", seed.book_work_id); + insert_var(&mut vars, "bookDoi", seed.book_doi.clone()); + insert_var(&mut vars, "chapterDoi", seed.chapter_doi.clone()); + insert_var(&mut vars, "publicationId", seed.publication_id); + insert_var(&mut vars, "publisherId", seed.publisher_id); + insert_var(&mut vars, "imprintId", seed.imprint_id); + insert_var(&mut vars, "contributorId", seed.contributor_id); + insert_var(&mut vars, "contributionId", seed.contribution_id); + insert_var(&mut vars, "seriesId", seed.series_id); + insert_var(&mut vars, "issueId", seed.issue_id); + insert_var(&mut vars, "languageId", seed.language_id); + insert_var(&mut vars, "locationId", seed.location_id); + insert_var(&mut vars, "priceId", seed.price_id); + insert_var(&mut vars, "subjectId", seed.subject_id); + insert_var(&mut vars, "institutionId", seed.institution_id); + insert_var(&mut vars, "fundingId", seed.funding_id); + insert_var(&mut vars, "affiliationId", seed.affiliation_id); + insert_var(&mut vars, "referenceId", seed.reference_id); + insert_var(&mut vars, "titleId", seed.title_id); + insert_var(&mut vars, "abstractId", seed.abstract_short_id); + insert_var(&mut vars, "biographyId", seed.biography_id); + insert_var(&mut vars, "contactId", seed.contact_id); + insert_var(&mut vars, "workStatus", WorkStatus::Active); + insert_var(&mut vars, "titleMarkup", MarkupFormat::PlainText); + insert_var(&mut vars, "abstractMarkup", MarkupFormat::PlainText); + insert_var(&mut vars, "biographyMarkup", MarkupFormat::PlainText); + + let data = execute_graphql(&schema, &context, query, Some(vars)); + assert!(data.get("workCount").is_some()); + + let org_user = + test_db::test_user_with_role("user-2", Role::PublisherAdmin, &seed.publisher_org); + let org_context = test_db::test_context_with_user(pool.clone(), org_user); + let me_data = execute_graphql( + &schema, + &org_context, + "query { me { userId publisherContexts { publisher { publisherId } } } }", + None, + ); + assert!(me_data.get("me").is_some()); + + let no_role_context = test_db::test_context(pool.clone(), "user-3"); + let me_empty = execute_graphql( + &schema, + &no_role_context, + "query { me { userId publisherContexts { publisher { publisherId } } } }", + None, + ); + assert!(me_empty.get("me").is_some()); + + let _ = context.db(); + let _ = context.user(); + + let work = Work::from_id(pool.as_ref(), &seed.book_work_id).unwrap(); + let title = Title::from_id(pool.as_ref(), &seed.title_id).unwrap(); + let short_abs = Abstract::from_id(pool.as_ref(), &seed.abstract_short_id).unwrap(); + let long_abs = Abstract::from_id(pool.as_ref(), &seed.abstract_long_id).unwrap(); + let biography = Biography::from_id(pool.as_ref(), &seed.biography_id).unwrap(); + assert_work_resolvers( + &work, + &context, + &title, + &short_abs, + &long_abs, + seed.imprint_id, + ); + + let publication = Publication::from_id(pool.as_ref(), &seed.publication_id).unwrap(); + assert_publication_resolvers(&publication, &context); + + let publisher = Publisher::from_id(pool.as_ref(), &seed.publisher_id).unwrap(); + assert_publisher_resolvers(&publisher, &context); + + let imprint = Imprint::from_id(pool.as_ref(), &seed.imprint_id).unwrap(); + assert_imprint_resolvers(&imprint, &context); + + let contributor = Contributor::from_id(pool.as_ref(), &seed.contributor_id).unwrap(); + assert_contributor_resolvers(&contributor, &context); + + let contribution = Contribution::from_id(pool.as_ref(), &seed.contribution_id).unwrap(); + assert_contribution_resolvers(&contribution, &context, &biography.content); + + let series = Series::from_id(pool.as_ref(), &seed.series_id).unwrap(); + assert_series_resolvers(&series, &context); + + let issue = Issue::from_id(pool.as_ref(), &seed.issue_id).unwrap(); + assert_issue_resolvers(&issue, &context); + + let language = Language::from_id(pool.as_ref(), &seed.language_id).unwrap(); + assert_language_resolvers(&language, &context); + + let location = Location::from_id(pool.as_ref(), &seed.location_id).unwrap(); + assert_location_resolvers(&location, &context); + + let price = Price::from_id(pool.as_ref(), &seed.price_id).unwrap(); + assert_price_resolvers(&price, &context); + + let subject = Subject::from_id(pool.as_ref(), &seed.subject_id).unwrap(); + assert_subject_resolvers(&subject, &context); + + let institution = Institution::from_id(pool.as_ref(), &seed.institution_id).unwrap(); + assert_institution_resolvers(&institution, &context); + + let funding = Funding::from_id(pool.as_ref(), &seed.funding_id).unwrap(); + assert_funding_resolvers(&funding, &context); + + let affiliation = Affiliation::from_id(pool.as_ref(), &seed.affiliation_id).unwrap(); + assert_affiliation_resolvers(&affiliation, &context); + + let work_relation = WorkRelation::from_id(pool.as_ref(), &seed.work_relation_id).unwrap(); + assert_work_relation_resolvers(&work_relation, &context); + + let reference = Reference::from_id(pool.as_ref(), &seed.reference_id).unwrap(); + assert_reference_resolvers(&reference, &context); + + let abstract_item = Abstract::from_id(pool.as_ref(), &seed.abstract_short_id).unwrap(); + assert_abstract_resolvers(&abstract_item, &context); + + assert_biography_resolvers(&biography, &context, contribution.work_id); + + let contact = Contact::from_id(pool.as_ref(), &seed.contact_id).unwrap(); + assert_contact_resolvers(&contact, &context); + + assert_title_resolvers(&title, &context); +} + +#[test] +fn graphql_books_order_respects_field_and_direction() { + let (_guard, pool) = test_db::setup_test_db(); + let schema = create_schema(); + let superuser = test_db::test_superuser("user-books-order"); + let context = test_db::test_context_with_user(pool.clone(), superuser); + + let publisher = Publisher::create(pool.as_ref(), &make_new_publisher("org-books-order")) + .expect("Failed to create publisher"); + let imprint = Imprint::create(pool.as_ref(), &make_new_imprint(publisher.publisher_id)) + .expect("Failed to create imprint"); + + let first = Work::create( + pool.as_ref(), + &make_new_book_work( + imprint.imprint_id, + Doi::from_str("https://doi.org/10.1111/BOOK.ORDER.FIRST").unwrap(), + ), + ) + .expect("Failed to create first book"); + let second = Work::create( + pool.as_ref(), + &make_new_book_work( + imprint.imprint_id, + Doi::from_str("https://doi.org/10.1111/BOOK.ORDER.SECOND").unwrap(), + ), + ) + .expect("Failed to create second book"); + + let mut by_id = [first, second]; + by_id.sort_by_key(|work| work.work_id); + + let mut newer_patch: PatchWork = by_id[0].clone().into(); + newer_patch.publication_date = NaiveDate::from_ymd_opt(2025, 1, 1); + by_id[0] + .update(&context, &newer_patch) + .expect("Failed to update newer book"); + + let mut older_patch: PatchWork = by_id[1].clone().into(); + older_patch.publication_date = NaiveDate::from_ymd_opt(2020, 1, 1); + by_id[1] + .update(&context, &older_patch) + .expect("Failed to update older book"); + + let query = r#" +{ + asc: books(limit: 10, order: {field: PUBLICATION_DATE, direction: ASC}) { workId } + desc: books(limit: 10, order: {field: PUBLICATION_DATE, direction: DESC}) { workId } +} +"#; + + let data = execute_graphql(&schema, &context, query, None); + let asc = data["asc"].as_array().expect("Expected asc books array"); + let desc = data["desc"].as_array().expect("Expected desc books array"); + + let asc_first_id = json_uuid(&asc[0]["workId"]); + let desc_first_id = json_uuid(&desc[0]["workId"]); + + assert_eq!(asc_first_id, by_id[1].work_id); + assert_eq!(desc_first_id, by_id[0].work_id); +} + +#[test] +fn work_additional_resources_applies_markup_format_argument() { + let (_guard, pool) = test_db::setup_test_db(); + let schema = create_schema(); + let superuser = test_db::test_superuser("user-additional-resources-markup"); + let context = test_db::test_context_with_user(pool.clone(), superuser); + let seed = seed_data(&schema, &context); + + let resource = AdditionalResource::create( + pool.as_ref(), + &NewAdditionalResource { + work_id: seed.book_work_id, + title: "<italic>Resource Title</italic>".to_string(), + description: Some("<p>Description <italic>markup</italic></p>".to_string()), + attribution: None, + resource_type: ResourceType::Video, + doi: None, + handle: None, + url: Some("https://example.com/resource.mp4".to_string()), + date: None, + resource_ordinal: 1, + }, + ) + .expect("Failed to create additional resource"); + + let work = Work::from_id(pool.as_ref(), &seed.book_work_id).expect("Failed to load work"); + + let resources_plain = work + .additional_resources(&context, Some(10), Some(0), Some(MarkupFormat::PlainText)) + .expect("Failed to fetch additional resources in plain text"); + let plain = resources_plain + .iter() + .find(|item| item.additional_resource_id == resource.additional_resource_id) + .expect("Missing created additional resource in plain text results"); + assert_eq!(plain.title, "Resource Title"); + assert_eq!(plain.description.as_deref(), Some("Description markup")); + assert!(!plain.title.contains('<')); + assert!(!plain + .description + .as_deref() + .unwrap_or_default() + .contains('<')); + + let resources_jats = work + .additional_resources(&context, Some(10), Some(0), Some(MarkupFormat::JatsXml)) + .expect("Failed to fetch additional resources in JATS"); + let jats = resources_jats + .iter() + .find(|item| item.additional_resource_id == resource.additional_resource_id) + .expect("Missing created additional resource in JATS results"); + assert!(jats.title.contains("<italic>")); + assert!(jats + .description + .as_deref() + .unwrap_or_default() + .contains("<italic>")); +} + +#[test] +fn graphql_work_additional_resources_uses_parent_markup_when_nested_args_omitted() { + let (_guard, pool) = test_db::setup_test_db(); + let schema = create_schema(); + let superuser = test_db::test_superuser("user-parent-markup"); + let context = test_db::test_context_with_user(pool.clone(), superuser); + let seed = seed_data(&schema, &context); + + let resource = AdditionalResource::create( + pool.as_ref(), + &NewAdditionalResource { + work_id: seed.book_work_id, + title: "<italic>Parent Title</italic>".to_string(), + description: Some("<p>Parent <italic>Description</italic></p>".to_string()), + attribution: Some("Attribution".to_string()), + resource_type: ResourceType::Video, + doi: None, + handle: None, + url: Some("https://example.com/parent-markup.mp4".to_string()), + date: None, + resource_ordinal: 1, + }, + ) + .expect("Failed to create additional resource"); + + let query = r#" +query ParentMarkup($id: Uuid!) { + work(workId: $id) { + additionalResources(markupFormat: MARKDOWN) { + workResourceId + title + description + } + } +} +"#; + let mut vars = Variables::new(); + insert_var(&mut vars, "id", seed.book_work_id); + let data = execute_graphql(&schema, &context, query, Some(vars)); + + let resources = data["work"]["additionalResources"] + .as_array() + .expect("Expected additionalResources array"); + let resource_id = resource.additional_resource_id.to_string(); + let matching = resources + .iter() + .find(|item| item["workResourceId"].as_str() == Some(resource_id.as_str())) + .expect("Missing created additional resource in GraphQL response"); + + let title = matching["title"] + .as_str() + .expect("Expected title string in GraphQL response"); + let description = matching["description"] + .as_str() + .expect("Expected description string in GraphQL response"); + + assert_ne!(title, "<italic>Parent Title</italic>"); + assert_ne!(description, "<p>Parent <italic>Description</italic></p>"); + assert!(!title.contains('<')); + assert!(!description.contains('<')); +} + +#[test] +fn graphql_award_supports_role_and_prize_statement_markup() { + let (_guard, pool) = test_db::setup_test_db(); + let schema = create_schema(); + let superuser = test_db::test_superuser("user-award-markup"); + let context = test_db::test_context_with_user(pool.clone(), superuser); + let seed = seed_data(&schema, &context); + + let award = create_with_data_and_markup( + &schema, + &context, + "createAward", + "NewAward", + "awardId role title(markupFormat: PLAIN_TEXT) prizeStatement(markupFormat: PLAIN_TEXT)", + NewAward { + work_id: seed.book_work_id, + title: "*Award*".to_string(), + url: Some("https://example.com/award".to_string()), + category: Some("Prize".to_string()), + prize_statement: Some("**Prize** statement".to_string()), + role: Some(AwardRole::JointWinner), + award_ordinal: 1, + }, + MarkupFormat::Markdown, + ); + + assert_eq!(award["role"].as_str(), Some("JOINT_WINNER")); + assert_eq!(award["title"].as_str(), Some("Award")); + assert_eq!(award["prizeStatement"].as_str(), Some("Prize statement")); + + let award_id = json_uuid(&award["awardId"]); + let stored = Award::from_id(pool.as_ref(), &award_id).expect("Failed to fetch stored award"); + assert_eq!(stored.role, Some(AwardRole::JointWinner)); + assert!(stored.title.contains("<italic>")); + assert!(stored + .prize_statement + .as_deref() + .unwrap_or_default() + .contains("<bold>")); +} + +#[test] +fn graphql_additional_resource_exposes_date() { + let (_guard, pool) = test_db::setup_test_db(); + let schema = create_schema(); + let superuser = test_db::test_superuser("user-resource-date"); + let context = test_db::test_context_with_user(pool.clone(), superuser); + let seed = seed_data(&schema, &context); + let resource_date = NaiveDate::from_ymd_opt(2025, 3, 1).unwrap(); + + let resource = create_with_data_and_markup( + &schema, + &context, + "createAdditionalResource", + "NewAdditionalResource", + "workResourceId date title(markupFormat: PLAIN_TEXT) description(markupFormat: PLAIN_TEXT)", + NewAdditionalResource { + work_id: seed.book_work_id, + title: "*Resource*".to_string(), + description: Some("**Description**".to_string()), + attribution: Some("Attribution".to_string()), + resource_type: ResourceType::Dataset, + doi: None, + handle: None, + url: Some("https://example.com/resource".to_string()), + date: Some(resource_date), + resource_ordinal: 1, + }, + MarkupFormat::Markdown, + ); + + assert_eq!(resource["date"].as_str(), Some("2025-03-01")); + assert_eq!(resource["title"].as_str(), Some("Resource")); + assert_eq!(resource["description"].as_str(), Some("Description")); + + let resource_id = json_uuid(&resource["workResourceId"]); + let stored = AdditionalResource::from_id(pool.as_ref(), &resource_id) + .expect("Failed to fetch stored resource"); + assert_eq!(stored.date, Some(resource_date)); +} + +#[test] +fn graphql_book_review_supports_reviewer_fields_and_title_markup() { + let (_guard, pool) = test_db::setup_test_db(); + let schema = create_schema(); + let superuser = test_db::test_superuser("user-book-review-fields"); + let context = test_db::test_context_with_user(pool.clone(), superuser); + let seed = seed_data(&schema, &context); + let institution = Institution::create( + pool.as_ref(), + &NewInstitution { + institution_name: unique("Reviewer Institution"), + institution_doi: None, + ror: Some(Ror::from_str("https://ror.org/051z6e826").unwrap()), + country_code: Some(CountryCode::Gbr), + }, + ) + .expect("Failed to create reviewer institution"); + + let review = create_with_data_and_markup( + &schema, + &context, + "createBookReview", + "NewBookReview", + "bookReviewId title(markupFormat: PLAIN_TEXT) reviewerOrcid reviewerInstitutionId reviewerInstitution { institutionId ror } pageRange text(markupFormat: PLAIN_TEXT)", + NewBookReview { + work_id: seed.book_work_id, + title: Some("*Review* Title".to_string()), + author_name: Some("Reviewer".to_string()), + reviewer_orcid: Some(Orcid::from_str("https://orcid.org/0000-0002-1234-5678").unwrap()), + reviewer_institution_id: Some(institution.institution_id), + url: Some("https://example.com/review".to_string()), + doi: None, + review_date: Some(NaiveDate::from_ymd_opt(2025, 2, 1).unwrap()), + journal_name: Some("Journal".to_string()), + journal_volume: Some("12".to_string()), + journal_number: Some("3".to_string()), + journal_issn: Some("1234-5678".to_string()), + page_range: Some("10-12".to_string()), + text: Some("**Review** text".to_string()), + review_ordinal: 1, + }, + MarkupFormat::Markdown, + ); + let reviewer_institution_id = institution.institution_id.to_string(); + + assert_eq!(review["title"].as_str(), Some("Review Title")); + assert_eq!( + review["reviewerOrcid"].as_str(), + Some("https://orcid.org/0000-0002-1234-5678") + ); + assert_eq!( + review["reviewerInstitutionId"].as_str(), + Some(reviewer_institution_id.as_str()) + ); + assert_eq!(review["pageRange"].as_str(), Some("10-12")); + assert_eq!(review["text"].as_str(), Some("Review text")); + assert_eq!( + review["reviewerInstitution"]["ror"].as_str(), + Some("https://ror.org/051z6e826") + ); + + let review_id = json_uuid(&review["bookReviewId"]); + let stored = + BookReview::from_id(pool.as_ref(), &review_id).expect("Failed to fetch stored review"); + assert!(stored + .title + .as_deref() + .unwrap_or_default() + .contains("<italic>")); +} + +#[test] +fn graphql_endorsement_supports_author_identity_fields() { + let (_guard, pool) = test_db::setup_test_db(); + let schema = create_schema(); + let superuser = test_db::test_superuser("user-endorsement-fields"); + let context = test_db::test_context_with_user(pool.clone(), superuser); + let seed = seed_data(&schema, &context); + let institution = Institution::create( + pool.as_ref(), + &NewInstitution { + institution_name: unique("Author Institution"), + institution_doi: None, + ror: Some(Ror::from_str("https://ror.org/03yrm5c26").unwrap()), + country_code: Some(CountryCode::Gbr), + }, + ) + .expect("Failed to create author institution"); + + let endorsement = create_with_data_and_markup( + &schema, + &context, + "createEndorsement", + "NewEndorsement", + "endorsementId authorOrcid authorInstitutionId authorInstitution { institutionId ror } text(markupFormat: PLAIN_TEXT)", + NewEndorsement { + work_id: seed.book_work_id, + author_name: Some("Author".to_string()), + author_role: Some("Scholar".to_string()), + author_orcid: Some(Orcid::from_str("https://orcid.org/0000-0001-2345-6789").unwrap()), + author_institution_id: Some(institution.institution_id), + url: Some("https://example.com/endorsement".to_string()), + text: Some("*Excellent* book".to_string()), + endorsement_ordinal: 1, + }, + MarkupFormat::Markdown, + ); + let author_institution_id = institution.institution_id.to_string(); + + assert_eq!( + endorsement["authorOrcid"].as_str(), + Some("https://orcid.org/0000-0001-2345-6789") + ); + assert_eq!( + endorsement["authorInstitutionId"].as_str(), + Some(author_institution_id.as_str()) + ); + assert_eq!(endorsement["text"].as_str(), Some("Excellent book")); + assert_eq!( + endorsement["authorInstitution"]["ror"].as_str(), + Some("https://ror.org/03yrm5c26") + ); + + let endorsement_id = json_uuid(&endorsement["endorsementId"]); + let stored = Endorsement::from_id(pool.as_ref(), &endorsement_id) + .expect("Failed to fetch stored endorsement"); + assert_eq!( + stored.author_orcid, + Some(Orcid::from_str("https://orcid.org/0000-0001-2345-6789").unwrap()) + ); +} + +#[test] +fn graphql_review_and_endorsement_relations_null_after_institution_delete() { + let (_guard, pool) = test_db::setup_test_db(); + let schema = create_schema(); + let superuser = test_db::test_superuser("user-institution-null-relations"); + let context = test_db::test_context_with_user(pool.clone(), superuser); + let seed = seed_data(&schema, &context); + let institution = Institution::create( + pool.as_ref(), + &NewInstitution { + institution_name: unique("Linked Institution"), + institution_doi: None, + ror: Some(Ror::from_str("https://ror.org/04wxnsj81").unwrap()), + country_code: Some(CountryCode::Gbr), + }, + ) + .expect("Failed to create institution"); + + let review = BookReview::create( + pool.as_ref(), + &NewBookReview { + work_id: seed.book_work_id, + title: Some("Review title".to_string()), + author_name: Some("Reviewer".to_string()), + reviewer_orcid: Some(Orcid::from_str("https://orcid.org/0000-0002-1234-5678").unwrap()), + reviewer_institution_id: Some(institution.institution_id), + url: Some("https://example.com/review".to_string()), + doi: None, + review_date: Some(NaiveDate::from_ymd_opt(2025, 2, 1).unwrap()), + journal_name: Some("Journal".to_string()), + journal_volume: Some("12".to_string()), + journal_number: Some("3".to_string()), + journal_issn: Some("1234-5678".to_string()), + page_range: Some("10-12".to_string()), + text: Some("Review text".to_string()), + review_ordinal: 1, + }, + ) + .expect("Failed to create review"); + + let endorsement = Endorsement::create( + pool.as_ref(), + &NewEndorsement { + work_id: seed.book_work_id, + author_name: Some("Author".to_string()), + author_role: Some("Scholar".to_string()), + author_orcid: Some(Orcid::from_str("https://orcid.org/0000-0001-2345-6789").unwrap()), + author_institution_id: Some(institution.institution_id), + url: Some("https://example.com/endorsement".to_string()), + text: Some("Endorsement text".to_string()), + endorsement_ordinal: 1, + }, + ) + .expect("Failed to create endorsement"); + + institution + .delete(pool.as_ref()) + .expect("Failed to delete linked institution"); + + let query = r#" +query LinkedRelations($reviewId: Uuid!, $endorsementId: Uuid!) { + bookReview(bookReviewId: $reviewId) { + reviewerInstitutionId + reviewerInstitution { institutionId } + } + endorsement(endorsementId: $endorsementId) { + authorInstitutionId + authorInstitution { institutionId } + } +} +"#; + let mut vars = Variables::new(); + insert_var(&mut vars, "reviewId", review.book_review_id); + insert_var(&mut vars, "endorsementId", endorsement.endorsement_id); + let data = execute_graphql(&schema, &context, query, Some(vars)); + + assert!(data["bookReview"]["reviewerInstitutionId"].is_null()); + assert!(data["bookReview"]["reviewerInstitution"].is_null()); + assert!(data["endorsement"]["authorInstitutionId"].is_null()); + assert!(data["endorsement"]["authorInstitution"].is_null()); +} + +#[test] +fn graphql_mutations_cover_all() { + let (_guard, pool) = test_db::setup_test_db(); + let schema = create_schema(); + let superuser = test_db::test_superuser("user-4"); + let context = test_db::test_context_with_user(pool.clone(), superuser); + let seed = seed_data(&schema, &context); + + let publisher = Publisher::from_id(pool.as_ref(), &seed.publisher_id).unwrap(); + update_with_data( + &schema, + &context, + "updatePublisher", + "PatchPublisher", + "publisherId", + patch_publisher(&publisher), + ); + + let imprint = Imprint::from_id(pool.as_ref(), &seed.imprint_id).unwrap(); + update_with_data( + &schema, + &context, + "updateImprint", + "PatchImprint", + "imprintId", + patch_imprint(&imprint), + ); + + let contributor = Contributor::from_id(pool.as_ref(), &seed.contributor_id).unwrap(); + update_with_data( + &schema, + &context, + "updateContributor", + "PatchContributor", + "contributorId", + patch_contributor(&contributor), + ); + + let contribution = Contribution::from_id(pool.as_ref(), &seed.contribution_id).unwrap(); + update_with_data( + &schema, + &context, + "updateContribution", + "PatchContribution", + "contributionId", + patch_contribution(&contribution), + ); + + let publication = Publication::from_id(pool.as_ref(), &seed.publication_id).unwrap(); + update_with_data( + &schema, + &context, + "updatePublication", + "PatchPublication", + "publicationId", + patch_publication(&publication), + ); + + let series = Series::from_id(pool.as_ref(), &seed.series_id).unwrap(); + update_with_data( + &schema, + &context, + "updateSeries", + "PatchSeries", + "seriesId", + patch_series(&series), + ); + + let issue = Issue::from_id(pool.as_ref(), &seed.issue_id).unwrap(); + update_with_data( + &schema, + &context, + "updateIssue", + "PatchIssue", + "issueId", + patch_issue(&issue), + ); + + let language = Language::from_id(pool.as_ref(), &seed.language_id).unwrap(); + update_with_data( + &schema, + &context, + "updateLanguage", + "PatchLanguage", + "languageId", + patch_language(&language), + ); + + let institution = Institution::from_id(pool.as_ref(), &seed.institution_id).unwrap(); + update_with_data( + &schema, + &context, + "updateInstitution", + "PatchInstitution", + "institutionId", + patch_institution(&institution), + ); + + let funding = Funding::from_id(pool.as_ref(), &seed.funding_id).unwrap(); + update_with_data( + &schema, + &context, + "updateFunding", + "PatchFunding", + "fundingId", + patch_funding(&funding), + ); + + let location = Location::from_id(pool.as_ref(), &seed.location_id).unwrap(); + update_with_data( + &schema, + &context, + "updateLocation", + "PatchLocation", + "locationId", + patch_location(&location), + ); + + let price = Price::from_id(pool.as_ref(), &seed.price_id).unwrap(); + update_with_data( + &schema, + &context, + "updatePrice", + "PatchPrice", + "priceId", + patch_price(&price), + ); + + let subject = Subject::from_id(pool.as_ref(), &seed.subject_id).unwrap(); + update_with_data( + &schema, + &context, + "updateSubject", + "PatchSubject", + "subjectId", + patch_subject(&subject), + ); + + let affiliation = Affiliation::from_id(pool.as_ref(), &seed.affiliation_id).unwrap(); + update_with_data( + &schema, + &context, + "updateAffiliation", + "PatchAffiliation", + "affiliationId", + patch_affiliation(&affiliation), + ); + + let work_relation = WorkRelation::from_id(pool.as_ref(), &seed.work_relation_id).unwrap(); + update_with_data( + &schema, + &context, + "updateWorkRelation", + "PatchWorkRelation", + "workRelationId", + patch_work_relation(&work_relation), + ); + + let reference = Reference::from_id(pool.as_ref(), &seed.reference_id).unwrap(); + update_with_data( + &schema, + &context, + "updateReference", + "PatchReference", + "referenceId", + patch_reference(&reference), + ); + + let contact = Contact::from_id(pool.as_ref(), &seed.contact_id).unwrap(); + update_with_data( + &schema, + &context, + "updateContact", + "PatchContact", + "contactId", + patch_contact(&contact), + ); + + let title = Title::from_id(pool.as_ref(), &seed.title_id).unwrap(); + update_with_data_and_markup( + &schema, + &context, + "updateTitle", + "PatchTitle", + "titleId", + patch_title(&title), + MarkupFormat::PlainText, + ); + + let abstract_item = Abstract::from_id(pool.as_ref(), &seed.abstract_short_id).unwrap(); + update_with_data_and_markup( + &schema, + &context, + "updateAbstract", + "PatchAbstract", + "abstractId", + patch_abstract(&abstract_item), + MarkupFormat::PlainText, + ); + + let biography = Biography::from_id(pool.as_ref(), &seed.biography_id).unwrap(); + update_with_data_and_markup( + &schema, + &context, + "updateBiography", + "PatchBiography", + "biographyId", + patch_biography(&biography), + MarkupFormat::PlainText, + ); + + let work = Work::from_id(pool.as_ref(), &seed.book_work_id).unwrap(); + update_with_data( + &schema, + &context, + "updateWork", + "PatchWork", + "workId", + PatchWork::from(work), + ); + + move_with_ordinal( + &schema, + &context, + "moveAffiliation", + "affiliationId", + seed.affiliation_id, + 1, + "affiliationId", + ); + move_with_ordinal( + &schema, + &context, + "moveAffiliation", + "affiliationId", + seed.affiliation_id, + 2, + "affiliationId", + ); + + move_with_ordinal( + &schema, + &context, + "moveContribution", + "contributionId", + seed.contribution_id, + 1, + "contributionId", + ); + move_with_ordinal( + &schema, + &context, + "moveContribution", + "contributionId", + seed.contribution_id, + 2, + "contributionId", + ); + + move_with_ordinal( + &schema, + &context, + "moveIssue", + "issueId", + seed.issue_id, + 1, + "issueId", + ); + move_with_ordinal( + &schema, + &context, + "moveIssue", + "issueId", + seed.issue_id, + 2, + "issueId", + ); + + move_with_ordinal( + &schema, + &context, + "moveReference", + "referenceId", + seed.reference_id, + 1, + "referenceId", + ); + move_with_ordinal( + &schema, + &context, + "moveReference", + "referenceId", + seed.reference_id, + 2, + "referenceId", + ); + + move_with_ordinal( + &schema, + &context, + "moveSubject", + "subjectId", + seed.subject_id, + 1, + "subjectId", + ); + move_with_ordinal( + &schema, + &context, + "moveSubject", + "subjectId", + seed.subject_id, + 2, + "subjectId", + ); + + move_with_ordinal( + &schema, + &context, + "moveWorkRelation", + "workRelationId", + seed.work_relation_id, + 1, + "workRelationId", + ); + move_with_ordinal( + &schema, + &context, + "moveWorkRelation", + "workRelationId", + seed.work_relation_id, + 2, + "workRelationId", + ); + + delete_with_id( + &schema, + &context, + "deleteContact", + "contactId", + seed.contact_id, + "contactId", + ); + delete_with_id( + &schema, + &context, + "deleteBiography", + "biographyId", + seed.biography_id, + "biographyId", + ); + delete_with_id( + &schema, + &context, + "deleteAbstract", + "abstractId", + seed.abstract_short_id, + "abstractId", + ); + delete_with_id( + &schema, + &context, + "deleteAbstract", + "abstractId", + seed.abstract_long_id, + "abstractId", + ); + delete_with_id( + &schema, + &context, + "deleteReference", + "referenceId", + seed.reference_id, + "referenceId", + ); + delete_with_id( + &schema, + &context, + "deleteReference", + "referenceId", + seed.reference_id_two, + "referenceId", + ); + delete_with_id( + &schema, + &context, + "deleteWorkRelation", + "workRelationId", + seed.work_relation_id, + "workRelationId", + ); + delete_with_id( + &schema, + &context, + "deleteWorkRelation", + "workRelationId", + seed.work_relation_id_two, + "workRelationId", + ); + delete_with_id( + &schema, + &context, + "deleteAffiliation", + "affiliationId", + seed.affiliation_id, + "affiliationId", + ); + delete_with_id( + &schema, + &context, + "deleteAffiliation", + "affiliationId", + seed.affiliation_id_two, + "affiliationId", + ); + delete_with_id( + &schema, + &context, + "deleteSubject", + "subjectId", + seed.subject_id, + "subjectId", + ); + delete_with_id( + &schema, + &context, + "deleteSubject", + "subjectId", + seed.subject_id_two, + "subjectId", + ); + delete_with_id( + &schema, + &context, + "deletePrice", + "priceId", + seed.price_id, + "priceId", + ); + delete_with_id( + &schema, + &context, + "deleteLocation", + "locationId", + seed.location_id, + "locationId", + ); + delete_with_id( + &schema, + &context, + "deleteFunding", + "fundingId", + seed.funding_id, + "fundingId", + ); + delete_with_id( + &schema, + &context, + "deleteInstitution", + "institutionId", + seed.institution_id, + "institutionId", + ); + delete_with_id( + &schema, + &context, + "deleteTitle", + "titleId", + seed.title_id, + "titleId", + ); + delete_with_id( + &schema, + &context, + "deleteLanguage", + "languageId", + seed.language_id, + "languageId", + ); + delete_with_id( + &schema, + &context, + "deleteIssue", + "issueId", + seed.issue_id, + "issueId", + ); + delete_with_id( + &schema, + &context, + "deleteIssue", + "issueId", + seed.issue_id_two, + "issueId", + ); + delete_with_id( + &schema, + &context, + "deleteSeries", + "seriesId", + seed.series_id, + "seriesId", + ); + delete_with_id( + &schema, + &context, + "deletePublication", + "publicationId", + seed.publication_id, + "publicationId", + ); + delete_with_id( + &schema, + &context, + "deleteContribution", + "contributionId", + seed.contribution_id, + "contributionId", + ); + delete_with_id( + &schema, + &context, + "deleteContribution", + "contributionId", + seed.contribution_id_two, + "contributionId", + ); + delete_with_id( + &schema, + &context, + "deleteContributor", + "contributorId", + seed.contributor_id, + "contributorId", + ); + delete_with_id( + &schema, + &context, + "deleteContributor", + "contributorId", + seed.contributor_id_two, + "contributorId", + ); + delete_with_id( + &schema, + &context, + "deleteWork", + "workId", + seed.book_work_id, + "workId", + ); + delete_with_id( + &schema, + &context, + "deleteWork", + "workId", + seed.chapter_work_id, + "workId", + ); + delete_with_id( + &schema, + &context, + "deleteWork", + "workId", + seed.other_chapter_work_id, + "workId", + ); + delete_with_id( + &schema, + &context, + "deleteWork", + "workId", + seed.issue_work_id, + "workId", + ); + delete_with_id( + &schema, + &context, + "deleteWork", + "workId", + seed.issue_work_id_two, + "workId", + ); + delete_with_id( + &schema, + &context, + "deleteImprint", + "imprintId", + seed.imprint_id, + "imprintId", + ); + delete_with_id( + &schema, + &context, + "deletePublisher", + "publisherId", + seed.publisher_id, + "publisherId", + ); +} diff --git a/thoth-api/src/graphql/types/inputs.rs b/thoth-api/src/graphql/types/inputs.rs new file mode 100644 index 00000000..f562e02b --- /dev/null +++ b/thoth-api/src/graphql/types/inputs.rs @@ -0,0 +1,425 @@ +use crate::model::contribution::ContributionField; +use crate::model::funding::FundingField; +use crate::model::issue::IssueField; +use crate::model::language::LanguageField; +use crate::model::price::PriceField; +use crate::model::subject::SubjectField; +use crate::model::Timestamp; +use serde::Deserialize; +use serde::Serialize; +use strum::{Display, EnumString}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, juniper::GraphQLEnum)] +#[graphql(description = "Order in which to sort query results")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Direction { + #[cfg_attr(feature = "backend", graphql(description = "Ascending order"))] + #[default] + Asc, + #[cfg_attr(feature = "backend", graphql(description = "Descending order"))] + Desc, +} + +#[test] +fn test_direction_default() { + let dir: Direction = Default::default(); + assert_eq!(dir, Direction::Asc); +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, juniper::GraphQLEnum)] +#[graphql(description = "Expression to use when filtering by numeric value")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Expression { + #[cfg_attr( + feature = "backend", + graphql( + description = "Return only results with values which are greater than the value supplied" + ) + )] + #[default] + GreaterThan, + #[cfg_attr( + feature = "backend", + graphql( + description = "Return only results with values which are less than the value supplied" + ) + )] + LessThan, +} + +#[test] +fn test_expression_default() { + let dir: Expression = Default::default(); + assert_eq!(dir, Expression::GreaterThan); +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Field and order to use when sorting contributions list")] +pub struct ContributionOrderBy { + pub field: ContributionField, + pub direction: Direction, +} + +impl Default for ContributionOrderBy { + fn default() -> ContributionOrderBy { + ContributionOrderBy { + field: ContributionField::ContributionType, + direction: Default::default(), + } + } +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Field and order to use when sorting issues list")] +pub struct IssueOrderBy { + pub field: IssueField, + pub direction: Direction, +} + +impl Default for IssueOrderBy { + fn default() -> IssueOrderBy { + IssueOrderBy { + field: IssueField::IssueOrdinal, + direction: Default::default(), + } + } +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Field and order to use when sorting languages list")] +pub struct LanguageOrderBy { + pub field: LanguageField, + pub direction: Direction, +} + +impl Default for LanguageOrderBy { + fn default() -> LanguageOrderBy { + LanguageOrderBy { + field: LanguageField::LanguageCode, + direction: Default::default(), + } + } +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Field and order to use when sorting prices list")] +pub struct PriceOrderBy { + pub field: PriceField, + pub direction: Direction, +} + +impl Default for PriceOrderBy { + fn default() -> PriceOrderBy { + PriceOrderBy { + field: PriceField::CurrencyCode, + direction: Default::default(), + } + } +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Field and order to use when sorting subjects list")] +pub struct SubjectOrderBy { + pub field: SubjectField, + pub direction: Direction, +} + +impl Default for SubjectOrderBy { + fn default() -> SubjectOrderBy { + SubjectOrderBy { + field: SubjectField::SubjectType, + direction: Default::default(), + } + } +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Field and order to use when sorting fundings list")] +pub struct FundingOrderBy { + pub field: FundingField, + pub direction: Direction, +} + +impl Default for FundingOrderBy { + fn default() -> FundingOrderBy { + FundingOrderBy { + field: FundingField::Program, + direction: Default::default(), + } + } +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql( + description = "Timestamp and choice out of greater than/less than to use when filtering by a time field (e.g. updated_at)" +)] +pub struct TimeExpression { + pub timestamp: Timestamp, + pub expression: Expression, +} + +#[derive( + Debug, + Copy, + Clone, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + EnumString, + Display, + juniper::GraphQLEnum, +)] +#[graphql(description = "Unit of measurement for physical Work dimensions (mm, cm or in)")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "lowercase")] +pub enum LengthUnit { + #[cfg_attr(feature = "backend", graphql(description = "Millimetres"))] + #[default] + Mm, + #[cfg_attr(feature = "backend", graphql(description = "Centimetres"))] + Cm, + #[cfg_attr(feature = "backend", graphql(description = "Inches"))] + In, +} + +#[derive( + Debug, + Copy, + Clone, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + EnumString, + Display, + juniper::GraphQLEnum, +)] +#[graphql(description = "Unit of measurement for physical Work weight (grams or ounces)")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "lowercase")] +pub enum WeightUnit { + #[cfg_attr(feature = "backend", graphql(description = "Grams"))] + #[default] + G, + #[cfg_attr(feature = "backend", graphql(description = "Ounces"))] + Oz, +} + +pub trait Convert { + fn convert_length_from_to(&self, current_units: &LengthUnit, new_units: &LengthUnit) -> f64; + fn convert_weight_from_to(&self, current_units: &WeightUnit, new_units: &WeightUnit) -> f64; +} + +impl Convert for f64 { + fn convert_length_from_to(&self, current_units: &LengthUnit, new_units: &LengthUnit) -> f64 { + match (current_units, new_units) { + // If current units and new units are the same, no conversion is needed + (LengthUnit::Mm, LengthUnit::Mm) + | (LengthUnit::Cm, LengthUnit::Cm) + | (LengthUnit::In, LengthUnit::In) => *self, + // Return cm values rounded to max 1 decimal place (1 cm = 10 mm) + (LengthUnit::Mm, LengthUnit::Cm) => self.round() / 10.0, + // Return mm values rounded to nearest mm (1 cm = 10 mm) + (LengthUnit::Cm, LengthUnit::Mm) => (self * 10.0).round(), + // Return inch values rounded to 2 decimal places (1 inch = 25.4 mm) + (LengthUnit::Mm, LengthUnit::In) => { + let unrounded_inches = self / 25.4; + // To round to a non-integer scale, multiply by the appropriate factor, + // round to the nearest integer, then divide again by the same factor + (unrounded_inches * 100.0).round() / 100.0 + } + // Return mm values rounded to nearest mm (1 inch = 25.4 mm) + (LengthUnit::In, LengthUnit::Mm) => (self * 25.4).round(), + // We don't currently support conversion between cm and in as it is not required + _ => unimplemented!(), + } + } + + fn convert_weight_from_to(&self, current_units: &WeightUnit, new_units: &WeightUnit) -> f64 { + match (current_units, new_units) { + // If current units and new units are the same, no conversion is needed + (WeightUnit::G, WeightUnit::G) | (WeightUnit::Oz, WeightUnit::Oz) => *self, + // Return ounce values rounded to 4 decimal places (1 ounce = 28.349523125 grams) + (WeightUnit::G, WeightUnit::Oz) => { + let unrounded_ounces = self / 28.349523125; + // To round to a non-integer scale, multiply by the appropriate factor, + // round to the nearest integer, then divide again by the same factor + (unrounded_ounces * 10000.0).round() / 10000.0 + } + // Return gram values rounded to nearest gram (1 ounce = 28.349523125 grams) + (WeightUnit::Oz, WeightUnit::G) => (self * 28.349523125).round(), + } + } +} + +#[cfg(test)] +mod tests { + use super::{Convert, LengthUnit::*, WeightUnit::*}; + + #[test] + // Float equality comparison is fine here because the floats + // have already been rounded by the functions under test + #[allow(clippy::float_cmp)] + fn test_convert_length_from_to() { + assert_eq!(123.456.convert_length_from_to(&Mm, &Cm), 12.3); + assert_eq!(123.456.convert_length_from_to(&Mm, &In), 4.86); + assert_eq!(123.456.convert_length_from_to(&Cm, &Mm), 1235.0); + assert_eq!(123.456.convert_length_from_to(&In, &Mm), 3136.0); + // Test some standard print sizes + assert_eq!(4.25.convert_length_from_to(&In, &Mm), 108.0); + assert_eq!(108.0.convert_length_from_to(&Mm, &In), 4.25); + assert_eq!(6.0.convert_length_from_to(&In, &Mm), 152.0); + assert_eq!(152.0.convert_length_from_to(&Mm, &In), 5.98); + assert_eq!(8.5.convert_length_from_to(&In, &Mm), 216.0); + assert_eq!(216.0.convert_length_from_to(&Mm, &In), 8.5); + // Test that converting and then converting back again + // returns a value within a reasonable margin of error + assert_eq!( + 5.06.convert_length_from_to(&In, &Mm) + .convert_length_from_to(&Mm, &In), + 5.08 + ); + assert_eq!( + 6.5.convert_length_from_to(&In, &Mm) + .convert_length_from_to(&Mm, &In), + 6.5 + ); + assert_eq!( + 7.44.convert_length_from_to(&In, &Mm) + .convert_length_from_to(&Mm, &In), + 7.44 + ); + assert_eq!( + 8.27.convert_length_from_to(&In, &Mm) + .convert_length_from_to(&Mm, &In), + 8.27 + ); + assert_eq!( + 9.0.convert_length_from_to(&In, &Mm) + .convert_length_from_to(&Mm, &In), + 9.02 + ); + assert_eq!( + 10.88 + .convert_length_from_to(&In, &Mm) + .convert_length_from_to(&Mm, &In), + 10.87 + ); + assert_eq!( + 102.0 + .convert_length_from_to(&Mm, &In) + .convert_length_from_to(&In, &Mm), + 102.0 + ); + assert_eq!( + 120.0 + .convert_length_from_to(&Mm, &In) + .convert_length_from_to(&In, &Mm), + 120.0 + ); + assert_eq!( + 168.0 + .convert_length_from_to(&Mm, &In) + .convert_length_from_to(&In, &Mm), + 168.0 + ); + assert_eq!( + 190.0 + .convert_length_from_to(&Mm, &In) + .convert_length_from_to(&In, &Mm), + 190.0 + ); + } + #[test] + // Float equality comparison is fine here because the floats + // have already been rounded by the functions under test + #[allow(clippy::float_cmp)] + fn test_convert_weight_from_to() { + assert_eq!(123.456.convert_weight_from_to(&G, &Oz), 4.3548); + assert_eq!(123.456.convert_weight_from_to(&Oz, &G), 3500.0); + assert_eq!(4.25.convert_weight_from_to(&Oz, &G), 120.0); + assert_eq!(108.0.convert_weight_from_to(&G, &Oz), 3.8096); + assert_eq!(6.0.convert_weight_from_to(&Oz, &G), 170.0); + assert_eq!(152.0.convert_weight_from_to(&G, &Oz), 5.3616); + assert_eq!(8.5.convert_weight_from_to(&Oz, &G), 241.0); + assert_eq!(216.0.convert_weight_from_to(&G, &Oz), 7.6192); + // Test that converting and then converting back again + // returns a value within a reasonable margin of error + assert_eq!( + 5.0.convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 5.0089 + ); + assert_eq!( + 5.125 + .convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 5.1147 + ); + assert_eq!( + 6.5.convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 6.4904 + ); + assert_eq!( + 7.25.convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 7.2664 + ); + assert_eq!( + 7.44.convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 7.4428 + ); + assert_eq!( + 8.0625 + .convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 8.0777 + ); + assert_eq!( + 9.0.convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 8.9949 + ); + assert_eq!( + 10.75 + .convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 10.7586 + ); + assert_eq!( + 10.88 + .convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 10.8644 + ); + assert_eq!( + 102.0 + .convert_weight_from_to(&G, &Oz) + .convert_weight_from_to(&Oz, &G), + 102.0 + ); + assert_eq!( + 120.0 + .convert_weight_from_to(&G, &Oz) + .convert_weight_from_to(&Oz, &G), + 120.0 + ); + assert_eq!( + 168.0 + .convert_weight_from_to(&G, &Oz) + .convert_weight_from_to(&Oz, &G), + 168.0 + ); + assert_eq!( + 190.0 + .convert_weight_from_to(&G, &Oz) + .convert_weight_from_to(&Oz, &G), + 190.0 + ); + } +} diff --git a/thoth-api/src/graphql/types/me.rs b/thoth-api/src/graphql/types/me.rs new file mode 100644 index 00000000..dec9c3de --- /dev/null +++ b/thoth-api/src/graphql/types/me.rs @@ -0,0 +1,150 @@ +use crate::graphql::Context; +use crate::model::publisher::{Publisher, PublisherOrderBy}; +use crate::model::Crud; +use crate::policy::{PublisherPermissions, UserAccess}; +use juniper::FieldResult; +use zitadel::actix::introspection::IntrospectedUser; + +pub struct Me { + pub user_id: String, + pub email: Option<String>, + pub first_name: Option<String>, + pub last_name: Option<String>, + pub is_superuser: bool, + pub publisher_contexts: Vec<PublisherContext>, +} + +#[derive(Clone)] +pub struct PublisherContext { + pub publisher: Publisher, + pub permissions: PublisherPermissions, +} + +pub trait ToMe { + fn to_me(&self, context: &Context) -> FieldResult<Me>; +} + +impl ToMe for IntrospectedUser { + fn to_me(&self, context: &Context) -> FieldResult<Me> { + let is_superuser = self.is_superuser(); + let mut publisher_contexts = publisher_contexts_for_user(context, self)?; + publisher_contexts + .sort_by(|a, b| a.publisher.publisher_name.cmp(&b.publisher.publisher_name)); + + Ok(Me { + user_id: self.user_id.clone(), + email: self.email.clone(), + first_name: self.given_name.clone(), + last_name: self.family_name.clone(), + is_superuser, + publisher_contexts, + }) + } +} + +fn publisher_contexts_for_user( + context: &Context, + user: &IntrospectedUser, +) -> FieldResult<Vec<PublisherContext>> { + if user.is_superuser() { + let publishers = Publisher::all( + &context.db, + i32::MAX, + 0, + None, + PublisherOrderBy::default(), + vec![], + None, + None, + vec![], + vec![], + None, + None, + )?; + + return Ok(publishers + .into_iter() + .map(|publisher| { + let permissions = publisher + .zitadel_id + .as_deref() + .map(|org_id| user.permissions_for_org(org_id)) + .unwrap_or_else(PublisherPermissions::for_superuser); + PublisherContext { + publisher, + permissions, + } + }) + .collect()); + } + + let org_ids = user.publisher_org_ids(); + if org_ids.is_empty() { + return Ok(Vec::new()); + } + + let publishers = Publisher::by_zitadel_ids(&context.db, org_ids)?; + Ok(publishers + .into_iter() + .filter_map(|publisher| { + let org_id = publisher.zitadel_id.as_deref()?.to_string(); + Some(PublisherContext { + publisher, + permissions: user.permissions_for_org(&org_id), + }) + }) + .collect()) +} + +#[juniper::graphql_object(Context = Context)] +impl Me { + fn user_id(&self) -> &str { + &self.user_id + } + + fn email(&self) -> Option<&String> { + self.email.as_ref() + } + + fn first_name(&self) -> Option<&String> { + self.first_name.as_ref() + } + + fn last_name(&self) -> Option<&String> { + self.last_name.as_ref() + } + + fn is_superuser(&self) -> bool { + self.is_superuser + } + + fn publisher_contexts(&self) -> Vec<PublisherContext> { + self.publisher_contexts.clone() + } +} + +#[juniper::graphql_object(Context = Context)] +impl PublisherContext { + fn publisher(&self) -> &Publisher { + &self.publisher + } + + fn permissions(&self) -> PublisherPermissions { + self.permissions + } +} + +#[juniper::graphql_object(Context = Context)] +impl PublisherPermissions { + fn publisher_admin(&self) -> bool { + self.publisher_admin + } + + fn work_lifecycle(&self) -> bool { + self.work_lifecycle + } + + fn cdn_write(&self) -> bool { + self.cdn_write + } +} diff --git a/thoth-api/src/graphql/types/mod.rs b/thoth-api/src/graphql/types/mod.rs new file mode 100644 index 00000000..ba424333 --- /dev/null +++ b/thoth-api/src/graphql/types/mod.rs @@ -0,0 +1,2 @@ +pub mod inputs; +pub mod me; diff --git a/thoth-api/src/graphql/utils.rs b/thoth-api/src/graphql/utils.rs deleted file mode 100644 index 95440fea..00000000 --- a/thoth-api/src/graphql/utils.rs +++ /dev/null @@ -1,46 +0,0 @@ -use serde::Deserialize; -use serde::Serialize; - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, juniper::GraphQLEnum)] -#[graphql(description = "Order in which to sort query results")] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum Direction { - #[cfg_attr(feature = "backend", graphql(description = "Ascending order"))] - #[default] - Asc, - #[cfg_attr(feature = "backend", graphql(description = "Descending order"))] - Desc, -} - -#[test] -fn test_direction_default() { - let dir: Direction = Default::default(); - assert_eq!(dir, Direction::Asc); -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, juniper::GraphQLEnum)] -#[graphql(description = "Expression to use when filtering by numeric value")] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum Expression { - #[cfg_attr( - feature = "backend", - graphql( - description = "Return only results with values which are greater than the value supplied" - ) - )] - #[default] - GreaterThan, - #[cfg_attr( - feature = "backend", - graphql( - description = "Return only results with values which are less than the value supplied" - ) - )] - LessThan, -} - -#[test] -fn test_expression_default() { - let dir: Expression = Default::default(); - assert_eq!(dir, Expression::GreaterThan); -} diff --git a/thoth-api/src/lib.rs b/thoth-api/src/lib.rs index 8495057b..de0226ee 100644 --- a/thoth-api/src/lib.rs +++ b/thoth-api/src/lib.rs @@ -1,38 +1,15 @@ -#![allow(clippy::extra_unused_lifetimes)] - -#[cfg(feature = "backend")] -#[macro_use] -extern crate diesel; -#[cfg(feature = "backend")] -#[macro_use] -extern crate diesel_derive_enum; -#[cfg(feature = "backend")] -#[macro_use] -extern crate diesel_derive_newtype; -#[cfg(feature = "backend")] -extern crate diesel_migrations; -extern crate dotenv; -extern crate juniper; - -pub mod account; #[cfg(feature = "backend")] pub mod db; +#[cfg(feature = "backend")] pub mod graphql; +pub mod markup; #[macro_use] pub mod model; #[cfg(feature = "backend")] +pub(crate) mod policy; +#[cfg(feature = "backend")] pub mod redis; #[cfg(feature = "backend")] mod schema; - -macro_rules! apis { - ($($name:ident => $content:expr,)*) => ( - $(#[allow(missing_docs)] pub const $name: &str = $content;)* - ) -} - -apis! { - API_URL_LOGIN_CREDENTIALS => "login/credentials", - API_URL_LOGIN_SESSION => "login/session", - API_URL_LOGOUT => "logout", -} +#[cfg(feature = "backend")] +pub mod storage; diff --git a/thoth-api/src/markup/ast.rs b/thoth-api/src/markup/ast.rs new file mode 100644 index 00000000..512cc1e3 --- /dev/null +++ b/thoth-api/src/markup/ast.rs @@ -0,0 +1,2128 @@ +use super::ConversionLimit; +use pulldown_cmark::{Event, Parser, Tag}; +use scraper::{ElementRef, Html, Selector}; +use thoth_errors::{ThothError, ThothResult}; + +// Simple AST node +#[derive(Debug, Clone)] +pub enum Node { + Document(Vec<Node>), + Paragraph(Vec<Node>), + Bold(Vec<Node>), + Italic(Vec<Node>), + Code(Vec<Node>), + Superscript(Vec<Node>), + Subscript(Vec<Node>), + SmallCaps(Vec<Node>), + List(Vec<Node>), + ListItem(Vec<Node>), + Link { url: String, text: Vec<Node> }, + Text(String), +} + +// Convert Markdown string to AST +pub fn markdown_to_ast(markdown: &str) -> Node { + let parser = Parser::new(markdown); + let mut stack: Vec<Node> = vec![Node::Document(vec![])]; + + for event in parser { + match event { + Event::Start(tag) => match tag { + Tag::Paragraph => stack.push(Node::Paragraph(vec![])), + Tag::Strong => stack.push(Node::Bold(vec![])), + Tag::Emphasis => stack.push(Node::Italic(vec![])), + Tag::List(_) => stack.push(Node::List(vec![])), + Tag::Item => stack.push(Node::ListItem(vec![])), + Tag::Link { + dest_url, title, .. + } => stack.push(Node::Link { + url: dest_url.to_string(), + text: vec![Node::Text(title.to_string())], + }), + _ => {} + }, + Event::End(_tag) => { + if let Some(node) = stack.pop() { + if let Some(top) = stack.last_mut() { + match top { + Node::Document(children) + | Node::Paragraph(children) + | Node::Bold(children) + | Node::Italic(children) + | Node::Code(children) + | Node::Superscript(children) + | Node::Subscript(children) + | Node::SmallCaps(children) + | Node::List(children) + | Node::ListItem(children) => children.push(node), + Node::Text(_) => {} + Node::Link { text, .. } => text.push(node), + } + } + } + } + Event::Text(text) => { + if let Some( + Node::Document(children) + | Node::Paragraph(children) + | Node::Bold(children) + | Node::Italic(children) + | Node::Code(children) + | Node::Superscript(children) + | Node::Subscript(children) + | Node::SmallCaps(children) + | Node::List(children) + | Node::ListItem(children), + ) = stack.last_mut() + { + children.push(Node::Text(text.to_string())); + } else if let Some(Node::Link { + text: link_text, .. + }) = stack.last_mut() + { + link_text.push(Node::Text(text.to_string())); + } + } + Event::Code(code_text) => { + if let Some( + Node::Document(children) + | Node::Paragraph(children) + | Node::Bold(children) + | Node::Italic(children) + | Node::Code(children) + | Node::Superscript(children) + | Node::Subscript(children) + | Node::SmallCaps(children) + | Node::List(children) + | Node::ListItem(children), + ) = stack.last_mut() + { + children.push(Node::Code(vec![Node::Text(code_text.to_string())])); + } else if let Some(Node::Link { + text: link_text, .. + }) = stack.last_mut() + { + link_text.push(Node::Code(vec![Node::Text(code_text.to_string())])); + } + } + _ => {} + } + } + + let result = stack.pop().unwrap_or_else(|| Node::Document(vec![])); + + // Post-process to wrap standalone inline elements in paragraphs + match result { + Node::Document(children) => { + if children.len() > 1 { + let all_inline = children.iter().all(|child| { + matches!( + child, + Node::Bold(_) + | Node::Italic(_) + | Node::Code(_) + | Node::Superscript(_) + | Node::Subscript(_) + | Node::SmallCaps(_) + | Node::Text(_) + | Node::Link { .. } + ) + }); + if all_inline { + Node::Document(vec![Node::Paragraph(children)]) + } else { + Node::Document(children) + } + } else if children.len() == 1 { + // If we have only one child, check if it should be wrapped in a paragraph + match &children[0] { + Node::Link { .. } | Node::Text(_) => { + // Wrap standalone links and text in paragraphs + Node::Document(vec![Node::Paragraph(children)]) + } + _ => Node::Document(children), + } + } else { + Node::Document(children) + } + } + _ => result, + } +} + +// Convert HTML string to AST +pub fn html_to_ast(html: &str) -> Node { + // Helper function to parse an HTML element to AST node + fn parse_element_to_node(element: ElementRef) -> Node { + let tag_name = element.value().name(); + let mut children = Vec::new(); + + for child in element.children() { + match child.value() { + scraper::node::Node::Element(_) => { + if let Some(child_element) = ElementRef::wrap(child) { + children.push(parse_element_to_node(child_element)); + } + } + scraper::node::Node::Text(text) => { + children.push(Node::Text(text.to_string())); + } + _ => {} + } + } + + match tag_name { + "html" | "body" | "div" => Node::Document(children), + "p" => Node::Paragraph(children), + "strong" | "b" => Node::Bold(children), + "em" | "i" => Node::Italic(children), + "code" => Node::Code(children), + "sup" => Node::Superscript(children), + "sub" => Node::Subscript(children), + "text" => Node::SmallCaps(children), + "ul" | "ol" => Node::List(children), + "li" => Node::ListItem(children), + "a" => { + // Extract href attribute for links + let url = element.value().attr("href").unwrap_or("").to_string(); + Node::Link { + url, + text: children, + } + } + _ => { + // For unknown tags, create a document node with the children + if children.is_empty() { + Node::Text(String::new()) + } else { + Node::Document(children) + } + } + } + } + + let document = Html::parse_document(html); + let body_selector = Selector::parse("body").unwrap(); + + // If there's a body tag, parse its contents, otherwise parse the whole document + if let Some(body_element) = document.select(&body_selector).next() { + parse_element_to_node(body_element) + } else { + // If no body tag, create a document node with all top-level elements + let mut children = Vec::new(); + for child in document.root_element().children() { + if let Some(element) = ElementRef::wrap(child) { + children.push(parse_element_to_node(element)); + } + } + let result = Node::Document(children); + + // Post-process to wrap standalone inline elements in paragraphs + match result { + Node::Document(children) => { + if children.len() > 1 { + let all_inline = children.iter().all(|child| { + matches!( + child, + Node::Bold(_) + | Node::Italic(_) + | Node::Code(_) + | Node::Superscript(_) + | Node::Subscript(_) + | Node::SmallCaps(_) + | Node::Text(_) + | Node::Link { .. } + ) + }); + if all_inline { + Node::Document(vec![Node::Paragraph(children)]) + } else { + Node::Document(children) + } + } else if children.len() == 1 { + // If we have only one child, check if it should be wrapped in a paragraph + match &children[0] { + Node::Link { .. } + | Node::Text(_) + | Node::Bold(_) + | Node::Italic(_) + | Node::Code(_) + | Node::Superscript(_) + | Node::Subscript(_) + | Node::SmallCaps(_) => { + // Wrap standalone inline elements in paragraphs + Node::Document(vec![Node::Paragraph(children)]) + } + _ => Node::Document(children), + } + } else { + Node::Document(children) + } + } + _ => result, + } + } +} + +// Helper function to parse text and detect URLs +fn parse_text_with_urls(text: &str) -> Vec<Node> { + let mut result = Vec::new(); + let mut current_pos = 0; + + // Simple URL regex pattern - matches http/https URLs + let url_pattern = regex::Regex::new(r"(https?://[^\s]+)").unwrap(); + + for mat in url_pattern.find_iter(text) { + if mat.start() > current_pos { + let before_text = &text[current_pos..mat.start()]; + if !before_text.is_empty() { + result.push(Node::Text(before_text.to_string())); + } + } + + let url = mat.as_str(); + result.push(Node::Link { + url: url.to_string(), + text: vec![Node::Text(url.to_string())], + }); + + current_pos = mat.end(); + } + + if current_pos < text.len() { + let remaining_text = &text[current_pos..]; + if !remaining_text.is_empty() { + result.push(Node::Text(remaining_text.to_string())); + } + } + + if result.is_empty() { + result.push(Node::Text(text.to_string())); + } + + result +} + +// Convert plain text string to AST +pub fn plain_text_to_ast(text: &str) -> Node { + let parsed_nodes = parse_text_with_urls(text.trim()); + + if parsed_nodes.len() == 1 { + parsed_nodes[0].clone() + } else { + Node::Document(parsed_nodes) + } +} + +// Special function to convert plain text AST to JATS with proper <sc> wrapping +pub fn plain_text_ast_to_jats(node: &Node) -> String { + match node { + Node::Document(children) => { + let inner: String = children.iter().map(plain_text_ast_to_jats).collect(); + inner + } + Node::Paragraph(children) => { + let inner: String = children.iter().map(plain_text_ast_to_jats).collect(); + format!("<p>{}</p>", inner) + } + Node::Text(text) => { + // For plain text, wrap in <sc> tags only + format!("<p>{}</p>", text) + } + Node::Link { url, text } => { + let inner: String = text.iter().map(plain_text_ast_to_jats).collect(); + format!(r#"<ext-link xlink:href="{}">{}</ext-link>"#, url, inner) + } + _ => { + // For other nodes, use regular ast_to_jats + ast_to_jats(node) + } + } +} + +// Render AST to JATS XML +pub fn ast_to_jats(node: &Node) -> String { + match node { + Node::Document(children) => children.iter().map(ast_to_jats).collect(), + Node::Paragraph(children) => { + let inner: String = children.iter().map(ast_to_jats).collect(); + format!("<p>{}</p>", inner) + } + Node::Bold(children) => { + let inner: String = children.iter().map(ast_to_jats).collect(); + format!("<bold>{}</bold>", inner) + } + Node::Italic(children) => { + let inner: String = children.iter().map(ast_to_jats).collect(); + format!("<italic>{}</italic>", inner) + } + Node::Code(children) => { + let inner: String = children.iter().map(ast_to_jats).collect(); + format!("<monospace>{}</monospace>", inner) + } + Node::Superscript(children) => { + let inner: String = children.iter().map(ast_to_jats).collect(); + format!("<sup>{}</sup>", inner) + } + Node::Subscript(children) => { + let inner: String = children.iter().map(ast_to_jats).collect(); + format!("<sub>{}</sub>", inner) + } + Node::SmallCaps(children) => { + let inner: String = children.iter().map(ast_to_jats).collect(); + format!("<sc>{}</sc>", inner) + } + Node::List(items) => { + let inner: String = items.iter().map(ast_to_jats).collect(); + format!("<list>{}</list>", inner) + } + Node::ListItem(children) => { + let inner: String = children.iter().map(ast_to_jats).collect(); + format!("<list-item>{}</list-item>", inner) + } + Node::Link { url, text } => { + let inner: String = text.iter().map(ast_to_jats).collect(); + format!(r#"<ext-link xlink:href="{}">{}</ext-link>"#, url, inner) + } + Node::Text(text) => text.clone(), + } +} + +// Convert JATS XML string to AST +pub fn jats_to_ast(jats: &str) -> Node { + // Helper function to parse a JATS element to AST node + fn parse_jats_element_to_node(element: ElementRef) -> Node { + let tag_name = element.value().name(); + let mut children = Vec::new(); + + for child in element.children() { + match child.value() { + scraper::node::Node::Element(_) => { + if let Some(child_element) = ElementRef::wrap(child) { + children.push(parse_jats_element_to_node(child_element)); + } + } + scraper::node::Node::Text(text) => { + children.push(Node::Text(text.to_string())); + } + _ => {} + } + } + + match tag_name { + "article" | "body" | "sec" | "div" => Node::Document(children), + "p" => Node::Paragraph(children), + "bold" => Node::Bold(children), + "italic" => Node::Italic(children), + "monospace" => Node::Code(children), + "sup" => Node::Superscript(children), + "sub" => Node::Subscript(children), + "sc" => Node::SmallCaps(children), + "list" => Node::List(children), + "list-item" => Node::ListItem(children), + "ext-link" => { + // Extract xlink:href attribute for links + let url = element.value().attr("xlink:href").unwrap_or("").to_string(); + Node::Link { + url, + text: children, + } + } + _ => { + // For unknown tags, create a document node with the children + if children.is_empty() { + Node::Text(String::new()) + } else { + Node::Document(children) + } + } + } + } + + let document = Html::parse_document(jats); + let body_selector = Selector::parse("body").unwrap(); + + // If there's a body tag, parse its contents, otherwise parse the whole document + if let Some(body_element) = document.select(&body_selector).next() { + parse_jats_element_to_node(body_element) + } else { + // If no body tag, create a document node with all top-level elements + let mut children = Vec::new(); + for child in document.root_element().children() { + if let Some(element) = ElementRef::wrap(child) { + children.push(parse_jats_element_to_node(element)); + } + } + + // If we have multiple inline elements, wrap them in a paragraph + if children.len() > 1 { + let all_inline = children.iter().all(|child| { + matches!( + child, + Node::Bold(_) + | Node::Italic(_) + | Node::Code(_) + | Node::Superscript(_) + | Node::Subscript(_) + | Node::Text(_) + | Node::Link { .. } + ) + }); + if all_inline { + Node::Document(vec![Node::Paragraph(children)]) + } else { + Node::Document(children) + } + } else if children.len() == 1 { + // Special case: if the single child is a text node, return it directly + // Otherwise, wrap in document + match &children[0] { + Node::Text(_) => children.into_iter().next().unwrap(), + _ => Node::Document(children), + } + } else { + Node::Document(children) + } + } +} + +// Convert AST to HTML +pub fn ast_to_html(node: &Node) -> String { + match node { + Node::Document(children) => children.iter().map(ast_to_html).collect(), + Node::Paragraph(children) => { + let inner: String = children.iter().map(ast_to_html).collect(); + format!("<p>{}</p>", inner) + } + Node::Bold(children) => { + let inner: String = children.iter().map(ast_to_html).collect(); + format!("<strong>{}</strong>", inner) + } + Node::Italic(children) => { + let inner: String = children.iter().map(ast_to_html).collect(); + format!("<em>{}</em>", inner) + } + Node::Code(children) => { + let inner: String = children.iter().map(ast_to_html).collect(); + format!("<code>{}</code>", inner) + } + Node::Superscript(children) => { + let inner: String = children.iter().map(ast_to_html).collect(); + format!("<sup>{}</sup>", inner) + } + Node::Subscript(children) => { + let inner: String = children.iter().map(ast_to_html).collect(); + format!("<sub>{}</sub>", inner) + } + Node::SmallCaps(children) => { + let inner: String = children.iter().map(ast_to_html).collect(); + format!("<text>{}</text>", inner) + } + Node::List(items) => { + let inner: String = items.iter().map(ast_to_html).collect(); + format!("<ul>{}</ul>", inner) + } + Node::ListItem(children) => { + let inner: String = children.iter().map(ast_to_html).collect(); + format!("<li>{}</li>", inner) + } + Node::Link { url, text } => { + let inner: String = text.iter().map(ast_to_html).collect(); + format!(r#"<a href="{}">{}</a>"#, url, inner) + } + Node::Text(text) => text.clone(), + } +} + +// Convert AST to Markdown +pub fn ast_to_markdown(node: &Node) -> String { + match node { + Node::Document(children) => { + if children.iter().all(is_inline_node) { + return children.iter().map(ast_to_markdown).collect(); + } + let mut result = String::new(); + for (i, child) in children.iter().enumerate() { + if i > 0 { + result.push_str("\n\n"); + } + result.push_str(&ast_to_markdown(child)); + } + result + } + Node::Paragraph(children) => { + let inner: String = children.iter().map(ast_to_markdown).collect(); + inner + } + Node::Bold(children) => { + let inner: String = children.iter().map(ast_to_markdown).collect(); + format!("**{}**", inner) + } + Node::Italic(children) => { + let inner: String = children.iter().map(ast_to_markdown).collect(); + format!("*{}*", inner) + } + Node::Code(children) => { + let inner: String = children.iter().map(ast_to_markdown).collect(); + format!("`{}`", inner) + } + Node::Superscript(children) => { + let inner: String = children.iter().map(ast_to_markdown).collect(); + format!("<sup>{}</sup>", inner) + } + Node::Subscript(children) => { + let inner: String = children.iter().map(ast_to_markdown).collect(); + format!("<sub>{}</sub>", inner) + } + Node::SmallCaps(children) => { + let inner: String = children.iter().map(ast_to_markdown).collect(); + format!("<sc>{}</sc>", inner) + } + Node::List(items) => { + let mut result = String::new(); + for item in items { + result.push_str(&ast_to_markdown(item)); + } + result + } + Node::ListItem(children) => { + let inner: String = children.iter().map(ast_to_markdown).collect(); + format!("- {}\n", inner) + } + Node::Link { url, text } => { + let inner: String = text.iter().map(ast_to_markdown).collect(); + format!("[{}]({})", inner, url) + } + Node::Text(text) => text.clone(), + } +} + +// Convert AST to plain text +pub fn ast_to_plain_text(node: &Node) -> String { + match node { + Node::Document(children) => { + if children.iter().all(is_inline_node) { + return children.iter().map(ast_to_plain_text).collect(); + } + let mut result = String::new(); + for (i, child) in children.iter().enumerate() { + if i > 0 { + result.push_str("\n\n"); + } + result.push_str(&ast_to_plain_text(child)); + } + result + } + Node::Paragraph(children) => { + let inner: String = children.iter().map(ast_to_plain_text).collect(); + inner + } + Node::Bold(children) + | Node::Italic(children) + | Node::Code(children) + | Node::Superscript(children) + | Node::Subscript(children) => { + // For plain text, we just extract the text content without formatting + children.iter().map(ast_to_plain_text).collect() + } + Node::SmallCaps(children) => { + // For plain text, we just extract the text content without formatting + children.iter().map(ast_to_plain_text).collect() + } + Node::List(items) => { + let mut result = String::new(); + for item in items { + result.push_str(&ast_to_plain_text(item)); + } + result + } + Node::ListItem(children) => { + let inner: String = children.iter().map(ast_to_plain_text).collect(); + format!("• {}\n", inner) + } + Node::Link { url, text } => { + let inner: String = text.iter().map(ast_to_plain_text).collect(); + format!("{} ({})", inner, url) + } + Node::Text(text) => text.clone(), + } +} + +fn is_inline_node(node: &Node) -> bool { + matches!( + node, + Node::Bold(_) + | Node::Italic(_) + | Node::Code(_) + | Node::Superscript(_) + | Node::Subscript(_) + | Node::SmallCaps(_) + | Node::Link { .. } + | Node::Text(_) + ) +} + +/// Strip structural elements from AST for title conversion (preserves paragraphs with inline content) +pub fn strip_structural_elements_from_ast(node: &Node) -> Node { + match node { + Node::Document(children) => { + let mut processed_children = Vec::new(); + for child in children { + let processed_child = strip_structural_elements_from_ast(child); + match processed_child { + Node::Document(grandchildren) => { + processed_children.extend(grandchildren); + } + _ => processed_children.push(processed_child), + } + } + Node::Document(processed_children) + } + Node::Paragraph(children) => { + // For titles, check if paragraph contains only inline elements + let all_inline = children.iter().all(|child| { + matches!( + child, + Node::Bold(_) + | Node::Italic(_) + | Node::Code(_) + | Node::Superscript(_) + | Node::Subscript(_) + | Node::Text(_) + | Node::Link { .. } + ) + }); + + if all_inline { + // If all children are inline, preserve the paragraph wrapper for titles + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast) + .collect(); + Node::Paragraph(processed_children) + } else { + // If contains structural elements, strip the paragraph but preserve content + let mut processed_children = Vec::new(); + for child in children { + let processed_child = strip_structural_elements_from_ast(child); + match processed_child { + Node::Document(grandchildren) => { + processed_children.extend(grandchildren); + } + _ => processed_children.push(processed_child), + } + } + if processed_children.len() == 1 { + processed_children.into_iter().next().unwrap() + } else { + Node::Document(processed_children) + } + } + } + Node::List(items) => { + // Lists are stripped, but their content is preserved + let mut processed_children = Vec::new(); + for item in items { + let processed_item = strip_structural_elements_from_ast(item); + match processed_item { + Node::Document(grandchildren) => { + processed_children.extend(grandchildren); + } + _ => processed_children.push(processed_item), + } + } + Node::Document(processed_children) + } + Node::ListItem(children) => { + // List items are stripped, but their content is preserved + let mut processed_children = Vec::new(); + for child in children { + let processed_child = strip_structural_elements_from_ast(child); + match processed_child { + Node::Document(grandchildren) => { + processed_children.extend(grandchildren); + } + _ => processed_children.push(processed_child), + } + } + Node::Document(processed_children) + } + Node::Bold(children) => { + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast) + .collect(); + Node::Bold(processed_children) + } + Node::Italic(children) => { + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast) + .collect(); + Node::Italic(processed_children) + } + Node::Code(children) => { + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast) + .collect(); + Node::Code(processed_children) + } + Node::Superscript(children) => { + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast) + .collect(); + Node::Superscript(processed_children) + } + Node::Subscript(children) => { + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast) + .collect(); + Node::Subscript(processed_children) + } + Node::SmallCaps(children) => { + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast) + .collect(); + Node::SmallCaps(processed_children) + } + Node::Link { url, text } => { + let processed_text: Vec<Node> = text + .iter() + .map(strip_structural_elements_from_ast) + .collect(); + Node::Link { + url: url.clone(), + text: processed_text, + } + } + Node::Text(text) => Node::Text(text.clone()), + } +} + +/// Strip structural elements from AST for convert_from_jats (strips all structural elements including paragraphs) +pub fn strip_structural_elements_from_ast_for_conversion(node: &Node) -> Node { + match node { + Node::Document(children) => { + let mut processed_children = Vec::new(); + for child in children { + let processed_child = strip_structural_elements_from_ast_for_conversion(child); + match processed_child { + Node::Document(grandchildren) => { + processed_children.extend(grandchildren); + } + _ => processed_children.push(processed_child), + } + } + Node::Document(processed_children) + } + Node::Paragraph(children) => { + // Always strip paragraphs for convert_from_jats + let mut processed_children = Vec::new(); + for child in children { + let processed_child = strip_structural_elements_from_ast_for_conversion(child); + match processed_child { + Node::Document(grandchildren) => { + processed_children.extend(grandchildren); + } + _ => processed_children.push(processed_child), + } + } + if processed_children.len() == 1 { + processed_children.into_iter().next().unwrap() + } else { + Node::Document(processed_children) + } + } + Node::List(items) => { + // Lists are stripped, but their content is preserved + let mut processed_children = Vec::new(); + for item in items { + let processed_item = strip_structural_elements_from_ast_for_conversion(item); + match processed_item { + Node::Document(grandchildren) => { + processed_children.extend(grandchildren); + } + _ => processed_children.push(processed_item), + } + } + Node::Document(processed_children) + } + Node::ListItem(children) => { + // List items are stripped, but their content is preserved + let mut processed_children = Vec::new(); + for child in children { + let processed_child = strip_structural_elements_from_ast_for_conversion(child); + match processed_child { + Node::Document(grandchildren) => { + processed_children.extend(grandchildren); + } + _ => processed_children.push(processed_child), + } + } + Node::Document(processed_children) + } + Node::Bold(children) => { + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast_for_conversion) + .collect(); + Node::Bold(processed_children) + } + Node::Italic(children) => { + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast_for_conversion) + .collect(); + Node::Italic(processed_children) + } + Node::Code(children) => { + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast_for_conversion) + .collect(); + Node::Code(processed_children) + } + Node::Superscript(children) => { + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast_for_conversion) + .collect(); + Node::Superscript(processed_children) + } + Node::Subscript(children) => { + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast_for_conversion) + .collect(); + Node::Subscript(processed_children) + } + Node::SmallCaps(children) => { + let processed_children: Vec<Node> = children + .iter() + .map(strip_structural_elements_from_ast_for_conversion) + .collect(); + Node::SmallCaps(processed_children) + } + Node::Link { url, text } => { + let processed_text: Vec<Node> = text + .iter() + .map(strip_structural_elements_from_ast_for_conversion) + .collect(); + Node::Link { + url: url.clone(), + text: processed_text, + } + } + Node::Text(text) => Node::Text(text.clone()), + } +} + +/// Validate AST content based on content type +pub fn validate_ast_content(node: &Node, conversion_limit: ConversionLimit) -> ThothResult<()> { + match conversion_limit { + ConversionLimit::Title => validate_title_content(node), + ConversionLimit::Abstract | ConversionLimit::Biography => validate_abstract_content(node), + } +} + +/// Validate title/subtitle content - only inline formatting allowed +fn validate_title_content(node: &Node) -> ThothResult<()> { + match node { + Node::Document(children) => { + // Document should only contain inline elements or a single paragraph + if children.len() > 1 { + // Check if all children are inline elements + let all_inline = children.iter().all(|child| { + matches!( + child, + Node::Bold(_) + | Node::Italic(_) + | Node::Code(_) + | Node::Superscript(_) + | Node::Subscript(_) + | Node::SmallCaps(_) + | Node::Text(_) + | Node::Link { .. } + ) + }); + if !all_inline { + return Err(ThothError::TitleMultipleTopLevelElementsError); + } + } + for child in children { + validate_title_content(child)?; + } + } + Node::Paragraph(children) => { + // Paragraphs are allowed in titles, but only for grouping inline elements + for child in children { + validate_title_content(child)?; + } + } + Node::Bold(children) + | Node::Italic(children) + | Node::Code(children) + | Node::Superscript(children) + | Node::Subscript(children) + | Node::SmallCaps(children) => { + // Inline formatting elements are allowed + for child in children { + validate_title_content(child)?; + } + } + Node::Link { text, .. } => { + // Links are allowed + for child in text { + validate_title_content(child)?; + } + } + Node::Text(_) => { + // Text nodes are allowed + } + Node::List(_) => { + return Err(ThothError::TitleListItemError); + } + Node::ListItem(_) => { + return Err(ThothError::TitleListItemError); + } + } + Ok(()) +} + +/// Validate abstract/biography content - paragraphs, breaks, and lists allowed +fn validate_abstract_content(node: &Node) -> ThothResult<()> { + match node { + Node::Document(children) => { + for child in children { + validate_abstract_content(child)?; + } + } + Node::Paragraph(children) + | Node::Bold(children) + | Node::Italic(children) + | Node::Code(children) + | Node::Superscript(children) + | Node::Subscript(children) + | Node::SmallCaps(children) => { + for child in children { + validate_abstract_content(child)?; + } + } + Node::List(children) | Node::ListItem(children) => { + for child in children { + validate_abstract_content(child)?; + } + } + Node::Link { text, .. } => { + for child in text { + validate_abstract_content(child)?; + } + } + Node::Text(_) => { + // Text nodes are always allowed + } + } + Ok(()) +} + +/// Check if content contains disallowed structural elements for titles +pub fn contains_disallowed_title_elements(content: &str) -> Vec<String> { + let mut disallowed = Vec::new(); + + // Check for HTML structural elements + let structural_patterns = [ + (r"<ul[^>]*>", "unordered list"), + (r"<ol[^>]*>", "ordered list"), + (r"<li[^>]*>", "list item"), + (r"<br\s*/?>", "line break"), + (r"<break\s*/?>", "break element"), + ]; + + for (pattern, description) in structural_patterns.iter() { + if let Ok(re) = regex::Regex::new(pattern) { + if re.is_match(content) { + disallowed.push(description.to_string()); + } + } + } + + // Check for Markdown structural elements + if content.contains("\n\n") && content.split("\n\n").count() > 1 { + disallowed.push("multiple paragraphs".to_string()); + } + + if content + .lines() + .any(|line| line.trim().starts_with("- ") || line.trim().starts_with("* ")) + { + disallowed.push("markdown list".to_string()); + } + + disallowed +} + +/// Check if content contains disallowed structural elements for abstracts/biographies +pub fn contains_disallowed_abstract_elements(content: &str) -> Vec<String> { + let mut disallowed = Vec::new(); + + // For abstracts/biographies, we allow most structural elements + // Only check for truly problematic elements + + // Check for nested lists (which might be too complex) + if let Ok(re) = regex::Regex::new(r"<li[^>]*>.*<ul[^>]*>") { + if re.is_match(content) { + disallowed.push("nested lists".to_string()); + } + } + + // Check for tables (not supported) + if content.contains("<table") || content.contains("<tr") || content.contains("<td") { + disallowed.push("tables".to_string()); + } + + // Check for images (not supported) + if content.contains("<img") || content.contains("![") { + disallowed.push("images".to_string()); + } + + disallowed +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_markdown_to_ast_basic() { + let markdown = "**Bold** and *italic* text"; + let ast = markdown_to_ast(markdown); + + match ast { + Node::Document(children) => { + assert_eq!(children.len(), 1); + match &children[0] { + Node::Paragraph(para_children) => { + assert_eq!(para_children.len(), 4); // Bold, text " and ", italic, text + // Check for bold, text, and italic nodes + let has_bold = para_children + .iter() + .any(|child| matches!(child, Node::Bold(_))); + let has_italic = para_children + .iter() + .any(|child| matches!(child, Node::Italic(_))); + let has_text = para_children + .iter() + .any(|child| matches!(child, Node::Text(_))); + assert!(has_bold); + assert!(has_italic); + assert!(has_text); + } + _ => panic!("Expected paragraph node"), + } + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_markdown_to_ast_list() { + let markdown = "- Item 1\n- Item 2"; + let ast = markdown_to_ast(markdown); + + match ast { + Node::Document(children) => { + assert_eq!(children.len(), 1); + match &children[0] { + Node::List(list_children) => { + assert_eq!(list_children.len(), 2); + for child in list_children { + match child { + Node::ListItem(_) => {} // Expected + _ => panic!("Expected list item node"), + } + } + } + _ => panic!("Expected list node"), + } + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_html_to_ast_basic() { + let html = "<p><strong>Bold</strong> and <em>italic</em> text</p>"; + let ast = html_to_ast(html); + + match ast { + Node::Document(children) => { + assert_eq!(children.len(), 1); + match &children[0] { + Node::Paragraph(para_children) => { + assert_eq!(para_children.len(), 4); // Bold, text " and ", italic, text + // Check for bold, text, and italic nodes + let has_bold = para_children + .iter() + .any(|child| matches!(child, Node::Bold(_))); + let has_italic = para_children + .iter() + .any(|child| matches!(child, Node::Italic(_))); + let has_text = para_children + .iter() + .any(|child| matches!(child, Node::Text(_))); + assert!(has_bold); + assert!(has_italic); + assert!(has_text); + } + _ => panic!("Expected paragraph node"), + } + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_html_to_ast_small_caps() { + let html = "<text>Small caps text</text>"; + let ast = html_to_ast(html); + + // Check that we have a SmallCaps node somewhere in the AST + fn find_small_caps(node: &Node) -> bool { + match node { + Node::SmallCaps(children) => { + if children.len() == 1 { + match &children[0] { + Node::Text(content) => content == "Small caps text", + _ => false, + } + } else { + false + } + } + Node::Document(children) | Node::Paragraph(children) => { + children.iter().any(find_small_caps) + } + _ => false, + } + } + + assert!( + find_small_caps(&ast), + "Expected to find SmallCaps node with 'Small caps text'" + ); + } + + #[test] + fn test_html_to_ast_list() { + let html = "<ul><li>Item 1</li><li>Item 2</li></ul>"; + let ast = html_to_ast(html); + + match ast { + Node::Document(children) => { + assert_eq!(children.len(), 1); + match &children[0] { + Node::List(list_children) => { + assert_eq!(list_children.len(), 2); + for child in list_children { + match child { + Node::ListItem(_) => {} // Expected + _ => panic!("Expected list item node"), + } + } + } + _ => panic!("Expected list node"), + } + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_html_to_ast_ordered_list() { + let html = "<ol><li>First</li><li>Second</li></ol>"; + let ast = html_to_ast(html); + + match ast { + Node::Document(children) => { + assert_eq!(children.len(), 1); + match &children[0] { + Node::List(list_children) => { + assert_eq!(list_children.len(), 2); + } + _ => panic!("Expected list node"), + } + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_html_to_ast_link() { + let html = r#"<a href="https://example.com">Link text</a>"#; + let ast = html_to_ast(html); + + match ast { + Node::Document(children) => { + assert_eq!(children.len(), 1); + match &children[0] { + Node::Link { url, text } => { + assert_eq!(url, "https://example.com"); + assert_eq!(text.len(), 1); + match &text[0] { + Node::Text(content) => assert_eq!(content, "Link text"), + _ => panic!("Expected text node"), + } + } + _ => panic!("Expected link node"), + } + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_plain_text_to_ast_single_paragraph() { + let text = "This is a single paragraph."; + let ast = plain_text_to_ast(text); + + match ast { + Node::Text(content) => { + assert_eq!(content, "This is a single paragraph."); + } + _ => panic!("Expected text node"), + } + } + + #[test] + fn test_plain_text_to_ast_multiple_paragraphs() { + let text = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph."; + let ast = plain_text_to_ast(text); + + match ast { + Node::Text(content) => { + assert_eq!( + content, + "First paragraph.\n\nSecond paragraph.\n\nThird paragraph." + ); + } + _ => panic!("Expected text node"), + } + } + + #[test] + fn test_plain_text_to_ast_empty_paragraphs_filtered() { + let text = "First paragraph.\n\n\n\nSecond paragraph."; + let ast = plain_text_to_ast(text); + + match ast { + Node::Text(content) => { + assert_eq!(content, "First paragraph.\n\n\n\nSecond paragraph."); + } + _ => panic!("Expected text node"), + } + } + + #[test] + fn test_ast_to_jats_document() { + let ast = Node::Document(vec![ + Node::Paragraph(vec![Node::Text("Hello".to_string())]), + Node::Bold(vec![Node::Text("Bold text".to_string())]), + ]); + + let jats = ast_to_jats(&ast); + assert!(jats.contains("<p>Hello</p>")); + assert!(jats.contains("<bold>Bold text</bold>")); + } + + #[test] + fn test_ast_to_jats_paragraph() { + let ast = Node::Paragraph(vec![ + Node::Text("Hello ".to_string()), + Node::Bold(vec![Node::Text("world".to_string())]), + ]); + + let jats = ast_to_jats(&ast); + assert_eq!(jats, "<p>Hello <bold>world</bold></p>"); + } + + #[test] + fn test_ast_to_jats_list() { + let ast = Node::List(vec![ + Node::ListItem(vec![Node::Text("Item 1".to_string())]), + Node::ListItem(vec![Node::Text("Item 2".to_string())]), + ]); + + let jats = ast_to_jats(&ast); + assert_eq!( + jats, + "<list><list-item>Item 1</list-item><list-item>Item 2</list-item></list>" + ); + } + + #[test] + fn test_ast_to_jats_superscript() { + let ast = Node::Superscript(vec![Node::Text("2".to_string())]); + let jats = ast_to_jats(&ast); + assert_eq!(jats, "<sup>2</sup>"); + } + + #[test] + fn test_ast_to_jats_subscript() { + let ast = Node::Subscript(vec![Node::Text("H2O".to_string())]); + let jats = ast_to_jats(&ast); + assert_eq!(jats, "<sub>H2O</sub>"); + } + + #[test] + fn test_ast_to_jats_bold() { + let ast = Node::Bold(vec![Node::Text("Bold text".to_string())]); + let jats = ast_to_jats(&ast); + assert_eq!(jats, "<bold>Bold text</bold>"); + } + + #[test] + fn test_ast_to_jats_italic() { + let ast = Node::Italic(vec![Node::Text("Italic text".to_string())]); + let jats = ast_to_jats(&ast); + assert_eq!(jats, "<italic>Italic text</italic>"); + } + + #[test] + fn test_ast_to_jats_list_item() { + let ast = Node::ListItem(vec![Node::Text("List item text".to_string())]); + let jats = ast_to_jats(&ast); + assert_eq!(jats, "<list-item>List item text</list-item>"); + } + + #[test] + fn test_ast_to_jats_link() { + let ast = Node::Link { + url: "https://example.com".to_string(), + text: vec![Node::Text("Link text".to_string())], + }; + let jats = ast_to_jats(&ast); + assert_eq!( + jats, + r#"<ext-link xlink:href="https://example.com">Link text</ext-link>"# + ); + } + + #[test] + fn test_round_trip_markdown_to_jats() { + let markdown = "**Bold** and *italic* text\n\n- Item 1\n- Item 2"; + let ast = markdown_to_ast(markdown); + let jats = ast_to_jats(&ast); + + // Should contain the expected JATS elements + assert!(jats.contains("<bold>Bold</bold>")); + assert!(jats.contains("<italic>italic</italic>")); + assert!(jats.contains("<list>")); + assert!(jats.contains("<list-item>Item 1</list-item>")); + assert!(jats.contains("<list-item>Item 2</list-item>")); + } + + #[test] + fn test_round_trip_html_to_jats() { + let html = "<p><strong>Bold</strong> and <em>italic</em> text</p><ul><li>Item 1</li><li>Item 2</li></ul>"; + let ast = html_to_ast(html); + let jats = ast_to_jats(&ast); + + // Should contain the expected JATS elements + assert!(jats.contains("<bold>Bold</bold>")); + assert!(jats.contains("<italic>italic</italic>")); + assert!(jats.contains("<list>")); + assert!(jats.contains("<list-item>Item 1</list-item>")); + assert!(jats.contains("<list-item>Item 2</list-item>")); + } + + #[test] + fn test_round_trip_plain_text_to_jats() { + let text = "First paragraph.\n\nSecond paragraph with multiple lines.\nIt continues here."; + let ast = plain_text_to_ast(text); + let jats = plain_text_ast_to_jats(&ast); + + // Should wrap plain text in <p><sc> tags + assert_eq!( + jats, + "<p>First paragraph.\n\nSecond paragraph with multiple lines.\nIt continues here.</p>" + ); + } + + #[test] + fn test_empty_input() { + let empty_ast = markdown_to_ast(""); + let jats = ast_to_jats(&empty_ast); + assert_eq!(jats, ""); + } + + #[test] + fn test_nested_formatting() { + let markdown = "**Bold with *italic* inside**"; + let ast = markdown_to_ast(markdown); + let jats = ast_to_jats(&ast); + + // Should handle nested formatting + assert!(jats.contains("<bold>")); + assert!(jats.contains("<italic>")); + } + + #[test] + fn test_markdown_to_ast_code() { + let markdown = "This is `inline code` text"; + let ast = markdown_to_ast(markdown); + + match ast { + Node::Document(children) => { + assert_eq!(children.len(), 1); + match &children[0] { + Node::Paragraph(para_children) => { + assert_eq!(para_children.len(), 3); // Text, Code, Text + let has_code = para_children + .iter() + .any(|child| matches!(child, Node::Code(_))); + assert!(has_code); + } + _ => panic!("Expected paragraph node"), + } + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_html_to_ast_code() { + let html = "<p>This is <code>inline code</code> text</p>"; + let ast = html_to_ast(html); + + match ast { + Node::Document(children) => { + assert_eq!(children.len(), 1); + match &children[0] { + Node::Paragraph(para_children) => { + assert_eq!(para_children.len(), 3); // Text, Code, Text + let has_code = para_children + .iter() + .any(|child| matches!(child, Node::Code(_))); + assert!(has_code); + } + _ => panic!("Expected paragraph node"), + } + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_ast_to_jats_code() { + let ast = Node::Code(vec![Node::Text("inline code".to_string())]); + let jats = ast_to_jats(&ast); + assert_eq!(jats, "<monospace>inline code</monospace>"); + } + + #[test] + fn test_ast_to_jats_code_with_nested_content() { + let ast = Node::Code(vec![ + Node::Text("function ".to_string()), + Node::Bold(vec![Node::Text("main".to_string())]), + Node::Text("()".to_string()), + ]); + let jats = ast_to_jats(&ast); + assert_eq!(jats, "<monospace>function <bold>main</bold>()</monospace>"); + } + + #[test] + fn test_round_trip_markdown_code_to_jats() { + let markdown = "Use `println!` macro for output"; + let ast = markdown_to_ast(markdown); + let jats = ast_to_jats(&ast); + + assert!(jats.contains("<monospace>println!</monospace>")); + } + + #[test] + fn test_round_trip_html_code_to_jats() { + let html = "<p>Use <code>println!</code> macro for output</p>"; + let ast = html_to_ast(html); + let jats = ast_to_jats(&ast); + + assert!(jats.contains("<monospace>println!</monospace>")); + } + + #[test] + fn test_code_with_multiple_spans() { + let markdown = "`first` and `second` code spans"; + let ast = markdown_to_ast(markdown); + let jats = ast_to_jats(&ast); + + assert!(jats.contains("<monospace>first</monospace>")); + assert!(jats.contains("<monospace>second</monospace>")); + } + + #[test] + fn test_code_in_list_item() { + let markdown = "- Use `git commit` to save changes"; + let ast = markdown_to_ast(markdown); + let jats = ast_to_jats(&ast); + + assert!(jats.contains("<list-item>")); + assert!(jats.contains("<monospace>git commit</monospace>")); + } + + #[test] + fn test_code_in_link() { + let html = r#"<a href="https://docs.rs">Visit <code>docs.rs</code> for documentation</a>"#; + let ast = html_to_ast(html); + let jats = ast_to_jats(&ast); + + assert!(jats.contains(r#"<ext-link xlink:href="https://docs.rs">"#)); + assert!(jats.contains("<monospace>docs.rs</monospace>")); + } + + #[test] + fn test_plain_text_to_ast_with_url() { + let text = "Visit https://example.com for more info"; + let ast = plain_text_to_ast(text); + + match ast { + Node::Document(children) => { + assert_eq!(children.len(), 3); // Text, Link, Text + let has_link = children + .iter() + .any(|child| matches!(child, Node::Link { .. })); + assert!(has_link); + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_plain_text_to_ast_multiple_urls() { + let text = "Check https://example.com and https://docs.rs for resources"; + let ast = plain_text_to_ast(text); + let jats = ast_to_jats(&ast); + + assert!(jats.contains(r#"<ext-link xlink:href="https://example.com">"#)); + assert!(jats.contains(r#"<ext-link xlink:href="https://docs.rs">"#)); + } + + #[test] + fn test_plain_text_to_ast_no_urls() { + let text = "This is just plain text without any URLs"; + let ast = plain_text_to_ast(text); + + match ast { + Node::Text(content) => { + assert_eq!(content, "This is just plain text without any URLs"); + } + _ => panic!("Expected text node"), + } + } + + #[test] + fn test_plain_text_to_ast_url_with_text() { + let text = "Visit https://example.com for more information"; + let ast = plain_text_to_ast(text); + let jats = ast_to_jats(&ast); + + assert!(jats.contains("Visit ")); + assert!(jats.contains( + r#"<ext-link xlink:href="https://example.com">https://example.com</ext-link>"# + )); + assert!(jats.contains(" for more information")); + } + + // Validation tests + #[test] + fn test_validate_title_content_valid() { + let ast = Node::Document(vec![Node::Paragraph(vec![Node::Text( + "Simple Title".to_string(), + )])]); + assert!(validate_ast_content(&ast, ConversionLimit::Title).is_ok()); + } + + #[test] + fn test_validate_title_content_with_inline_formatting() { + let ast = Node::Document(vec![Node::Paragraph(vec![ + Node::Bold(vec![Node::Text("Bold".to_string())]), + Node::Text(" and ".to_string()), + Node::Italic(vec![Node::Text("italic".to_string())]), + Node::Text(" text".to_string()), + ])]); + assert!(validate_ast_content(&ast, ConversionLimit::Title).is_ok()); + } + + #[test] + fn test_validate_title_content_with_link() { + let ast = Node::Document(vec![Node::Paragraph(vec![ + Node::Text("Visit ".to_string()), + Node::Link { + url: "https://example.com".to_string(), + text: vec![Node::Text("example.com".to_string())], + }, + ])]); + assert!(validate_ast_content(&ast, ConversionLimit::Title).is_ok()); + } + + #[test] + fn test_validate_title_content_disallows_lists() { + let ast = Node::Document(vec![Node::List(vec![Node::ListItem(vec![Node::Text( + "Item 1".to_string(), + )])])]); + assert!(validate_ast_content(&ast, ConversionLimit::Title).is_err()); + } + + #[test] + fn test_validate_title_content_disallows_multiple_top_level() { + let ast = Node::Document(vec![ + Node::Paragraph(vec![Node::Text("First".to_string())]), + Node::Paragraph(vec![Node::Text("Second".to_string())]), + ]); + assert!(validate_ast_content(&ast, ConversionLimit::Title).is_err()); + } + + #[test] + fn test_validate_abstract_content_allows_lists() { + let ast = Node::Document(vec![Node::List(vec![ + Node::ListItem(vec![Node::Text("Item 1".to_string())]), + Node::ListItem(vec![Node::Text("Item 2".to_string())]), + ])]); + assert!(validate_ast_content(&ast, ConversionLimit::Abstract).is_ok()); + } + + #[test] + fn test_validate_abstract_content_allows_multiple_paragraphs() { + let ast = Node::Document(vec![ + Node::Paragraph(vec![Node::Text("First paragraph".to_string())]), + Node::Paragraph(vec![Node::Text("Second paragraph".to_string())]), + ]); + assert!(validate_ast_content(&ast, ConversionLimit::Abstract).is_ok()); + } + + #[test] + fn test_validate_abstract_content_allows_nested_formatting() { + let ast = Node::Document(vec![Node::Paragraph(vec![Node::Bold(vec![ + Node::Text("Bold with ".to_string()), + Node::Italic(vec![Node::Text("italic".to_string())]), + ])])]); + assert!(validate_ast_content(&ast, ConversionLimit::Abstract).is_ok()); + } + + #[test] + fn test_contains_disallowed_title_elements_html() { + let content = "<p>Title with <ul><li>list</li></ul></p>"; + let disallowed = contains_disallowed_title_elements(content); + assert!(disallowed.contains(&"unordered list".to_string())); + } + + #[test] + fn test_contains_disallowed_title_elements_markdown() { + let content = "Title\n\nWith multiple paragraphs"; + let disallowed = contains_disallowed_title_elements(content); + assert!(disallowed.contains(&"multiple paragraphs".to_string())); + } + + #[test] + fn test_contains_disallowed_title_elements_markdown_list() { + let content = "Title with\n- Item 1\n- Item 2"; + let disallowed = contains_disallowed_title_elements(content); + assert!(disallowed.contains(&"markdown list".to_string())); + } + + #[test] + fn test_contains_disallowed_title_elements_valid() { + let content = "<p><strong>Valid Title</strong></p>"; + let disallowed = contains_disallowed_title_elements(content); + assert!(disallowed.is_empty()); + } + + #[test] + fn test_contains_disallowed_abstract_elements_tables() { + let content = "<p>Abstract with <table><tr><td>data</td></tr></table></p>"; + let disallowed = contains_disallowed_abstract_elements(content); + assert!(disallowed.contains(&"tables".to_string())); + } + + #[test] + fn test_contains_disallowed_abstract_elements_images() { + let content = "<p>Abstract with <img src=\"test.jpg\"></p>"; + let disallowed = contains_disallowed_abstract_elements(content); + assert!(disallowed.contains(&"images".to_string())); + } + + #[test] + fn test_contains_disallowed_abstract_elements_valid() { + let content = "<p>Valid abstract with <ul><li>list</li></ul></p>"; + let disallowed = contains_disallowed_abstract_elements(content); + assert!(disallowed.is_empty()); + } + + #[test] + fn test_validation_error_display() { + let error = ThothError::RequestError("Lists are not allowed".to_string()); + assert!(error.to_string().contains("Lists are not allowed")); + + let error = ThothError::RequestError("Structural element 'div' is not allowed".to_string()); + assert!(error + .to_string() + .contains("Structural element 'div' is not allowed")); + } + + // JATS to AST tests + #[test] + fn test_jats_to_ast_basic_formatting() { + let jats = "<bold>Bold text</bold> and <italic>italic text</italic>"; + let ast = jats_to_ast(jats); + + // Debug: let's see what we actually get + match ast { + Node::Document(children) => { + // For now, let's just check that we have the expected elements + // regardless of whether they're wrapped in a paragraph + let has_bold = children.iter().any(|child| matches!(child, Node::Bold(_))); + let has_italic = children + .iter() + .any(|child| matches!(child, Node::Italic(_))); + let has_text = children.iter().any(|child| matches!(child, Node::Text(_))); + assert!(has_bold); + assert!(has_italic); + assert!(has_text); + + // If we have exactly 3 children, they should be wrapped in a paragraph + if children.len() == 3 { + // This means the paragraph wrapping didn't work + // Let's check if all children are inline elements + let all_inline = children.iter().all(|child| { + matches!( + child, + Node::Bold(_) + | Node::Italic(_) + | Node::Code(_) + | Node::Superscript(_) + | Node::Subscript(_) + | Node::Text(_) + | Node::Link { .. } + ) + }); + assert!(all_inline, "All children should be inline elements"); + } else if children.len() == 1 { + // This means they were wrapped in a paragraph + match &children[0] { + Node::Paragraph(para_children) => { + assert_eq!(para_children.len(), 3); + } + _ => panic!("Expected paragraph node"), + } + } else { + panic!("Unexpected number of children: {}", children.len()); + } + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_jats_to_ast_link() { + let jats = r#"<ext-link xlink:href="https://example.com">Link text</ext-link>"#; + let ast = jats_to_ast(jats); + + match ast { + Node::Document(children) => { + assert_eq!(children.len(), 1); + match &children[0] { + Node::Link { url, text } => { + assert_eq!(url, "https://example.com"); + assert_eq!(text.len(), 1); + match &text[0] { + Node::Text(content) => assert_eq!(content, "Link text"), + _ => panic!("Expected text node"), + } + } + _ => panic!("Expected link node"), + } + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_jats_to_ast_list() { + let jats = "<list><list-item>Item 1</list-item><list-item>Item 2</list-item></list>"; + let ast = jats_to_ast(jats); + + match ast { + Node::Document(children) => { + assert_eq!(children.len(), 1); + match &children[0] { + Node::List(list_children) => { + assert_eq!(list_children.len(), 2); + for child in list_children { + match child { + Node::ListItem(_) => {} // Expected + _ => panic!("Expected list item node"), + } + } + } + _ => panic!("Expected list node"), + } + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_jats_to_ast_superscript_subscript() { + let jats = "<p>H<sub>2</sub>O and E=mc<sup>2</sup></p>"; + let ast = jats_to_ast(jats); + + match ast { + Node::Document(children) => { + // The HTML parser creates multiple nodes: text "H", sub, text "O and E=mc", sup, text "" + assert!(!children.is_empty()); + + // Helper function to check recursively for subscript/superscript + fn has_node_type(node: &Node, check_subscript: bool) -> bool { + match node { + Node::Subscript(_) if check_subscript => true, + Node::Superscript(_) if !check_subscript => true, + Node::Document(children) + | Node::Paragraph(children) + | Node::Bold(children) + | Node::Italic(children) + | Node::Code(children) + | Node::Superscript(children) + | Node::Subscript(children) + | Node::List(children) + | Node::ListItem(children) => children + .iter() + .any(|child| has_node_type(child, check_subscript)), + Node::Link { text, .. } => text + .iter() + .any(|child| has_node_type(child, check_subscript)), + _ => false, + } + } + + let has_subscript = children.iter().any(|child| has_node_type(child, true)); + let has_superscript = children.iter().any(|child| has_node_type(child, false)); + + assert!(has_subscript); + assert!(has_superscript); + } + _ => panic!("Expected document node"), + } + } + + #[test] + fn test_jats_to_ast_small_caps() { + let jats = "<sc>Small caps text</sc>"; + let ast = jats_to_ast(jats); + + // Debug: let's see what we actually get + match ast { + Node::SmallCaps(children) => { + assert_eq!(children.len(), 1); + match &children[0] { + Node::Text(content) => { + assert_eq!(content, "Small caps text"); + } + _ => panic!("Expected text node as child of SmallCaps"), + } + } + Node::Document(children) => { + // If it's a document, check if it has one child that's a SmallCaps node + if children.len() == 1 { + match &children[0] { + Node::SmallCaps(sc_children) => { + assert_eq!(sc_children.len(), 1); + match &sc_children[0] { + Node::Text(content) => { + assert_eq!(content, "Small caps text"); + } + _ => panic!("Expected text node as child of SmallCaps"), + } + } + _ => panic!( + "Expected SmallCaps node as single child, got: {:?}", + children[0] + ), + } + } else { + panic!( + "Expected single child in document, got {} children: {:?}", + children.len(), + children + ); + } + } + _ => panic!( + "Expected SmallCaps node or document with SmallCaps child, got: {:?}", + ast + ), + } + } + + #[test] + fn test_jats_to_ast_round_trip() { + let original_jats = "<bold>Bold</bold> and <italic>italic</italic> with <ext-link xlink:href=\"https://example.com\">link</ext-link>"; + let ast = jats_to_ast(original_jats); + let converted_jats = ast_to_jats(&ast); + + // Should preserve the basic structure + assert!(converted_jats.contains("<bold>Bold</bold>")); + assert!(converted_jats.contains("<italic>italic</italic>")); + assert!(converted_jats + .contains(r#"<ext-link xlink:href="https://example.com">link</ext-link>"#)); + } + + // AST to HTML tests + #[test] + fn test_ast_to_html_basic() { + let ast = Node::Document(vec![Node::Paragraph(vec![ + Node::Bold(vec![Node::Text("Bold".to_string())]), + Node::Text(" and ".to_string()), + Node::Italic(vec![Node::Text("italic".to_string())]), + ])]); + let html = ast_to_html(&ast); + assert_eq!(html, "<p><strong>Bold</strong> and <em>italic</em></p>"); + } + + #[test] + fn test_ast_to_html_small_caps() { + let ast = Node::SmallCaps(vec![Node::Text("Small caps text".to_string())]); + let html = ast_to_html(&ast); + assert_eq!(html, "<text>Small caps text</text>"); + } + + #[test] + fn test_ast_to_html_list() { + let ast = Node::List(vec![ + Node::ListItem(vec![Node::Text("Item 1".to_string())]), + Node::ListItem(vec![Node::Text("Item 2".to_string())]), + ]); + let html = ast_to_html(&ast); + assert_eq!(html, "<ul><li>Item 1</li><li>Item 2</li></ul>"); + } + + #[test] + fn test_ast_to_html_link() { + let ast = Node::Link { + url: "https://example.com".to_string(), + text: vec![Node::Text("Link text".to_string())], + }; + let html = ast_to_html(&ast); + assert_eq!(html, r#"<a href="https://example.com">Link text</a>"#); + } + + // AST to Markdown tests + #[test] + fn test_ast_to_markdown_basic() { + let ast = Node::Document(vec![Node::Paragraph(vec![ + Node::Bold(vec![Node::Text("Bold".to_string())]), + Node::Text(" and ".to_string()), + Node::Italic(vec![Node::Text("italic".to_string())]), + ])]); + let markdown = ast_to_markdown(&ast); + assert_eq!(markdown, "**Bold** and *italic*"); + } + + #[test] + fn test_ast_to_markdown_list() { + let ast = Node::List(vec![ + Node::ListItem(vec![Node::Text("Item 1".to_string())]), + Node::ListItem(vec![Node::Text("Item 2".to_string())]), + ]); + let markdown = ast_to_markdown(&ast); + assert_eq!(markdown, "- Item 1\n- Item 2\n"); + } + + #[test] + fn test_ast_to_markdown_link() { + let ast = Node::Link { + url: "https://example.com".to_string(), + text: vec![Node::Text("Link text".to_string())], + }; + let markdown = ast_to_markdown(&ast); + assert_eq!(markdown, "[Link text](https://example.com)"); + } + + #[test] + fn test_ast_to_markdown_code() { + let ast = Node::Code(vec![Node::Text("code".to_string())]); + let markdown = ast_to_markdown(&ast); + assert_eq!(markdown, "`code`"); + } + + // AST to plain text tests + #[test] + fn test_ast_to_plain_text_basic() { + let ast = Node::Document(vec![Node::Paragraph(vec![ + Node::Bold(vec![Node::Text("Bold".to_string())]), + Node::Text(" and ".to_string()), + Node::Italic(vec![Node::Text("italic".to_string())]), + ])]); + let plain = ast_to_plain_text(&ast); + assert_eq!(plain, "Bold and italic"); + } + + #[test] + fn test_ast_to_plain_text_list() { + let ast = Node::List(vec![ + Node::ListItem(vec![Node::Text("Item 1".to_string())]), + Node::ListItem(vec![Node::Text("Item 2".to_string())]), + ]); + let plain = ast_to_plain_text(&ast); + assert_eq!(plain, "• Item 1\n• Item 2\n"); + } + + #[test] + fn test_ast_to_plain_text_link() { + let ast = Node::Link { + url: "https://example.com".to_string(), + text: vec![Node::Text("Link text".to_string())], + }; + let plain = ast_to_plain_text(&ast); + assert_eq!(plain, "Link text (https://example.com)"); + } + + #[test] + fn test_ast_to_plain_text_multiple_paragraphs() { + let ast = Node::Document(vec![ + Node::Paragraph(vec![Node::Text("First paragraph".to_string())]), + Node::Paragraph(vec![Node::Text("Second paragraph".to_string())]), + ]); + let plain = ast_to_plain_text(&ast); + assert_eq!(plain, "First paragraph\n\nSecond paragraph"); + } + + // Round-trip tests + #[test] + fn test_round_trip_html_to_ast_to_html() { + let original_html = "<p><strong>Bold</strong> and <em>italic</em></p>"; + let ast = html_to_ast(original_html); + let converted_html = ast_to_html(&ast); + assert_eq!(converted_html, original_html); + } + + #[test] + fn test_round_trip_markdown_to_ast_to_markdown() { + let original_markdown = "**Bold** and *italic*"; + let ast = markdown_to_ast(original_markdown); + let converted_markdown = ast_to_markdown(&ast); + // Note: The converted markdown might be slightly different due to paragraph wrapping + assert!(converted_markdown.contains("**Bold**")); + assert!(converted_markdown.contains("*italic*")); + } + + #[test] + fn test_round_trip_jats_to_ast_to_jats() { + let original_jats = "<bold>Bold</bold> and <italic>italic</italic>"; + let ast = jats_to_ast(original_jats); + let converted_jats = ast_to_jats(&ast); + assert!(converted_jats.contains("<bold>Bold</bold>")); + assert!(converted_jats.contains("<italic>italic</italic>")); + } +} diff --git a/thoth-api/src/markup/mod.rs b/thoth-api/src/markup/mod.rs new file mode 100644 index 00000000..3fad85de --- /dev/null +++ b/thoth-api/src/markup/mod.rs @@ -0,0 +1,490 @@ +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; +use thoth_errors::{ThothError, ThothResult}; + +pub mod ast; + +use ast::{ + ast_to_html, ast_to_jats, ast_to_markdown, ast_to_plain_text, html_to_ast, jats_to_ast, + markdown_to_ast, plain_text_ast_to_jats, plain_text_to_ast, + strip_structural_elements_from_ast_for_conversion, validate_ast_content, +}; + +/// Enum to represent the markup format +#[cfg_attr( + feature = "backend", + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), + graphql( + description = "Allowed markup formats for text fields that support structured content" + ), + ExistingTypePath = "crate::schema::sql_types::MarkupFormat" +)] +#[derive( + Debug, Copy, Clone, Default, PartialEq, Eq, Deserialize, Serialize, EnumString, Display, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "UPPERCASE")] +pub enum MarkupFormat { + #[cfg_attr(feature = "backend", graphql(description = "HTML format"))] + Html, + #[cfg_attr(feature = "backend", graphql(description = "Markdown format"))] + Markdown, + #[cfg_attr(feature = "backend", graphql(description = "Plain text format"))] + PlainText, + #[cfg_attr(feature = "backend", graphql(description = "JATS XML format"))] + #[default] + JatsXml, +} + +/// Limits how much structure is preserved/allowed when converting to/from JATS. +/// +/// - `Abstract`/`Biography`: allow basic structural elements (paragraphs, lists, emphasis, links). +/// - `Title`: disallow structure; structural tags are stripped to plain inline text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversionLimit { + Abstract, + Biography, + Title, +} + +/// Validate content format based on markup format +pub fn validate_format(content: &str, format: &MarkupFormat) -> ThothResult<()> { + match format { + MarkupFormat::Html | MarkupFormat::JatsXml => { + // Basic HTML validation - check for opening and closing tags + if !content.contains('<') || !content.contains('>') || !content.contains("</") { + return Err(ThothError::UnsupportedFileFormatError); + } + } + MarkupFormat::Markdown => { + // Basic Markdown validation - check for markdown syntax + if content.contains('<') && content.contains('>') { + // At least one markdown element should be present + return Err(ThothError::UnsupportedFileFormatError); + } + } + MarkupFormat::PlainText => {} + } + Ok(()) +} + +/// Convert content to JATS XML format with specified tag +pub fn convert_to_jats( + content: String, + format: MarkupFormat, + conversion_limit: ConversionLimit, +) -> ThothResult<String> { + validate_format(&content, &format)?; + let mut output = content.clone(); + + match format { + MarkupFormat::Html => { + // Use ast library to parse HTML and convert to JATS + let ast = html_to_ast(&content); + + // For title conversion, strip structural elements before validation + let processed_ast = if conversion_limit == ConversionLimit::Title { + strip_structural_elements_from_ast_for_conversion(&ast) + } else { + ast + }; + + validate_ast_content(&processed_ast, conversion_limit)?; + output = ast_to_jats(&processed_ast); + } + + MarkupFormat::Markdown => { + // Use ast library to parse Markdown and convert to JATS + let ast = markdown_to_ast(&content); + + // For title conversion, strip structural elements before validation + let processed_ast = if conversion_limit == ConversionLimit::Title { + strip_structural_elements_from_ast_for_conversion(&ast) + } else { + ast + }; + + validate_ast_content(&processed_ast, conversion_limit)?; + output = ast_to_jats(&processed_ast); + } + + MarkupFormat::PlainText => { + // Use ast library to parse plain text and convert to JATS + let ast = plain_text_to_ast(&content); + + // For title conversion, strip structural elements before validation + let processed_ast = if conversion_limit == ConversionLimit::Title { + strip_structural_elements_from_ast_for_conversion(&ast) + } else { + ast + }; + + validate_ast_content(&processed_ast, conversion_limit)?; + output = if conversion_limit == ConversionLimit::Title { + // Title JATS should remain inline (no paragraph wrapper) + ast_to_jats(&processed_ast) + } else { + plain_text_ast_to_jats(&processed_ast) + }; + } + + MarkupFormat::JatsXml => {} + } + + Ok(output) +} + +/// Convert from JATS XML to specified format using a specific tag name +pub fn convert_from_jats( + jats_xml: &str, + format: MarkupFormat, + conversion_limit: ConversionLimit, +) -> ThothResult<String> { + // Allow plain-text content that was stored without JATS markup for titles. + if !jats_xml.contains('<') || !jats_xml.contains("</") { + let ast = plain_text_to_ast(jats_xml); + let processed_ast = if conversion_limit == ConversionLimit::Title { + strip_structural_elements_from_ast_for_conversion(&ast) + } else { + ast + }; + validate_ast_content(&processed_ast, conversion_limit)?; + return Ok(match format { + MarkupFormat::Html => ast_to_html(&processed_ast), + MarkupFormat::Markdown => ast_to_markdown(&processed_ast), + MarkupFormat::PlainText => ast_to_plain_text(&processed_ast), + MarkupFormat::JatsXml => { + if conversion_limit == ConversionLimit::Title { + ast_to_jats(&processed_ast) + } else { + plain_text_ast_to_jats(&processed_ast) + } + } + }); + } + + validate_format(jats_xml, &MarkupFormat::JatsXml)?; + + // Parse JATS to AST first for better handling + let ast = jats_to_ast(jats_xml); + + // For title conversion, strip structural elements before validation + let processed_ast = if conversion_limit == ConversionLimit::Title { + strip_structural_elements_from_ast_for_conversion(&ast) + } else { + ast + }; + + // Validate the AST content based on conversion limit + validate_ast_content(&processed_ast, conversion_limit)?; + + let output = match format { + MarkupFormat::Html => { + // Use the dedicated AST to HTML converter + ast_to_html(&processed_ast) + } + + MarkupFormat::Markdown => { + // Use the dedicated AST to Markdown converter + ast_to_markdown(&processed_ast) + } + + MarkupFormat::PlainText => { + // Use the dedicated AST to plain text converter + ast_to_plain_text(&processed_ast) + } + + MarkupFormat::JatsXml => { + // Return the AST converted back to JATS (should be identical) + jats_xml.to_string() + } + }; + + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- convert_to_jats tests start --- + #[test] + fn test_html_basic_formatting() { + let input = "<em>Italic</em> and <strong>Bold</strong>"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Html, + ConversionLimit::Biography, + ) + .unwrap(); + assert_eq!(output, "<italic>Italic</italic> and <bold>Bold</bold>"); + } + + #[test] + fn test_html_link_conversion() { + let input = r#"<a href="https://example.com">Link</a>"#; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Html, + ConversionLimit::Abstract, + ) + .unwrap(); + assert_eq!( + output, + r#"<ext-link xlink:href="https://example.com">Link</ext-link>"# + ); + } + + #[test] + fn test_html_with_structure_allowed() { + let input = "<ul><li>One</li><li>Two</li></ul>"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Html, + ConversionLimit::Abstract, + ) + .unwrap(); + assert_eq!( + output, + "<list><list-item>One</list-item><list-item>Two</list-item></list>" + ); + } + + #[test] + fn test_html_with_structure_stripped() { + let input = "<ul><li>One</li></ul>"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Html, + ConversionLimit::Title, + ) + .unwrap(); + assert_eq!(output, "One"); + } + + #[test] + fn test_html_small_caps_conversion() { + let input = "<text>Small caps text</text>"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Html, + ConversionLimit::Title, + ) + .unwrap(); + assert_eq!(output, "<sc>Small caps text</sc>"); + } + + #[test] + fn test_markdown_basic_formatting() { + let input = "**Bold** and *Italic* and `code`"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Markdown, + ConversionLimit::Title, + ) + .unwrap(); + assert_eq!( + output, + "<bold>Bold</bold> and <italic>Italic</italic> and <monospace>code</monospace>" + ); + } + + #[test] + fn test_markdown_link_conversion() { + let input = "[text](https://example.com)"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Markdown, + ConversionLimit::Title, + ) + .unwrap(); + assert_eq!( + output, + r#"<ext-link xlink:href="https://example.com">text</ext-link>"# + ); + } + + #[test] + fn test_markdown_with_structure() { + let input = "- Item 1\n- Item 2\n\nParagraph text"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Markdown, + ConversionLimit::Abstract, + ) + .unwrap(); + + assert!( + output.contains( + "<list><list-item>Item 1</list-item><list-item>Item 2</list-item></list>" + ) && output.contains("<p>Paragraph text</p>") + ); + } + + #[test] + fn test_plain_text_with_url() { + let input = "Hello https://example.com world"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::PlainText, + ConversionLimit::Biography, + ) + .unwrap(); + assert_eq!( + output, + "<p>Hello </p><ext-link xlink:href=\"https://example.com\"><p>https://example.com</p></ext-link><p> world</p>" + ); + } + + #[test] + fn test_plain_text_no_url() { + let input = "Just plain text."; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::PlainText, + ConversionLimit::Title, + ) + .unwrap(); + assert_eq!(output, "Just plain text."); + } + // --- convert_to_jats tests end --- + + // --- convert_from_jats tests start --- + #[test] + fn test_convert_from_jats_html_with_structure() { + let input = r#" + <p>Paragraph text</p> + <list><list-item>Item 1</list-item><list-item>Item 2</list-item></list> + <italic>Italic</italic> and <bold>Bold</bold> + <ext-link xlink:href="https://example.com">Link</ext-link> + "#; + let output = + convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Abstract).unwrap(); + + assert!(output.contains("<p>Paragraph text</p>")); + assert!(output.contains("<ul><li>Item 1</li><li>Item 2</li></ul>")); + assert!(output.contains("<em>Italic</em>")); + assert!(output.contains("<strong>Bold</strong>")); + assert!(output.contains(r#"<a href="https://example.com">Link</a>"#)); + } + + #[test] + fn test_convert_from_jats_html_no_structure() { + let input = r#" + <p>Text</p><list><list-item>Item</list-item></list><bold>Bold</bold> + "#; + let output = convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Title).unwrap(); + + assert!(!output.contains("<p>")); + assert!(!output.contains("<ul>")); + assert!(output.contains("<strong>Bold</strong>")); + } + + #[test] + fn test_convert_from_jats_html_title_limit() { + let input = r#"<p>Title</p><bold>Bold</bold>"#; + let output = convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Title).unwrap(); + + assert!(!output.contains("<p>")); + assert!(output.contains("<strong>Bold</strong>")); + } + + #[test] + fn test_convert_from_jats_markdown_with_structure() { + let input = r#" + <p>Text</p><list><list-item>Item 1</list-item><list-item>Item 2</list-item></list> + <italic>It</italic> and <bold>Bold</bold> + <ext-link xlink:href="https://link.com">Here</ext-link> + "#; + let output = + convert_from_jats(input, MarkupFormat::Markdown, ConversionLimit::Biography).unwrap(); + + assert!(output.contains("Text")); + assert!(output.contains("- Item 1")); + assert!(output.contains("*It*")); + assert!(output.contains("**Bold**")); + assert!(output.contains("[Here](https://link.com)")); + } + + #[test] + fn test_convert_from_jats_markdown_title_limit() { + let input = r#"<p>Title</p><italic>It</italic>"#; + let output = + convert_from_jats(input, MarkupFormat::Markdown, ConversionLimit::Title).unwrap(); + + assert!(!output.contains("<p>")); + assert!(output.contains("*It*")); + } + + #[test] + fn test_convert_from_jats_plain_text_basic() { + let input = r#" + <p>Text</p> and <ext-link xlink:href="https://ex.com">Link</ext-link> and <sc>SC</sc> + "#; + let output = + convert_from_jats(input, MarkupFormat::PlainText, ConversionLimit::Abstract).unwrap(); + + assert!(output.contains("Text")); + assert!(output.contains("Link (https://ex.com)")); + assert!(!output.contains("<sc>")); + assert!(!output.contains("<")); + } + + #[test] + fn test_convert_from_jats_preserves_inline_html() { + let input = r#"<italic>i</italic> <bold>b</bold> <monospace>code</monospace>"#; + let output = + convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Abstract).unwrap(); + + assert!(output.contains("<em>i</em>")); + assert!(output.contains("<strong>b</strong>")); + assert!(output.contains("<code>code</code>")); + } + + #[test] + fn test_convert_from_jats_jatsxml_noop() { + let input = r#"<p>Do nothing</p>"#; + let output = + convert_from_jats(input, MarkupFormat::JatsXml, ConversionLimit::Biography).unwrap(); + assert_eq!(input, output); + } + + #[test] + fn test_convert_from_jats_html_allow_structure_false() { + let input = r#"<p>Para</p><list><list-item>Item</list-item></list>"#; + let output = convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Title).unwrap(); + + assert!(!output.contains("<p>")); + assert!(!output.contains("<ul>")); + assert!(output.contains("Para")); + assert!(output.contains("Item")); + } + + #[test] + fn test_title_plain_text_to_jats_has_no_paragraph() { + let input = "Plain title"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::PlainText, + ConversionLimit::Title, + ) + .unwrap(); + assert_eq!(output, "Plain title"); + } + + #[test] + fn test_title_plain_text_roundtrip_no_paragraphs() { + let plain = "Another plain title"; + let jats = convert_to_jats( + plain.to_string(), + MarkupFormat::PlainText, + ConversionLimit::Title, + ) + .unwrap(); + assert!(!jats.contains("<p>")); + + let back = convert_from_jats(&jats, MarkupFormat::JatsXml, ConversionLimit::Title).unwrap(); + assert_eq!(back, plain); + } + // --- convert_from_jats tests end +} diff --git a/thoth-api/src/model/abstract/crud.rs b/thoth-api/src/model/abstract/crud.rs new file mode 100644 index 00000000..20caa534 --- /dev/null +++ b/thoth-api/src/model/abstract/crud.rs @@ -0,0 +1,166 @@ +use super::LocaleCode; +use super::{ + Abstract, AbstractField, AbstractHistory, AbstractOrderBy, AbstractType, NewAbstract, + NewAbstractHistory, PatchAbstract, +}; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; +use crate::schema::work_abstract::dsl; +use crate::schema::{abstract_history, work_abstract}; +use diesel::{ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl}; +use thoth_errors::ThothResult; +use uuid::Uuid; + +impl Abstract { + fn canonical_from_work_id_and_type( + db: &crate::db::PgPool, + work_id: &Uuid, + abstract_type: AbstractType, + ) -> ThothResult<Self> { + let mut connection = db.get()?; + work_abstract::table + .filter(work_abstract::work_id.eq(work_id)) + .filter(work_abstract::canonical.eq(true)) + .filter(work_abstract::abstract_type.eq(abstract_type)) + .first::<Abstract>(&mut connection) + .map_err(Into::into) + } + + pub(crate) fn short_canonical_from_work_id( + db: &crate::db::PgPool, + work_id: &Uuid, + ) -> ThothResult<Self> { + Self::canonical_from_work_id_and_type(db, work_id, AbstractType::Short) + } + + pub(crate) fn long_canonical_from_work_id( + db: &crate::db::PgPool, + work_id: &Uuid, + ) -> ThothResult<Self> { + Self::canonical_from_work_id_and_type(db, work_id, AbstractType::Long) + } +} + +impl Crud for Abstract { + type NewEntity = NewAbstract; + type PatchEntity = PatchAbstract; + type OrderByEntity = AbstractOrderBy; + type FilterParameter1 = LocaleCode; + type FilterParameter2 = (); + type FilterParameter3 = AbstractType; + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.abstract_id + } + + fn all( + db: &crate::db::PgPool, + limit: i32, + offset: i32, + filter: Option<String>, + order: Self::OrderByEntity, + _: Vec<Uuid>, + parent_id_1: Option<Uuid>, + _: Option<Uuid>, + locale_codes: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + abstract_type: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<Vec<Abstract>> { + let mut connection = db.get()?; + let mut query = dsl::work_abstract + .select(crate::schema::work_abstract::all_columns) + .into_boxed(); + + query = match order.field { + AbstractField::AbstractId => { + apply_directional_order!(query, order.direction, order, dsl::abstract_id) + } + AbstractField::WorkId => { + apply_directional_order!(query, order.direction, order, dsl::work_id) + } + AbstractField::LocaleCode => { + apply_directional_order!(query, order.direction, order, dsl::locale_code) + } + AbstractField::AbstractType => { + apply_directional_order!(query, order.direction, order, dsl::abstract_type) + } + AbstractField::Content => { + apply_directional_order!(query, order.direction, order, dsl::content) + } + AbstractField::Canonical => { + apply_directional_order!(query, order.direction, order, dsl::canonical) + } + }; + + if let Some(filter) = filter { + query = query.filter(dsl::content.ilike(format!("%{filter}%"))); + } + + if let Some(pid) = parent_id_1 { + query = query.filter(dsl::work_id.eq(pid)); + } + + if !locale_codes.is_empty() { + query = query.filter(dsl::locale_code.eq_any(locale_codes)); + } + + if let Some(at) = abstract_type { + query = query.filter(dsl::abstract_type.eq(at)); + } + + query + .limit(limit.into()) + .offset(offset.into()) + .load::<Abstract>(&mut connection) + .map_err(Into::into) + } + + fn count( + db: &crate::db::PgPool, + filter: Option<String>, + _: Vec<Uuid>, + _: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<i32> { + let mut connection = db.get()?; + let mut query = dsl::work_abstract.into_boxed(); + + if let Some(filter) = filter { + query = query.filter(dsl::content.ilike(format!("%{filter}%"))); + } + + query + .count() + .get_result::<i64>(&mut connection) + .map(|t| t.to_string().parse::<i32>().unwrap()) + .map_err(Into::into) + } + + crud_methods!(work_abstract::table, work_abstract::dsl::work_abstract); +} + +publisher_id_impls!(Abstract, NewAbstract, PatchAbstract, |s, db| { + let work = crate::model::work::Work::from_id(db, &s.work_id)?; + <crate::model::work::Work as PublisherId>::publisher_id(&work, db) +}); + +impl HistoryEntry for Abstract { + type NewHistoryEntity = NewAbstractHistory; + + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { + Self::NewHistoryEntity { + abstract_id: self.abstract_id, + user_id: user_id.to_string(), + data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), + } + } +} + +impl DbInsert for NewAbstractHistory { + type MainEntity = AbstractHistory; + + db_insert!(abstract_history::table); +} diff --git a/thoth-api/src/model/abstract/mod.rs b/thoth-api/src/model/abstract/mod.rs new file mode 100644 index 00000000..318d92e5 --- /dev/null +++ b/thoth-api/src/model/abstract/mod.rs @@ -0,0 +1,146 @@ +use crate::model::locale::LocaleCode; +use serde::{Deserialize, Serialize}; +use strum::Display; +use strum::EnumString; +use uuid::Uuid; + +use crate::graphql::types::inputs::Direction; + +#[cfg(feature = "backend")] +use crate::schema::abstract_history; +#[cfg(feature = "backend")] +use crate::schema::work_abstract; + +#[cfg_attr( + feature = "backend", + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), + graphql(description = "BCP-47 code representing locale"), + ExistingTypePath = "crate::schema::sql_types::AbstractType" +)] +#[derive( + Debug, Copy, Clone, Default, PartialEq, Eq, Deserialize, Serialize, EnumString, Display, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "UPPERCASE")] +pub enum AbstractType { + #[default] + #[cfg_attr(feature = "backend", graphql(description = "Short"))] + Short, + #[cfg_attr(feature = "backend", graphql(description = "Long"))] + Long, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLEnum), + graphql(description = "Field to use when sorting abstract list") +)] +pub enum AbstractField { + AbstractId, + WorkId, + Content, + LocaleCode, + AbstractType, + Canonical, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject), + graphql(description = "Field and order to use when sorting titles list") +)] +pub struct AbstractOrderBy { + pub field: AbstractField, + pub direction: Direction, +} + +impl Default for AbstractOrderBy { + fn default() -> Self { + Self { + field: AbstractField::Canonical, + direction: Direction::Desc, + } + } +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Abstract { + pub abstract_id: Uuid, + pub work_id: Uuid, + pub content: String, + pub locale_code: LocaleCode, + pub abstract_type: AbstractType, + pub canonical: bool, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::Insertable, Clone), + graphql(description = "Set of values required to define a new work's abstract"), + diesel(table_name = work_abstract) +)] +pub struct NewAbstract { + pub work_id: Uuid, + pub content: String, + pub locale_code: LocaleCode, + pub abstract_type: AbstractType, + pub canonical: bool, +} + +impl Default for NewAbstract { + fn default() -> Self { + Self { + work_id: Default::default(), + content: String::new(), + locale_code: Default::default(), + abstract_type: AbstractType::Short, + canonical: false, + } + } +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::AsChangeset, Clone), + graphql(description = "Set of values required to update an existing work's abstract"), + diesel(table_name = work_abstract) +)] +pub struct PatchAbstract { + pub abstract_id: Uuid, + pub work_id: Uuid, + pub content: String, + pub locale_code: LocaleCode, + pub abstract_type: AbstractType, + pub canonical: bool, +} + +#[cfg_attr( + feature = "backend", + derive(diesel::Insertable), + diesel(table_name = abstract_history) +)] +pub struct NewAbstractHistory { + pub abstract_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +pub struct AbstractHistory { + pub abstract_history_id: Uuid, + pub abstract_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, + pub timestamp: chrono::DateTime<chrono::Utc>, +} + +#[cfg(feature = "backend")] +pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::AbstractPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/abstract/policy.rs b/thoth-api/src/model/abstract/policy.rs new file mode 100644 index 00000000..009cf78c --- /dev/null +++ b/thoth-api/src/model/abstract/policy.rs @@ -0,0 +1,85 @@ +use diesel::dsl::{exists, select}; +use diesel::prelude::*; +use uuid::Uuid; + +use super::{Abstract, AbstractType, NewAbstract, PatchAbstract}; +use crate::markup::MarkupFormat; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use crate::schema::work_abstract; +use thoth_errors::{ThothError, ThothResult}; + +pub const MAX_SHORT_ABSTRACT_CHAR_LIMIT: u16 = 350; + +/// Write policies for `Abstract`. +/// +/// `Abstract` spans two works and therefore potentially two publisher scopes. +/// This policy enforces: +/// - authentication +/// - membership for *all* publishers involved (via `PublisherIds`) +pub struct AbstractPolicy; + +fn has_canonical_abstract(db: &crate::db::PgPool, work_id: &Uuid) -> ThothResult<bool> { + let mut connection = db.get()?; + let query = work_abstract::table + .filter(work_abstract::work_id.eq(work_id)) + .filter(work_abstract::canonical.eq(true)); + + let result: bool = select(exists(query)).get_result(&mut connection)?; + Ok(result) +} + +impl CreatePolicy<NewAbstract, Option<MarkupFormat>> for AbstractPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewAbstract, + markup: Option<MarkupFormat>, + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + + // Abstract creation requires a markup format. + markup.ok_or(ThothError::MissingMarkupFormat)?; + + // Canonical abstracts: only one canonical abstract is allowed per work. + if data.canonical && has_canonical_abstract(ctx.db(), &data.work_id)? { + return Err(ThothError::CanonicalAbstractExistsError); + } + + if data.abstract_type == AbstractType::Short + && data.content.len() > MAX_SHORT_ABSTRACT_CHAR_LIMIT as usize + { + return Err(ThothError::ShortAbstractLimitExceedError); + }; + + Ok(()) + } +} + +impl UpdatePolicy<Abstract, PatchAbstract, Option<MarkupFormat>> for AbstractPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Abstract, + patch: &PatchAbstract, + markup: Option<MarkupFormat>, + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + // Abstract creation requires a markup format. + markup.ok_or(ThothError::MissingMarkupFormat)?; + + if patch.abstract_type == AbstractType::Short + && patch.content.len() > MAX_SHORT_ABSTRACT_CHAR_LIMIT as usize + { + return Err(ThothError::ShortAbstractLimitExceedError); + }; + + Ok(()) + } +} + +impl DeletePolicy<Abstract> for AbstractPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Abstract) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/abstract/tests.rs b/thoth-api/src/model/abstract/tests.rs new file mode 100644 index 00000000..ebcdab18 --- /dev/null +++ b/thoth-api/src/model/abstract/tests.rs @@ -0,0 +1,881 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_abstract( + pool: &crate::db::PgPool, + work_id: Uuid, + content: String, + abstract_type: AbstractType, + locale_code: LocaleCode, +) -> Abstract { + let new_abstract = NewAbstract { + work_id, + content, + locale_code, + abstract_type, + canonical: false, + }; + + Abstract::create(pool, &new_abstract).expect("Failed to create abstract") +} + +mod defaults { + use super::*; + + #[test] + fn abstracttype_default_is_short() { + let abstract_type: AbstractType = Default::default(); + assert_eq!(abstract_type, AbstractType::Short); + } + + #[test] + fn abstractorderby_default_is_canonical_desc() { + let order: AbstractOrderBy = Default::default(); + assert!(matches!(order.field, AbstractField::Canonical)); + assert!(matches!(order.direction, Direction::Desc)); + } + + #[test] + fn newabstract_default_values() { + let new_abstract = NewAbstract::default(); + assert_eq!(new_abstract.work_id, Uuid::default()); + assert_eq!(new_abstract.content, ""); + assert_eq!(new_abstract.locale_code, LocaleCode::default()); + assert_eq!(new_abstract.abstract_type, AbstractType::Short); + assert!(!new_abstract.canonical); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn abstracttype_display_formats_expected_strings() { + assert_eq!(format!("{}", AbstractType::Short), "SHORT"); + assert_eq!(format!("{}", AbstractType::Long), "LONG"); + } + + #[test] + fn abstracttype_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + AbstractType::from_str("SHORT").unwrap(), + AbstractType::Short + ); + assert_eq!(AbstractType::from_str("LONG").unwrap(), AbstractType::Long); + assert!(AbstractType::from_str("BRIEF").is_err()); + } +} + +#[cfg(feature = "backend")] +mod conversions { + use super::*; + use crate::model::tests::db::setup_test_db; + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn abstracttype_graphql_roundtrip() { + assert_graphql_enum_roundtrip(AbstractType::Short); + } + + #[test] + fn abstracttype_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<AbstractType, crate::schema::sql_types::AbstractType>( + pool.as_ref(), + "'short'::abstract_type", + AbstractType::Short, + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::markup::MarkupFormat; + use crate::model::r#abstract::policy::{AbstractPolicy, MAX_SHORT_ABSTRACT_CHAR_LIMIT}; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + use thoth_errors::ThothError; + + #[test] + fn crud_policy_allows_publisher_user_with_markup() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("abstract-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let new_abstract = NewAbstract { + work_id: work.work_id, + content: "Policy Abstract".to_string(), + locale_code: LocaleCode::En, + abstract_type: AbstractType::Long, + canonical: false, + }; + + let abstract_item = + Abstract::create(pool.as_ref(), &new_abstract).expect("Failed to create"); + let patch = PatchAbstract { + abstract_id: abstract_item.abstract_id, + work_id: abstract_item.work_id, + content: "Updated Policy Abstract".to_string(), + locale_code: abstract_item.locale_code, + abstract_type: abstract_item.abstract_type, + canonical: abstract_item.canonical, + }; + + assert!(AbstractPolicy::can_create(&ctx, &new_abstract, Some(MarkupFormat::Html)).is_ok()); + assert!( + AbstractPolicy::can_update(&ctx, &abstract_item, &patch, Some(MarkupFormat::Html)) + .is_ok() + ); + assert!(AbstractPolicy::can_delete(&ctx, &abstract_item).is_ok()); + } + + #[test] + fn crud_policy_requires_markup_format() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("abstract-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let new_abstract = NewAbstract { + work_id: work.work_id, + content: "Policy Abstract".to_string(), + locale_code: LocaleCode::En, + abstract_type: AbstractType::Long, + canonical: false, + }; + + let abstract_item = + Abstract::create(pool.as_ref(), &new_abstract).expect("Failed to create"); + let patch = PatchAbstract { + abstract_id: abstract_item.abstract_id, + work_id: abstract_item.work_id, + content: "Updated Policy Abstract".to_string(), + locale_code: abstract_item.locale_code, + abstract_type: abstract_item.abstract_type, + canonical: abstract_item.canonical, + }; + + assert!(AbstractPolicy::can_create(&ctx, &new_abstract, None).is_err()); + assert!(AbstractPolicy::can_update(&ctx, &abstract_item, &patch, None).is_err()); + } + + #[test] + fn crud_policy_rejects_duplicate_canonical_abstract() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("abstract-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let canonical_abstract = NewAbstract { + work_id: work.work_id, + content: "Canonical Abstract".to_string(), + locale_code: LocaleCode::En, + abstract_type: AbstractType::Long, + canonical: true, + }; + Abstract::create(pool.as_ref(), &canonical_abstract).expect("Failed to create abstract"); + + let new_abstract = NewAbstract { + work_id: work.work_id, + content: "Second Canonical Abstract".to_string(), + locale_code: LocaleCode::En, + abstract_type: AbstractType::Long, + canonical: true, + }; + + let result = AbstractPolicy::can_create(&ctx, &new_abstract, Some(MarkupFormat::Html)); + assert!(matches!( + result, + Err(ThothError::CanonicalAbstractExistsError) + )); + } + + #[test] + fn crud_policy_rejects_short_abstract_over_limit() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("abstract-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let content = "a".repeat(MAX_SHORT_ABSTRACT_CHAR_LIMIT as usize + 1); + let new_abstract = NewAbstract { + work_id: work.work_id, + content, + locale_code: LocaleCode::En, + abstract_type: AbstractType::Short, + canonical: false, + }; + + let result = AbstractPolicy::can_create(&ctx, &new_abstract, Some(MarkupFormat::Html)); + assert!(matches!( + result, + Err(ThothError::ShortAbstractLimitExceedError) + )); + } + + #[test] + fn crud_policy_rejects_short_abstract_update_over_limit() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("abstract-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let abstract_item = Abstract::create( + pool.as_ref(), + &NewAbstract { + work_id: work.work_id, + content: "Short Abstract".to_string(), + locale_code: LocaleCode::En, + abstract_type: AbstractType::Short, + canonical: false, + }, + ) + .expect("Failed to create abstract"); + + let patch = PatchAbstract { + abstract_id: abstract_item.abstract_id, + work_id: abstract_item.work_id, + content: "a".repeat(MAX_SHORT_ABSTRACT_CHAR_LIMIT as usize + 1), + locale_code: abstract_item.locale_code, + abstract_type: AbstractType::Short, + canonical: abstract_item.canonical, + }; + + let result = + AbstractPolicy::can_update(&ctx, &abstract_item, &patch, Some(MarkupFormat::Html)); + assert!(matches!( + result, + Err(ThothError::ShortAbstractLimitExceedError) + )); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let abstract_item = make_abstract( + pool.as_ref(), + work.work_id, + "Policy Abstract".to_string(), + AbstractType::Long, + LocaleCode::En, + ); + let patch = PatchAbstract { + abstract_id: abstract_item.abstract_id, + work_id: abstract_item.work_id, + content: "Updated Policy Abstract".to_string(), + locale_code: abstract_item.locale_code, + abstract_type: abstract_item.abstract_type, + canonical: abstract_item.canonical, + }; + + let user = test_user_with_role("abstract-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_abstract = NewAbstract { + work_id: work.work_id, + content: "Policy Abstract".to_string(), + locale_code: LocaleCode::En, + abstract_type: AbstractType::Long, + canonical: false, + }; + + assert!(AbstractPolicy::can_create(&ctx, &new_abstract, Some(MarkupFormat::Html)).is_err()); + assert!( + AbstractPolicy::can_update(&ctx, &abstract_item, &patch, Some(MarkupFormat::Html)) + .is_err() + ); + assert!(AbstractPolicy::can_delete(&ctx, &abstract_item).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context, + }; + use crate::model::Crud; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let new_abstract = NewAbstract { + work_id: work.work_id, + content: format!("Abstract {}", Uuid::new_v4()), + locale_code: LocaleCode::En, + abstract_type: AbstractType::Short, + canonical: false, + }; + + let abstract_ = + Abstract::create(pool.as_ref(), &new_abstract).expect("Failed to create abstract"); + let fetched = + Abstract::from_id(pool.as_ref(), &abstract_.abstract_id).expect("Failed to fetch"); + assert_eq!(abstract_.abstract_id, fetched.abstract_id); + + let patch = PatchAbstract { + abstract_id: abstract_.abstract_id, + work_id: abstract_.work_id, + content: format!("Updated {}", Uuid::new_v4()), + locale_code: abstract_.locale_code, + abstract_type: AbstractType::Long, + canonical: true, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = abstract_ + .update(&ctx, &patch) + .expect("Failed to update abstract"); + assert_eq!(updated.content, patch.content); + + let deleted = updated + .delete(pool.as_ref()) + .expect("Failed to delete abstract"); + assert!(Abstract::from_id(pool.as_ref(), &deleted.abstract_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {}", Uuid::new_v4()), + AbstractType::Short, + LocaleCode::En, + ); + make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {}", Uuid::new_v4()), + AbstractType::Long, + LocaleCode::En, + ); + + let order = AbstractOrderBy { + field: AbstractField::AbstractId, + direction: Direction::Asc, + }; + + let first = Abstract::all( + pool.as_ref(), + 1, + 0, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch abstracts"); + let second = Abstract::all( + pool.as_ref(), + 1, + 1, + None, + AbstractOrderBy { + field: AbstractField::AbstractId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch abstracts"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].abstract_id, second[0].abstract_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {}", Uuid::new_v4()), + AbstractType::Short, + LocaleCode::En, + ); + make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {}", Uuid::new_v4()), + AbstractType::Long, + LocaleCode::En, + ); + + let count = Abstract::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count abstracts"); + assert_eq!(count, 2); + } + + #[test] + fn crud_filter_matches_content() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let marker = format!("Filter {}", Uuid::new_v4()); + let matches = make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {marker}"), + AbstractType::Short, + LocaleCode::En, + ); + make_abstract( + pool.as_ref(), + work.work_id, + "Other abstract".to_string(), + AbstractType::Long, + LocaleCode::En, + ); + + let filtered = Abstract::all( + pool.as_ref(), + 10, + 0, + Some(marker), + AbstractOrderBy { + field: AbstractField::AbstractId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter abstracts"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].abstract_id, matches.abstract_id); + } + + #[test] + fn crud_filter_param_limits_abstract_type() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let matches = make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {}", Uuid::new_v4()), + AbstractType::Short, + LocaleCode::En, + ); + make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {}", Uuid::new_v4()), + AbstractType::Long, + LocaleCode::En, + ); + + let filtered = Abstract::all( + pool.as_ref(), + 10, + 0, + None, + AbstractOrderBy { + field: AbstractField::AbstractId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + Some(AbstractType::Short), + None, + ) + .expect("Failed to filter abstracts by type"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].abstract_id, matches.abstract_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let first = make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {}", Uuid::new_v4()), + AbstractType::Short, + LocaleCode::En, + ); + let second = make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {}", Uuid::new_v4()), + AbstractType::Long, + LocaleCode::En, + ); + let mut ids = [first.abstract_id, second.abstract_id]; + ids.sort(); + + let asc = Abstract::all( + pool.as_ref(), + 2, + 0, + None, + AbstractOrderBy { + field: AbstractField::AbstractId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order abstracts (asc)"); + + let desc = Abstract::all( + pool.as_ref(), + 2, + 0, + None, + AbstractOrderBy { + field: AbstractField::AbstractId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order abstracts (desc)"); + + assert_eq!(asc[0].abstract_id, ids[0]); + assert_eq!(desc[0].abstract_id, ids[1]); + } + + #[test] + fn crud_canonical_from_work_id_returns_short_and_long() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let short = Abstract::create( + pool.as_ref(), + &NewAbstract { + work_id: work.work_id, + content: "Short canonical".to_string(), + locale_code: LocaleCode::En, + abstract_type: AbstractType::Short, + canonical: true, + }, + ) + .expect("Failed to create short canonical abstract"); + let long = Abstract::create( + pool.as_ref(), + &NewAbstract { + work_id: work.work_id, + content: "Long canonical".to_string(), + locale_code: LocaleCode::En, + abstract_type: AbstractType::Long, + canonical: true, + }, + ) + .expect("Failed to create long canonical abstract"); + + let fetched_short = Abstract::short_canonical_from_work_id(pool.as_ref(), &work.work_id) + .expect("Failed to fetch short canonical abstract"); + let fetched_long = Abstract::long_canonical_from_work_id(pool.as_ref(), &work.work_id) + .expect("Failed to fetch long canonical abstract"); + + assert_eq!(fetched_short.abstract_id, short.abstract_id); + assert_eq!(fetched_long.abstract_id, long.abstract_id); + } + + #[test] + fn crud_filter_parent_work_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + + let matches = make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {}", Uuid::new_v4()), + AbstractType::Short, + LocaleCode::En, + ); + make_abstract( + pool.as_ref(), + other_work.work_id, + format!("Abstract {}", Uuid::new_v4()), + AbstractType::Long, + LocaleCode::En, + ); + + let filtered = Abstract::all( + pool.as_ref(), + 10, + 0, + None, + AbstractOrderBy { + field: AbstractField::AbstractId, + direction: Direction::Asc, + }, + vec![], + Some(work.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter abstracts by work"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].abstract_id, matches.abstract_id); + } + + #[test] + fn crud_filter_param_limits_locale_codes() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let matches = make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {}", Uuid::new_v4()), + AbstractType::Short, + LocaleCode::En, + ); + make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {}", Uuid::new_v4()), + AbstractType::Long, + LocaleCode::Fr, + ); + + let filtered = Abstract::all( + pool.as_ref(), + 10, + 0, + None, + AbstractOrderBy { + field: AbstractField::AbstractId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![LocaleCode::En], + vec![], + None, + None, + ) + .expect("Failed to filter abstracts by locale"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].abstract_id, matches.abstract_id); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + Abstract::create( + pool.as_ref(), + &NewAbstract { + work_id: work.work_id, + content: "Abstract A".to_string(), + locale_code: LocaleCode::En, + abstract_type: AbstractType::Short, + canonical: true, + }, + ) + .expect("Failed to create abstract"); + Abstract::create( + pool.as_ref(), + &NewAbstract { + work_id: work.work_id, + content: "Abstract B".to_string(), + locale_code: LocaleCode::Fr, + abstract_type: AbstractType::Long, + canonical: true, + }, + ) + .expect("Failed to create abstract"); + + let fields: Vec<fn() -> AbstractField> = vec![ + || AbstractField::AbstractId, + || AbstractField::WorkId, + || AbstractField::LocaleCode, + || AbstractField::AbstractType, + || AbstractField::Content, + || AbstractField::Canonical, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Abstract::all( + pool.as_ref(), + 10, + 0, + None, + AbstractOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order abstracts"); + + assert_eq!(results.len(), 2); + } + } + } + + #[test] + fn crud_count_with_filter_matches_content() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let marker = format!("Filter {}", Uuid::new_v4()); + + make_abstract( + pool.as_ref(), + work.work_id, + format!("Abstract {marker}"), + AbstractType::Short, + LocaleCode::En, + ); + make_abstract( + pool.as_ref(), + work.work_id, + "Other abstract".to_string(), + AbstractType::Long, + LocaleCode::En, + ); + + let count = Abstract::count( + pool.as_ref(), + Some(marker), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count filtered abstracts"); + + assert_eq!(count, 1); + } +} diff --git a/thoth-api/src/model/additional_resource/crud.rs b/thoth-api/src/model/additional_resource/crud.rs new file mode 100644 index 00000000..c68a9855 --- /dev/null +++ b/thoth-api/src/model/additional_resource/crud.rs @@ -0,0 +1,209 @@ +use super::{ + AdditionalResource, AdditionalResourceField, AdditionalResourceHistory, + AdditionalResourceOrderBy, NewAdditionalResource, NewAdditionalResourceHistory, + PatchAdditionalResource, +}; +use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; +use crate::schema::{additional_resource, additional_resource_history}; +use diesel::{ + BoolExpressionMethods, Connection, ExpressionMethods, PgTextExpressionMethods, QueryDsl, + RunQueryDsl, +}; +use thoth_errors::ThothResult; +use uuid::Uuid; + +impl Crud for AdditionalResource { + type NewEntity = NewAdditionalResource; + type PatchEntity = PatchAdditionalResource; + type OrderByEntity = AdditionalResourceOrderBy; + type FilterParameter1 = (); + type FilterParameter2 = (); + type FilterParameter3 = (); + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.additional_resource_id + } + + fn all( + db: &crate::db::PgPool, + limit: i32, + offset: i32, + filter: Option<String>, + order: Self::OrderByEntity, + publishers: Vec<Uuid>, + parent_id_1: Option<Uuid>, + _: Option<Uuid>, + _: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<Vec<AdditionalResource>> { + use crate::schema::additional_resource::dsl::*; + let mut connection = db.get()?; + let mut query = additional_resource + .inner_join(crate::schema::work::table.inner_join(crate::schema::imprint::table)) + .select(crate::schema::additional_resource::all_columns) + .into_boxed(); + + query = match order.field { + AdditionalResourceField::AdditionalResourceId => { + apply_directional_order!(query, order.direction, order, additional_resource_id) + } + AdditionalResourceField::WorkId => { + apply_directional_order!(query, order.direction, order, work_id) + } + AdditionalResourceField::ResourceOrdinal => { + apply_directional_order!(query, order.direction, order, resource_ordinal) + } + AdditionalResourceField::Title => { + apply_directional_order!(query, order.direction, order, title) + } + AdditionalResourceField::Attribution => { + apply_directional_order!(query, order.direction, order, attribution) + } + AdditionalResourceField::ResourceType => { + apply_directional_order!(query, order.direction, order, resource_type) + } + AdditionalResourceField::Doi => { + apply_directional_order!(query, order.direction, order, doi) + } + AdditionalResourceField::Handle => { + apply_directional_order!(query, order.direction, order, handle) + } + AdditionalResourceField::Url => { + apply_directional_order!(query, order.direction, order, url) + } + AdditionalResourceField::Date => { + apply_directional_order!(query, order.direction, order, date) + } + AdditionalResourceField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + AdditionalResourceField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } + }; + + if !publishers.is_empty() { + query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); + } + if let Some(pid) = parent_id_1 { + query = query.filter(work_id.eq(pid)); + } + if let Some(filter) = filter { + if !filter.is_empty() { + query = query.filter( + title + .ilike(format!("%{filter}%")) + .or(description.ilike(format!("%{filter}%"))) + .or(attribution.ilike(format!("%{filter}%"))) + .or(doi.ilike(format!("%{filter}%"))) + .or(handle.ilike(format!("%{filter}%"))) + .or(url.ilike(format!("%{filter}%"))), + ); + } + } + + query + .limit(limit.into()) + .offset(offset.into()) + .load::<AdditionalResource>(&mut connection) + .map_err(Into::into) + } + + fn count( + db: &crate::db::PgPool, + filter: Option<String>, + publishers: Vec<Uuid>, + _: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<i32> { + use crate::schema::additional_resource::dsl::*; + let mut connection = db.get()?; + let mut query = additional_resource + .inner_join(crate::schema::work::table.inner_join(crate::schema::imprint::table)) + .into_boxed(); + + if !publishers.is_empty() { + query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); + } + if let Some(filter) = filter { + if !filter.is_empty() { + query = query.filter( + title + .ilike(format!("%{filter}%")) + .or(description.ilike(format!("%{filter}%"))) + .or(attribution.ilike(format!("%{filter}%"))) + .or(doi.ilike(format!("%{filter}%"))) + .or(handle.ilike(format!("%{filter}%"))) + .or(url.ilike(format!("%{filter}%"))), + ); + } + } + + query + .count() + .get_result::<i64>(&mut connection) + .map(|t| t.to_string().parse::<i32>().unwrap()) + .map_err(Into::into) + } + + crud_methods!( + additional_resource::table, + additional_resource::dsl::additional_resource + ); +} + +publisher_id_impls!( + AdditionalResource, + NewAdditionalResource, + PatchAdditionalResource, + |s, db| { crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) } +); + +impl HistoryEntry for AdditionalResource { + type NewHistoryEntity = NewAdditionalResourceHistory; + + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { + Self::NewHistoryEntity { + additional_resource_id: self.additional_resource_id, + user_id: user_id.to_string(), + data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), + } + } +} + +impl DbInsert for NewAdditionalResourceHistory { + type MainEntity = AdditionalResourceHistory; + + db_insert!(additional_resource_history::table); +} + +impl Reorder for AdditionalResource { + db_change_ordinal!( + additional_resource::table, + additional_resource::resource_ordinal, + "additional_resource_resource_ordinal_work_id_uniq" + ); + + fn get_other_objects( + &self, + connection: &mut diesel::PgConnection, + ) -> ThothResult<Vec<(Uuid, i32)>> { + additional_resource::table + .select(( + additional_resource::additional_resource_id, + additional_resource::resource_ordinal, + )) + .filter( + additional_resource::work_id.eq(self.work_id).and( + additional_resource::additional_resource_id.ne(self.additional_resource_id), + ), + ) + .load::<(Uuid, i32)>(connection) + .map_err(Into::into) + } +} diff --git a/thoth-api/src/model/additional_resource/mod.rs b/thoth-api/src/model/additional_resource/mod.rs new file mode 100644 index 00000000..f4a4db74 --- /dev/null +++ b/thoth-api/src/model/additional_resource/mod.rs @@ -0,0 +1,171 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; +use uuid::Uuid; + +use crate::graphql::types::inputs::Direction; +use crate::model::{Doi, Timestamp}; +#[cfg(feature = "backend")] +use crate::schema::{additional_resource, additional_resource_history}; + +#[cfg_attr( + feature = "backend", + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), + graphql(description = "Type of additional resource"), + ExistingTypePath = "crate::schema::sql_types::ResourceType" +)] +#[derive( + Debug, Copy, Clone, Default, PartialEq, Eq, Deserialize, Serialize, EnumString, Display, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "SCREAMING_SNAKE_CASE")] +pub enum ResourceType { + #[cfg_attr(feature = "backend", db_rename = "AUDIO")] + Audio, + #[cfg_attr(feature = "backend", db_rename = "VIDEO")] + Video, + #[cfg_attr(feature = "backend", db_rename = "IMAGE")] + Image, + #[cfg_attr(feature = "backend", db_rename = "BLOG")] + Blog, + #[cfg_attr(feature = "backend", db_rename = "WEBSITE")] + Website, + #[cfg_attr(feature = "backend", db_rename = "DOCUMENT")] + Document, + #[cfg_attr(feature = "backend", db_rename = "BOOK")] + Book, + #[cfg_attr(feature = "backend", db_rename = "ARTICLE")] + Article, + #[cfg_attr(feature = "backend", db_rename = "MAP")] + Map, + #[cfg_attr(feature = "backend", db_rename = "SOURCE")] + Source, + #[cfg_attr(feature = "backend", db_rename = "DATASET")] + Dataset, + #[cfg_attr(feature = "backend", db_rename = "SPREADSHEET")] + Spreadsheet, + #[default] + #[cfg_attr(feature = "backend", db_rename = "OTHER")] + Other, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLEnum), + graphql(description = "Field to use when sorting additional resources list") +)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AdditionalResourceField { + AdditionalResourceId, + WorkId, + #[default] + ResourceOrdinal, + Title, + Attribution, + ResourceType, + Doi, + Handle, + Url, + Date, + CreatedAt, + UpdatedAt, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdditionalResource { + pub additional_resource_id: Uuid, + pub work_id: Uuid, + pub title: String, + pub description: Option<String>, + pub attribution: Option<String>, + pub resource_type: ResourceType, + pub doi: Option<Doi>, + pub handle: Option<String>, + pub url: Option<String>, + pub date: Option<NaiveDate>, + pub resource_ordinal: i32, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::Insertable), + graphql(description = "Set of values required to define a new additional resource linked to a work"), + diesel(table_name = additional_resource) +)] +pub struct NewAdditionalResource { + pub work_id: Uuid, + pub title: String, + pub description: Option<String>, + pub attribution: Option<String>, + pub resource_type: ResourceType, + pub doi: Option<Doi>, + pub handle: Option<String>, + pub url: Option<String>, + pub date: Option<NaiveDate>, + pub resource_ordinal: i32, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::AsChangeset), + graphql(description = "Set of values required to update an existing additional resource"), + diesel(table_name = additional_resource, treat_none_as_null = true) +)] +pub struct PatchAdditionalResource { + pub additional_resource_id: Uuid, + pub work_id: Uuid, + pub title: String, + pub description: Option<String>, + pub attribution: Option<String>, + pub resource_type: ResourceType, + pub doi: Option<Doi>, + pub handle: Option<String>, + pub url: Option<String>, + pub date: Option<NaiveDate>, + pub resource_ordinal: i32, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +pub struct AdditionalResourceHistory { + pub additional_resource_history_id: Uuid, + pub additional_resource_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, + pub timestamp: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(diesel::Insertable), + diesel(table_name = additional_resource_history) +)] +pub struct NewAdditionalResourceHistory { + pub additional_resource_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject), + graphql(description = "Field and order to use when sorting additional resources list") +)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct AdditionalResourceOrderBy { + pub field: AdditionalResourceField, + pub direction: Direction, +} + +#[cfg(feature = "backend")] +pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::AdditionalResourcePolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/additional_resource/policy.rs b/thoth-api/src/model/additional_resource/policy.rs new file mode 100644 index 00000000..7f206d57 --- /dev/null +++ b/thoth-api/src/model/additional_resource/policy.rs @@ -0,0 +1,60 @@ +use crate::model::additional_resource::{ + AdditionalResource, NewAdditionalResource, PatchAdditionalResource, +}; +use crate::model::work::{Work, WorkType}; +use crate::model::Crud; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `AdditionalResource`. +/// +/// These policies enforce publisher scoping and prevent attachment to chapter records. +pub struct AdditionalResourcePolicy; + +fn ensure_work_is_book(db: &crate::db::PgPool, work_id: uuid::Uuid) -> ThothResult<()> { + let work = Work::from_id(db, &work_id)?; + if work.work_type == WorkType::BookChapter { + Err(ThothError::ChapterBookMetadataError) + } else { + Ok(()) + } +} + +impl CreatePolicy<NewAdditionalResource> for AdditionalResourcePolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewAdditionalResource, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + ensure_work_is_book(ctx.db(), data.work_id) + } +} + +impl UpdatePolicy<AdditionalResource, PatchAdditionalResource> for AdditionalResourcePolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &AdditionalResource, + patch: &PatchAdditionalResource, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + ensure_work_is_book(ctx.db(), current.work_id)?; + ensure_work_is_book(ctx.db(), patch.work_id) + } +} + +impl DeletePolicy<AdditionalResource> for AdditionalResourcePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &AdditionalResource) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ensure_work_is_book(ctx.db(), current.work_id) + } +} + +impl MovePolicy<AdditionalResource> for AdditionalResourcePolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &AdditionalResource) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ensure_work_is_book(ctx.db(), current.work_id) + } +} diff --git a/thoth-api/src/model/additional_resource/tests.rs b/thoth-api/src/model/additional_resource/tests.rs new file mode 100644 index 00000000..9399098f --- /dev/null +++ b/thoth-api/src/model/additional_resource/tests.rs @@ -0,0 +1,412 @@ +use super::*; +use crate::model::Crud; +use std::str::FromStr; +use uuid::Uuid; + +fn make_additional_resource( + pool: &crate::db::PgPool, + work_id: Uuid, + resource_ordinal: i32, + title: &str, +) -> AdditionalResource { + let data = NewAdditionalResource { + work_id, + title: title.to_string(), + description: Some("Resource description".to_string()), + attribution: Some("Resource attribution".to_string()), + resource_type: ResourceType::Website, + doi: Some(crate::model::Doi::from_str("https://doi.org/10.1234/RESOURCE.1").unwrap()), + handle: Some("hdl:1234/5678".to_string()), + url: Some("https://example.com/resource".to_string()), + date: chrono::NaiveDate::from_ymd_opt(2025, 1, 1), + resource_ordinal, + }; + + AdditionalResource::create(pool, &data).expect("Failed to create additional resource") +} + +mod defaults { + use super::*; + + #[test] + fn resourcetype_default_is_other() { + let resource_type: ResourceType = Default::default(); + assert_eq!(resource_type, ResourceType::Other); + } + + #[test] + fn additionalresourcefield_default_is_resource_ordinal() { + let field: AdditionalResourceField = Default::default(); + assert_eq!(field, AdditionalResourceField::ResourceOrdinal); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let resource: AdditionalResource = Default::default(); + assert_eq!(resource.pk(), resource.additional_resource_id); + } + + #[test] + fn history_entry_serializes_model() { + let resource: AdditionalResource = Default::default(); + let user_id = "123456".to_string(); + let history = resource.new_history_entry(&user_id); + assert_eq!( + history.additional_resource_id, + resource.additional_resource_id + ); + assert_eq!(history.user_id, user_id); + assert_eq!( + history.data, + serde_json::Value::String(serde_json::to_string(&resource).unwrap()) + ); + } +} + +mod conversions { + use super::*; + #[cfg(feature = "backend")] + use crate::model::tests::db::setup_test_db; + #[cfg(feature = "backend")] + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[cfg(feature = "backend")] + #[test] + fn resourcetype_graphql_roundtrip() { + assert_graphql_enum_roundtrip(ResourceType::Other); + } + + #[cfg(feature = "backend")] + #[test] + fn resourcetype_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<ResourceType, crate::schema::sql_types::ResourceType>( + pool.as_ref(), + "'OTHER'::resource_type", + ResourceType::Other, + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::additional_resource::policy::AdditionalResourcePolicy; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::work::{NewWork, Work, WorkStatus, WorkType}; + use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("resource-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let data = NewAdditionalResource { + work_id: work.work_id, + title: "Resource".to_string(), + description: Some("Description".to_string()), + attribution: Some("Attribution".to_string()), + resource_type: ResourceType::Website, + doi: None, + handle: None, + url: Some("https://example.com".to_string()), + date: None, + resource_ordinal: 1, + }; + + let resource = AdditionalResource::create(pool.as_ref(), &data).expect("Failed to create"); + let patch = PatchAdditionalResource { + additional_resource_id: resource.additional_resource_id, + work_id: resource.work_id, + title: "Resource Updated".to_string(), + description: resource.description.clone(), + attribution: resource.attribution.clone(), + resource_type: resource.resource_type, + doi: resource.doi.clone(), + handle: resource.handle.clone(), + url: resource.url.clone(), + date: resource.date, + resource_ordinal: 1, + }; + + assert!(AdditionalResourcePolicy::can_create(&ctx, &data, ()).is_ok()); + assert!(AdditionalResourcePolicy::can_update(&ctx, &resource, &patch, ()).is_ok()); + assert!(AdditionalResourcePolicy::can_delete(&ctx, &resource).is_ok()); + assert!(AdditionalResourcePolicy::can_move(&ctx, &resource).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let resource = make_additional_resource(pool.as_ref(), work.work_id, 1, "Resource"); + + let patch = PatchAdditionalResource { + additional_resource_id: resource.additional_resource_id, + work_id: resource.work_id, + title: "Resource Updated".to_string(), + description: resource.description.clone(), + attribution: resource.attribution.clone(), + resource_type: resource.resource_type, + doi: resource.doi.clone(), + handle: resource.handle.clone(), + url: resource.url.clone(), + date: resource.date, + resource_ordinal: 2, + }; + + let user = test_user_with_role("resource-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let data = NewAdditionalResource { + work_id: work.work_id, + title: "Resource".to_string(), + description: Some("Description".to_string()), + attribution: Some("Attribution".to_string()), + resource_type: ResourceType::Website, + doi: None, + handle: None, + url: Some("https://example.com".to_string()), + date: None, + resource_ordinal: 1, + }; + + assert!(AdditionalResourcePolicy::can_create(&ctx, &data, ()).is_err()); + assert!(AdditionalResourcePolicy::can_update(&ctx, &resource, &patch, ()).is_err()); + assert!(AdditionalResourcePolicy::can_delete(&ctx, &resource).is_err()); + assert!(AdditionalResourcePolicy::can_move(&ctx, &resource).is_err()); + } + + #[test] + fn crud_policy_rejects_chapter_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("resource-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let chapter = Work::create( + pool.as_ref(), + &NewWork { + work_type: WorkType::BookChapter, + work_status: WorkStatus::Forthcoming, + reference: None, + edition: None, + imprint_id: imprint.imprint_id, + doi: None, + publication_date: None, + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: Some("1".to_string()), + last_page: Some("10".to_string()), + page_interval: Some("1–10".to_string()), + }, + ) + .expect("Failed to create chapter work"); + + let data = NewAdditionalResource { + work_id: chapter.work_id, + title: "Resource".to_string(), + description: Some("Description".to_string()), + attribution: None, + resource_type: ResourceType::Website, + doi: None, + handle: None, + url: Some("https://example.com".to_string()), + date: None, + resource_ordinal: 1, + }; + + assert!(matches!( + AdditionalResourcePolicy::can_create(&ctx, &data, ()), + Err(thoth_errors::ThothError::ChapterBookMetadataError) + )); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context, + }; + use crate::model::Reorder; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let data = NewAdditionalResource { + work_id: work.work_id, + title: "Resource".to_string(), + description: Some("Description".to_string()), + attribution: Some("Attribution".to_string()), + resource_type: ResourceType::Website, + doi: None, + handle: None, + url: Some("https://example.com".to_string()), + date: chrono::NaiveDate::from_ymd_opt(2025, 2, 1), + resource_ordinal: 1, + }; + + let resource = AdditionalResource::create(pool.as_ref(), &data).expect("Failed to create"); + let fetched = AdditionalResource::from_id(pool.as_ref(), &resource.additional_resource_id) + .expect("Failed to fetch"); + assert_eq!( + resource.additional_resource_id, + fetched.additional_resource_id + ); + + let patch = PatchAdditionalResource { + additional_resource_id: resource.additional_resource_id, + work_id: resource.work_id, + title: "Resource Updated".to_string(), + description: Some("Description Updated".to_string()), + attribution: resource.attribution.clone(), + resource_type: ResourceType::Document, + doi: resource.doi.clone(), + handle: resource.handle.clone(), + url: resource.url.clone(), + date: chrono::NaiveDate::from_ymd_opt(2025, 3, 1), + resource_ordinal: 1, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = resource.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.title, patch.title); + assert_eq!(updated.date, patch.date); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!( + AdditionalResource::from_id(pool.as_ref(), &deleted.additional_resource_id).is_err() + ); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_additional_resource(pool.as_ref(), work.work_id, 1, "Resource 1"); + make_additional_resource(pool.as_ref(), work.work_id, 2, "Resource 2"); + + let first = AdditionalResource::all( + pool.as_ref(), + 1, + 0, + None, + AdditionalResourceOrderBy { + field: AdditionalResourceField::ResourceOrdinal, + direction: crate::graphql::types::inputs::Direction::Asc, + }, + vec![], + Some(work.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch additional resources"); + + let second = AdditionalResource::all( + pool.as_ref(), + 1, + 1, + None, + AdditionalResourceOrderBy { + field: AdditionalResourceField::ResourceOrdinal, + direction: crate::graphql::types::inputs::Direction::Asc, + }, + vec![], + Some(work.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch additional resources"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!( + first[0].additional_resource_id, + second[0].additional_resource_id + ); + } + + #[test] + fn crud_change_ordinal_reorders_within_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let first = make_additional_resource(pool.as_ref(), work.work_id, 1, "Resource 1"); + let second = make_additional_resource(pool.as_ref(), work.work_id, 2, "Resource 2"); + let ctx = test_context(pool.clone(), "test-user"); + + let moved = second + .change_ordinal(&ctx, second.resource_ordinal, 1) + .expect("Failed to reorder additional resource"); + let shifted = AdditionalResource::from_id(pool.as_ref(), &first.additional_resource_id) + .expect("Failed to fetch shifted additional resource"); + + assert_eq!(moved.resource_ordinal, 1); + assert_eq!(shifted.resource_ordinal, 2); + } +} diff --git a/thoth-api/src/model/affiliation/crud.rs b/thoth-api/src/model/affiliation/crud.rs index 3aee12fb..4c677e7e 100644 --- a/thoth-api/src/model/affiliation/crud.rs +++ b/thoth-api/src/model/affiliation/crud.rs @@ -2,11 +2,9 @@ use super::{ Affiliation, AffiliationField, AffiliationHistory, AffiliationOrderBy, NewAffiliation, NewAffiliationHistory, PatchAffiliation, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; use crate::schema::{affiliation, affiliation_history}; -use crate::{crud_methods, db_insert}; -use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; +use diesel::{BoolExpressionMethods, Connection, ExpressionMethods, QueryDsl, RunQueryDsl}; use thoth_errors::ThothResult; use uuid::Uuid; @@ -17,6 +15,7 @@ impl Crud for Affiliation { type FilterParameter1 = (); type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.affiliation_id @@ -34,6 +33,7 @@ impl Crud for Affiliation { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Affiliation>> { use crate::schema::affiliation::dsl::*; let mut connection = db.get()?; @@ -46,34 +46,27 @@ impl Crud for Affiliation { .into_boxed(); query = match order.field { - AffiliationField::AffiliationId => match order.direction { - Direction::Asc => query.order(affiliation_id.asc()), - Direction::Desc => query.order(affiliation_id.desc()), - }, - AffiliationField::ContributionId => match order.direction { - Direction::Asc => query.order(contribution_id.asc()), - Direction::Desc => query.order(contribution_id.desc()), - }, - AffiliationField::InstitutionId => match order.direction { - Direction::Asc => query.order(institution_id.asc()), - Direction::Desc => query.order(institution_id.desc()), - }, - AffiliationField::AffiliationOrdinal => match order.direction { - Direction::Asc => query.order(affiliation_ordinal.asc()), - Direction::Desc => query.order(affiliation_ordinal.desc()), - }, - AffiliationField::Position => match order.direction { - Direction::Asc => query.order(position.asc()), - Direction::Desc => query.order(position.desc()), - }, - AffiliationField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - AffiliationField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + AffiliationField::AffiliationId => { + apply_directional_order!(query, order.direction, order, affiliation_id) + } + AffiliationField::ContributionId => { + apply_directional_order!(query, order.direction, order, contribution_id) + } + AffiliationField::InstitutionId => { + apply_directional_order!(query, order.direction, order, institution_id) + } + AffiliationField::AffiliationOrdinal => { + apply_directional_order!(query, order.direction, order, affiliation_ordinal) + } + AffiliationField::Position => { + apply_directional_order!(query, order.direction, order, position) + } + AffiliationField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + AffiliationField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if !publishers.is_empty() { query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); @@ -98,6 +91,7 @@ impl Crud for Affiliation { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::affiliation::dsl::*; let mut connection = db.get()?; @@ -113,21 +107,20 @@ impl Crud for Affiliation { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::contribution::Contribution::from_id(db, &self.contribution_id)? - .publisher_id(db) - } - crud_methods!(affiliation::table, affiliation::dsl::affiliation); } +publisher_id_impls!(Affiliation, NewAffiliation, PatchAffiliation, |s, db| { + crate::model::contribution::Contribution::from_id(db, &s.contribution_id)?.publisher_id(db) +}); + impl HistoryEntry for Affiliation { type NewHistoryEntity = NewAffiliationHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { affiliation_id: self.affiliation_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -139,29 +132,28 @@ impl DbInsert for NewAffiliationHistory { db_insert!(affiliation_history::table); } -#[cfg(test)] -mod tests { - use super::*; +impl Reorder for Affiliation { + db_change_ordinal!( + affiliation::table, + affiliation::affiliation_ordinal, + "affiliation_affiliation_ordinal_contribution_id_uniq" + ); - #[test] - fn test_affiliation_pk() { - let affiliation: Affiliation = Default::default(); - assert_eq!(affiliation.pk(), affiliation.affiliation_id); - } - - #[test] - fn test_new_affiliation_history_from_affiliation() { - let affiliation: Affiliation = Default::default(); - let account_id: Uuid = Default::default(); - let new_affiliation_history = affiliation.new_history_entry(&account_id); - assert_eq!( - new_affiliation_history.affiliation_id, - affiliation.affiliation_id - ); - assert_eq!(new_affiliation_history.account_id, account_id); - assert_eq!( - new_affiliation_history.data, - serde_json::Value::String(serde_json::to_string(&affiliation).unwrap()) - ); + fn get_other_objects( + &self, + connection: &mut diesel::PgConnection, + ) -> ThothResult<Vec<(Uuid, i32)>> { + affiliation::table + .select(( + affiliation::affiliation_id, + affiliation::affiliation_ordinal, + )) + .filter( + affiliation::contribution_id + .eq(self.contribution_id) + .and(affiliation::affiliation_id.ne(self.affiliation_id)), + ) + .load::<(Uuid, i32)>(connection) + .map_err(Into::into) } } diff --git a/thoth-api/src/model/affiliation/mod.rs b/thoth-api/src/model/affiliation/mod.rs index d3ad6b39..183a2814 100644 --- a/thoth-api/src/model/affiliation/mod.rs +++ b/thoth-api/src/model/affiliation/mod.rs @@ -1,9 +1,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::graphql::utils::Direction; -use crate::model::contribution::ContributionWithWork; -use crate::model::institution::Institution; +use crate::graphql::types::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::affiliation; @@ -25,7 +23,7 @@ pub enum AffiliationField { UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Affiliation { @@ -38,26 +36,9 @@ pub struct Affiliation { pub updated_at: Timestamp, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AffiliationWithInstitution { - pub affiliation_id: Uuid, - pub contribution_id: Uuid, - pub institution_id: Uuid, - pub affiliation_ordinal: i32, - pub position: Option<String>, - pub institution: Institution, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct AffiliationWithContribution { - pub contribution: ContributionWithWork, -} - #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new association between a person and an institution for a specific contribution"), diesel(table_name = affiliation) )] @@ -70,7 +51,7 @@ pub struct NewAffiliation { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing association between a person and an institution for a specific contribution"), diesel(table_name = affiliation, treat_none_as_null = true) )] @@ -82,23 +63,23 @@ pub struct PatchAffiliation { pub position: Option<String>, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct AffiliationHistory { pub affiliation_history_id: Uuid, pub affiliation_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = affiliation_history) )] pub struct NewAffiliationHistory { pub affiliation_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -112,19 +93,6 @@ pub struct AffiliationOrderBy { pub direction: Direction, } -impl Default for AffiliationWithInstitution { - fn default() -> AffiliationWithInstitution { - AffiliationWithInstitution { - affiliation_id: Default::default(), - institution_id: Default::default(), - contribution_id: Default::default(), - affiliation_ordinal: 1, - position: Default::default(), - institution: Default::default(), - } - } -} - impl Default for AffiliationOrderBy { fn default() -> AffiliationOrderBy { AffiliationOrderBy { @@ -136,3 +104,9 @@ impl Default for AffiliationOrderBy { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::AffiliationPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/affiliation/policy.rs b/thoth-api/src/model/affiliation/policy.rs new file mode 100644 index 00000000..ebb2e453 --- /dev/null +++ b/thoth-api/src/model/affiliation/policy.rs @@ -0,0 +1,49 @@ +use crate::model::affiliation::{Affiliation, NewAffiliation, PatchAffiliation}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Affiliation`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct AffiliationPolicy; + +impl CreatePolicy<NewAffiliation> for AffiliationPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewAffiliation, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Affiliation, PatchAffiliation> for AffiliationPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Affiliation, + patch: &PatchAffiliation, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy<Affiliation> for AffiliationPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Affiliation) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} + +impl MovePolicy<Affiliation> for AffiliationPolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &Affiliation) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/affiliation/tests.rs b/thoth-api/src/model/affiliation/tests.rs new file mode 100644 index 00000000..c000d58e --- /dev/null +++ b/thoth-api/src/model/affiliation/tests.rs @@ -0,0 +1,678 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_new_affiliation( + contribution_id: Uuid, + institution_id: Uuid, + affiliation_ordinal: i32, + position: Option<String>, +) -> NewAffiliation { + NewAffiliation { + contribution_id, + institution_id, + affiliation_ordinal, + position, + } +} + +fn make_patch_affiliation( + affiliation: &Affiliation, + affiliation_ordinal: i32, + position: Option<String>, +) -> PatchAffiliation { + PatchAffiliation { + affiliation_id: affiliation.affiliation_id, + contribution_id: affiliation.contribution_id, + institution_id: affiliation.institution_id, + affiliation_ordinal, + position, + } +} + +fn make_affiliation( + pool: &crate::db::PgPool, + contribution_id: Uuid, + institution_id: Uuid, + affiliation_ordinal: i32, +) -> Affiliation { + let new_affiliation = make_new_affiliation( + contribution_id, + institution_id, + affiliation_ordinal, + Some(format!("Position {}", Uuid::new_v4())), + ); + + Affiliation::create(pool, &new_affiliation).expect("Failed to create affiliation") +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let affiliation: Affiliation = Default::default(); + assert_eq!(affiliation.pk(), affiliation.affiliation_id); + } + + #[test] + fn history_entry_serializes_model() { + let affiliation: Affiliation = Default::default(); + let user_id = "123456".to_string(); + let new_affiliation_history = affiliation.new_history_entry(&user_id); + assert_eq!( + new_affiliation_history.affiliation_id, + affiliation.affiliation_id + ); + assert_eq!(new_affiliation_history.user_id, user_id); + assert_eq!( + new_affiliation_history.data, + serde_json::Value::String(serde_json::to_string(&affiliation).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::affiliation::policy::AffiliationPolicy; + use crate::model::tests::db::{ + create_contribution, create_contributor, create_imprint, create_institution, + create_publisher, create_work, setup_test_db, test_context_with_user, test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("affiliation-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let institution = create_institution(pool.as_ref()); + let new_affiliation = make_new_affiliation( + contribution.contribution_id, + institution.institution_id, + 1, + Some("Position".to_string()), + ); + + let affiliation = + Affiliation::create(pool.as_ref(), &new_affiliation).expect("Failed to create"); + let patch = make_patch_affiliation(&affiliation, 2, Some("Updated Position".to_string())); + + assert!(AffiliationPolicy::can_create(&ctx, &new_affiliation, ()).is_ok()); + assert!(AffiliationPolicy::can_update(&ctx, &affiliation, &patch, ()).is_ok()); + assert!(AffiliationPolicy::can_delete(&ctx, &affiliation).is_ok()); + assert!(AffiliationPolicy::can_move(&ctx, &affiliation).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let institution = create_institution(pool.as_ref()); + let affiliation = make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution.institution_id, + 1, + ); + let patch = make_patch_affiliation(&affiliation, 2, Some("Updated Position".to_string())); + + let user = test_user_with_role("affiliation-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_affiliation = make_new_affiliation( + contribution.contribution_id, + institution.institution_id, + 1, + Some("Position".to_string()), + ); + + assert!(AffiliationPolicy::can_create(&ctx, &new_affiliation, ()).is_err()); + assert!(AffiliationPolicy::can_update(&ctx, &affiliation, &patch, ()).is_err()); + assert!(AffiliationPolicy::can_delete(&ctx, &affiliation).is_err()); + assert!(AffiliationPolicy::can_move(&ctx, &affiliation).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::model::tests::db::{ + create_contribution, create_contributor, create_imprint, create_institution, + create_publisher, create_work, setup_test_db, test_context, + }; + use crate::model::{Crud, Reorder}; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let institution = create_institution(pool.as_ref()); + + let new_affiliation = make_new_affiliation( + contribution.contribution_id, + institution.institution_id, + 1, + Some(format!("Position {}", Uuid::new_v4())), + ); + + let affiliation = Affiliation::create(pool.as_ref(), &new_affiliation) + .expect("Failed to create affiliation"); + let fetched = Affiliation::from_id(pool.as_ref(), &affiliation.affiliation_id) + .expect("Failed to fetch"); + assert_eq!(affiliation.affiliation_id, fetched.affiliation_id); + + let patch = make_patch_affiliation(&affiliation, 2, Some("Updated Position".to_string())); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = affiliation.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.position, patch.position); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Affiliation::from_id(pool.as_ref(), &deleted.affiliation_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let institution_one = create_institution(pool.as_ref()); + let institution_two = create_institution(pool.as_ref()); + + make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_one.institution_id, + 1, + ); + make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_two.institution_id, + 2, + ); + + let order = AffiliationOrderBy { + field: AffiliationField::AffiliationId, + direction: Direction::Asc, + }; + + let first = Affiliation::all( + pool.as_ref(), + 1, + 0, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch affiliations"); + let second = Affiliation::all( + pool.as_ref(), + 1, + 1, + None, + AffiliationOrderBy { + field: AffiliationField::AffiliationId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch affiliations"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].affiliation_id, second[0].affiliation_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let institution_one = create_institution(pool.as_ref()); + let institution_two = create_institution(pool.as_ref()); + + make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_one.institution_id, + 1, + ); + make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_two.institution_id, + 2, + ); + + let count = Affiliation::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count affiliations"); + assert_eq!(count, 2); + } + + #[test] + fn crud_filter_parent_institution_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let institution_one = create_institution(pool.as_ref()); + let institution_two = create_institution(pool.as_ref()); + + let matches = make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_one.institution_id, + 1, + ); + make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_two.institution_id, + 2, + ); + + let filtered = Affiliation::all( + pool.as_ref(), + 10, + 0, + None, + AffiliationOrderBy { + field: AffiliationField::AffiliationId, + direction: Direction::Asc, + }, + vec![], + Some(institution_one.institution_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter affiliations by institution"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].affiliation_id, matches.affiliation_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let institution_one = create_institution(pool.as_ref()); + let institution_two = create_institution(pool.as_ref()); + + let first = make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_one.institution_id, + 1, + ); + let second = make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_two.institution_id, + 2, + ); + let mut ids = [first.affiliation_id, second.affiliation_id]; + ids.sort(); + + let asc = Affiliation::all( + pool.as_ref(), + 2, + 0, + None, + AffiliationOrderBy { + field: AffiliationField::AffiliationId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order affiliations (asc)"); + + let desc = Affiliation::all( + pool.as_ref(), + 2, + 0, + None, + AffiliationOrderBy { + field: AffiliationField::AffiliationId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order affiliations (desc)"); + + assert_eq!(asc[0].affiliation_id, ids[0]); + assert_eq!(desc[0].affiliation_id, ids[1]); + } + + #[test] + fn crud_filter_parent_contribution_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let other_contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let other_contribution = + create_contribution(pool.as_ref(), &other_work, &other_contributor); + let institution = create_institution(pool.as_ref()); + + let matches = make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution.institution_id, + 1, + ); + make_affiliation( + pool.as_ref(), + other_contribution.contribution_id, + institution.institution_id, + 1, + ); + + let filtered = Affiliation::all( + pool.as_ref(), + 10, + 0, + None, + AffiliationOrderBy { + field: AffiliationField::AffiliationId, + direction: Direction::Asc, + }, + vec![], + None, + Some(contribution.contribution_id), + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter affiliations by contribution"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].affiliation_id, matches.affiliation_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let institution = create_institution(pool.as_ref()); + let matches = make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution.institution_id, + 1, + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let other_work = create_work(pool.as_ref(), &other_imprint); + let other_contributor = create_contributor(pool.as_ref()); + let other_contribution = + create_contribution(pool.as_ref(), &other_work, &other_contributor); + let other_institution = create_institution(pool.as_ref()); + make_affiliation( + pool.as_ref(), + other_contribution.contribution_id, + other_institution.institution_id, + 1, + ); + + let filtered = Affiliation::all( + pool.as_ref(), + 10, + 0, + None, + AffiliationOrderBy { + field: AffiliationField::AffiliationId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter affiliations by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].affiliation_id, matches.affiliation_id); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let institution_one = create_institution(pool.as_ref()); + let institution_two = create_institution(pool.as_ref()); + + make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_one.institution_id, + 1, + ); + make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_two.institution_id, + 2, + ); + + let fields: Vec<fn() -> AffiliationField> = vec![ + || AffiliationField::AffiliationId, + || AffiliationField::ContributionId, + || AffiliationField::InstitutionId, + || AffiliationField::AffiliationOrdinal, + || AffiliationField::Position, + || AffiliationField::CreatedAt, + || AffiliationField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Affiliation::all( + pool.as_ref(), + 10, + 0, + None, + AffiliationOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order affiliations"); + + assert_eq!(results.len(), 2); + } + } + } + + #[test] + fn crud_change_ordinal_reorders_affiliations() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let institution_one = create_institution(pool.as_ref()); + let institution_two = create_institution(pool.as_ref()); + + let first = make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_one.institution_id, + 1, + ); + let second = make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_two.institution_id, + 2, + ); + let third = make_affiliation( + pool.as_ref(), + contribution.contribution_id, + create_institution(pool.as_ref()).institution_id, + 3, + ); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = first + .change_ordinal(&ctx, first.affiliation_ordinal, 2) + .expect("Failed to change affiliation ordinal"); + + let refreshed_first = + Affiliation::from_id(pool.as_ref(), &updated.affiliation_id).expect("Failed to fetch"); + let refreshed_second = + Affiliation::from_id(pool.as_ref(), &second.affiliation_id).expect("Failed to fetch"); + let refreshed_third = + Affiliation::from_id(pool.as_ref(), &third.affiliation_id).expect("Failed to fetch"); + + assert_eq!(refreshed_first.affiliation_ordinal, 2); + assert_eq!(refreshed_second.affiliation_ordinal, 1); + assert_eq!(refreshed_third.affiliation_ordinal, 3); + } + + #[test] + fn crud_change_ordinal_move_up_reorders_affiliations() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let institution_one = create_institution(pool.as_ref()); + let institution_two = create_institution(pool.as_ref()); + + let first = make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_one.institution_id, + 1, + ); + let second = make_affiliation( + pool.as_ref(), + contribution.contribution_id, + institution_two.institution_id, + 2, + ); + let third = make_affiliation( + pool.as_ref(), + contribution.contribution_id, + create_institution(pool.as_ref()).institution_id, + 3, + ); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = second + .change_ordinal(&ctx, second.affiliation_ordinal, 1) + .expect("Failed to move affiliation ordinal up"); + + let refreshed_first = + Affiliation::from_id(pool.as_ref(), &first.affiliation_id).expect("Failed to fetch"); + let refreshed_second = + Affiliation::from_id(pool.as_ref(), &updated.affiliation_id).expect("Failed to fetch"); + let refreshed_third = + Affiliation::from_id(pool.as_ref(), &third.affiliation_id).expect("Failed to fetch"); + + assert_eq!(refreshed_second.affiliation_ordinal, 1); + assert_eq!(refreshed_first.affiliation_ordinal, 2); + assert_eq!(refreshed_third.affiliation_ordinal, 3); + } +} diff --git a/thoth-api/src/model/award/crud.rs b/thoth-api/src/model/award/crud.rs new file mode 100644 index 00000000..ddf4af1a --- /dev/null +++ b/thoth-api/src/model/award/crud.rs @@ -0,0 +1,175 @@ +use super::{Award, AwardField, AwardHistory, AwardOrderBy, NewAward, NewAwardHistory, PatchAward}; +use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; +use crate::schema::{award, award_history}; +use diesel::{ + BoolExpressionMethods, Connection, ExpressionMethods, PgTextExpressionMethods, QueryDsl, + RunQueryDsl, +}; +use thoth_errors::ThothResult; +use uuid::Uuid; + +impl Crud for Award { + type NewEntity = NewAward; + type PatchEntity = PatchAward; + type OrderByEntity = AwardOrderBy; + type FilterParameter1 = (); + type FilterParameter2 = (); + type FilterParameter3 = (); + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.award_id + } + + fn all( + db: &crate::db::PgPool, + limit: i32, + offset: i32, + filter: Option<String>, + order: Self::OrderByEntity, + publishers: Vec<Uuid>, + parent_id_1: Option<Uuid>, + _: Option<Uuid>, + _: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<Vec<Award>> { + use crate::schema::award::dsl::*; + let mut connection = db.get()?; + let mut query = award + .inner_join(crate::schema::work::table.inner_join(crate::schema::imprint::table)) + .select(crate::schema::award::all_columns) + .into_boxed(); + + query = match order.field { + AwardField::AwardId => { + apply_directional_order!(query, order.direction, order, award_id) + } + AwardField::WorkId => apply_directional_order!(query, order.direction, order, work_id), + AwardField::AwardOrdinal => { + apply_directional_order!(query, order.direction, order, award_ordinal) + } + AwardField::Title => apply_directional_order!(query, order.direction, order, title), + AwardField::Category => { + apply_directional_order!(query, order.direction, order, category) + } + AwardField::Role => apply_directional_order!(query, order.direction, order, role), + AwardField::Url => apply_directional_order!(query, order.direction, order, url), + AwardField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + AwardField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } + }; + + if !publishers.is_empty() { + query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); + } + if let Some(pid) = parent_id_1 { + query = query.filter(work_id.eq(pid)); + } + if let Some(filter) = filter { + if !filter.is_empty() { + query = query.filter( + title + .ilike(format!("%{filter}%")) + .or(category.ilike(format!("%{filter}%"))) + .or(prize_statement.ilike(format!("%{filter}%"))) + .or(url.ilike(format!("%{filter}%"))), + ); + } + } + + query + .limit(limit.into()) + .offset(offset.into()) + .load::<Award>(&mut connection) + .map_err(Into::into) + } + + fn count( + db: &crate::db::PgPool, + filter: Option<String>, + publishers: Vec<Uuid>, + _: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<i32> { + use crate::schema::award::dsl::*; + let mut connection = db.get()?; + let mut query = award + .inner_join(crate::schema::work::table.inner_join(crate::schema::imprint::table)) + .into_boxed(); + + if !publishers.is_empty() { + query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); + } + if let Some(filter) = filter { + if !filter.is_empty() { + query = query.filter( + title + .ilike(format!("%{filter}%")) + .or(category.ilike(format!("%{filter}%"))) + .or(prize_statement.ilike(format!("%{filter}%"))) + .or(url.ilike(format!("%{filter}%"))), + ); + } + } + + query + .count() + .get_result::<i64>(&mut connection) + .map(|t| t.to_string().parse::<i32>().unwrap()) + .map_err(Into::into) + } + + crud_methods!(award::table, award::dsl::award); +} + +publisher_id_impls!(Award, NewAward, PatchAward, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + +impl HistoryEntry for Award { + type NewHistoryEntity = NewAwardHistory; + + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { + Self::NewHistoryEntity { + award_id: self.award_id, + user_id: user_id.to_string(), + data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), + } + } +} + +impl DbInsert for NewAwardHistory { + type MainEntity = AwardHistory; + + db_insert!(award_history::table); +} + +impl Reorder for Award { + db_change_ordinal!( + award::table, + award::award_ordinal, + "award_award_ordinal_work_id_uniq" + ); + + fn get_other_objects( + &self, + connection: &mut diesel::PgConnection, + ) -> ThothResult<Vec<(Uuid, i32)>> { + award::table + .select((award::award_id, award::award_ordinal)) + .filter( + award::work_id + .eq(self.work_id) + .and(award::award_id.ne(self.award_id)), + ) + .load::<(Uuid, i32)>(connection) + .map_err(Into::into) + } +} diff --git a/thoth-api/src/model/award/mod.rs b/thoth-api/src/model/award/mod.rs new file mode 100644 index 00000000..ced12da2 --- /dev/null +++ b/thoth-api/src/model/award/mod.rs @@ -0,0 +1,141 @@ +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; +use uuid::Uuid; + +use crate::graphql::types::inputs::Direction; +use crate::model::Timestamp; +#[cfg(feature = "backend")] +use crate::schema::award; +#[cfg(feature = "backend")] +use crate::schema::award_history; + +#[cfg_attr( + feature = "backend", + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), + graphql(description = "Role of the work in an award"), + ExistingTypePath = "crate::schema::sql_types::AwardRole" +)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize, EnumString, Display)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "SCREAMING_SNAKE_CASE")] +pub enum AwardRole { + #[cfg_attr(feature = "backend", db_rename = "SHORT_LISTED")] + ShortListed, + #[cfg_attr(feature = "backend", db_rename = "WINNER")] + Winner, + #[cfg_attr(feature = "backend", db_rename = "LONG_LISTED")] + LongListed, + #[cfg_attr(feature = "backend", db_rename = "COMMENDED")] + Commended, + #[cfg_attr(feature = "backend", db_rename = "RUNNER_UP")] + RunnerUp, + #[cfg_attr(feature = "backend", db_rename = "JOINT_WINNER")] + JointWinner, + #[cfg_attr(feature = "backend", db_rename = "NOMINATED")] + Nominated, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLEnum), + graphql(description = "Field to use when sorting awards list") +)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AwardField { + AwardId, + WorkId, + #[default] + AwardOrdinal, + Title, + Category, + Role, + Url, + CreatedAt, + UpdatedAt, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Award { + pub award_id: Uuid, + pub work_id: Uuid, + pub title: String, + pub url: Option<String>, + pub category: Option<String>, + pub prize_statement: Option<String>, + pub role: Option<AwardRole>, + pub award_ordinal: i32, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::Insertable), + graphql(description = "Set of values required to define a new award linked to a work"), + diesel(table_name = award) +)] +pub struct NewAward { + pub work_id: Uuid, + pub title: String, + pub url: Option<String>, + pub category: Option<String>, + pub prize_statement: Option<String>, + pub role: Option<AwardRole>, + pub award_ordinal: i32, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::AsChangeset), + graphql(description = "Set of values required to update an existing award"), + diesel(table_name = award, treat_none_as_null = true) +)] +pub struct PatchAward { + pub award_id: Uuid, + pub work_id: Uuid, + pub title: String, + pub url: Option<String>, + pub category: Option<String>, + pub prize_statement: Option<String>, + pub role: Option<AwardRole>, + pub award_ordinal: i32, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +pub struct AwardHistory { + pub award_history_id: Uuid, + pub award_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, + pub timestamp: Timestamp, +} + +#[cfg_attr(feature = "backend", derive(diesel::Insertable), diesel(table_name = award_history))] +pub struct NewAwardHistory { + pub award_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject), + graphql(description = "Field and order to use when sorting awards list") +)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct AwardOrderBy { + pub field: AwardField, + pub direction: Direction, +} + +#[cfg(feature = "backend")] +pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::AwardPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/award/policy.rs b/thoth-api/src/model/award/policy.rs new file mode 100644 index 00000000..1c6e07d0 --- /dev/null +++ b/thoth-api/src/model/award/policy.rs @@ -0,0 +1,54 @@ +use crate::model::award::{Award, NewAward, PatchAward}; +use crate::model::work::{Work, WorkType}; +use crate::model::Crud; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Award`. +/// +/// These policies enforce publisher scoping and prevent attachment to chapter records. +pub struct AwardPolicy; + +fn ensure_work_is_book(db: &crate::db::PgPool, work_id: uuid::Uuid) -> ThothResult<()> { + let work = Work::from_id(db, &work_id)?; + if work.work_type == WorkType::BookChapter { + Err(ThothError::ChapterBookMetadataError) + } else { + Ok(()) + } +} + +impl CreatePolicy<NewAward> for AwardPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewAward, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + ensure_work_is_book(ctx.db(), data.work_id) + } +} + +impl UpdatePolicy<Award, PatchAward> for AwardPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Award, + patch: &PatchAward, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + ensure_work_is_book(ctx.db(), current.work_id)?; + ensure_work_is_book(ctx.db(), patch.work_id) + } +} + +impl DeletePolicy<Award> for AwardPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Award) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ensure_work_is_book(ctx.db(), current.work_id) + } +} + +impl MovePolicy<Award> for AwardPolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &Award) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ensure_work_is_book(ctx.db(), current.work_id) + } +} diff --git a/thoth-api/src/model/award/tests.rs b/thoth-api/src/model/award/tests.rs new file mode 100644 index 00000000..bdc3ac9f --- /dev/null +++ b/thoth-api/src/model/award/tests.rs @@ -0,0 +1,310 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_award(pool: &crate::db::PgPool, work_id: Uuid, award_ordinal: i32, title: &str) -> Award { + let data = NewAward { + work_id, + title: title.to_string(), + url: Some("https://example.com/award".to_string()), + category: Some("Prize".to_string()), + prize_statement: Some("Award note".to_string()), + role: Some(AwardRole::Winner), + award_ordinal, + }; + + Award::create(pool, &data).expect("Failed to create award") +} + +mod conversions { + use super::*; + #[cfg(feature = "backend")] + use crate::model::tests::db::setup_test_db; + #[cfg(feature = "backend")] + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[cfg(feature = "backend")] + #[test] + fn awardrole_graphql_roundtrip() { + assert_graphql_enum_roundtrip(AwardRole::JointWinner); + } + + #[cfg(feature = "backend")] + #[test] + fn awardrole_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<AwardRole, crate::schema::sql_types::AwardRole>( + pool.as_ref(), + "'JOINT_WINNER'::award_role", + AwardRole::JointWinner, + ); + } +} + +mod defaults { + use super::*; + + #[test] + fn awardfield_default_is_award_ordinal() { + let field: AwardField = Default::default(); + assert_eq!(field, AwardField::AwardOrdinal); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let award: Award = Default::default(); + assert_eq!(award.pk(), award.award_id); + } + + #[test] + fn history_entry_serializes_model() { + let award: Award = Default::default(); + let user_id = "123456".to_string(); + let history = award.new_history_entry(&user_id); + assert_eq!(history.award_id, award.award_id); + assert_eq!(history.user_id, user_id); + assert_eq!( + history.data, + serde_json::Value::String(serde_json::to_string(&award).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::award::policy::AwardPolicy; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::work::{NewWork, Work, WorkStatus, WorkType}; + use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("award-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let data = NewAward { + work_id: work.work_id, + title: "Award".to_string(), + url: Some("https://example.com/award".to_string()), + category: Some("Prize".to_string()), + prize_statement: Some("Award note".to_string()), + role: Some(AwardRole::Winner), + award_ordinal: 1, + }; + + let award = Award::create(pool.as_ref(), &data).expect("Failed to create"); + let patch = PatchAward { + award_id: award.award_id, + work_id: award.work_id, + title: "Award Updated".to_string(), + url: award.url.clone(), + category: award.category.clone(), + prize_statement: award.prize_statement.clone(), + role: award.role, + award_ordinal: 1, + }; + + assert!(AwardPolicy::can_create(&ctx, &data, ()).is_ok()); + assert!(AwardPolicy::can_update(&ctx, &award, &patch, ()).is_ok()); + assert!(AwardPolicy::can_delete(&ctx, &award).is_ok()); + assert!(AwardPolicy::can_move(&ctx, &award).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let award = make_award(pool.as_ref(), work.work_id, 1, "Award"); + + let patch = PatchAward { + award_id: award.award_id, + work_id: award.work_id, + title: "Award Updated".to_string(), + url: award.url.clone(), + category: award.category.clone(), + prize_statement: award.prize_statement.clone(), + role: award.role, + award_ordinal: 2, + }; + + let user = test_user_with_role("award-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let data = NewAward { + work_id: work.work_id, + title: "Award".to_string(), + url: Some("https://example.com/award".to_string()), + category: Some("Prize".to_string()), + prize_statement: Some("Award note".to_string()), + role: Some(AwardRole::Winner), + award_ordinal: 1, + }; + + assert!(AwardPolicy::can_create(&ctx, &data, ()).is_err()); + assert!(AwardPolicy::can_update(&ctx, &award, &patch, ()).is_err()); + assert!(AwardPolicy::can_delete(&ctx, &award).is_err()); + assert!(AwardPolicy::can_move(&ctx, &award).is_err()); + } + + #[test] + fn crud_policy_rejects_chapter_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("award-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let chapter = Work::create( + pool.as_ref(), + &NewWork { + work_type: WorkType::BookChapter, + work_status: WorkStatus::Forthcoming, + reference: None, + edition: None, + imprint_id: imprint.imprint_id, + doi: None, + publication_date: None, + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: Some("1".to_string()), + last_page: Some("10".to_string()), + page_interval: Some("1-10".to_string()), + }, + ) + .expect("Failed to create chapter work"); + + let data = NewAward { + work_id: chapter.work_id, + title: "Award".to_string(), + url: Some("https://example.com/award".to_string()), + category: Some("Prize".to_string()), + prize_statement: Some("Award note".to_string()), + role: Some(AwardRole::Winner), + award_ordinal: 1, + }; + + assert!(matches!( + AwardPolicy::can_create(&ctx, &data, ()), + Err(thoth_errors::ThothError::ChapterBookMetadataError) + )); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context, + }; + use crate::model::Reorder; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let data = NewAward { + work_id: work.work_id, + title: "Award".to_string(), + url: Some("https://example.com/award".to_string()), + category: Some("Prize".to_string()), + prize_statement: Some("Award note".to_string()), + role: Some(AwardRole::Winner), + award_ordinal: 1, + }; + + let award = Award::create(pool.as_ref(), &data).expect("Failed to create"); + let fetched = Award::from_id(pool.as_ref(), &award.award_id).expect("Failed to fetch"); + assert_eq!(award.award_id, fetched.award_id); + + let patch = PatchAward { + award_id: award.award_id, + work_id: award.work_id, + title: "Award Updated".to_string(), + url: award.url.clone(), + category: award.category.clone(), + prize_statement: Some("Updated award note".to_string()), + role: Some(AwardRole::JointWinner), + award_ordinal: 1, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = award.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.title, patch.title); + assert_eq!(updated.role, patch.role); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Award::from_id(pool.as_ref(), &deleted.award_id).is_err()); + } + + #[test] + fn crud_change_ordinal_reorders_within_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let first = make_award(pool.as_ref(), work.work_id, 1, "Award 1"); + let second = make_award(pool.as_ref(), work.work_id, 2, "Award 2"); + let ctx = test_context(pool.clone(), "test-user"); + + let moved = second + .change_ordinal(&ctx, second.award_ordinal, 1) + .expect("Failed to reorder award"); + let shifted = + Award::from_id(pool.as_ref(), &first.award_id).expect("Failed to fetch shifted award"); + + assert_eq!(moved.award_ordinal, 1); + assert_eq!(shifted.award_ordinal, 2); + } +} diff --git a/thoth-api/src/model/biography/crud.rs b/thoth-api/src/model/biography/crud.rs new file mode 100644 index 00000000..40c12c09 --- /dev/null +++ b/thoth-api/src/model/biography/crud.rs @@ -0,0 +1,152 @@ +use super::LocaleCode; +use super::{ + Biography, BiographyField, BiographyHistory, BiographyOrderBy, NewBiography, + NewBiographyHistory, PatchBiography, +}; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; +use crate::schema::{biography, biography_history}; +use diesel::{ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl}; +use thoth_errors::ThothResult; +use uuid::Uuid; + +impl Biography { + pub(crate) fn canonical_from_contribution_id( + db: &crate::db::PgPool, + contribution_id: &Uuid, + ) -> ThothResult<Self> { + let mut connection = db.get()?; + biography::table + .filter(biography::contribution_id.eq(contribution_id)) + .filter(biography::canonical.eq(true)) + .first::<Biography>(&mut connection) + .map_err(Into::into) + } +} + +impl Crud for Biography { + type NewEntity = NewBiography; + type PatchEntity = PatchBiography; + type OrderByEntity = BiographyOrderBy; + type FilterParameter1 = LocaleCode; + type FilterParameter2 = (); + type FilterParameter3 = (); + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.biography_id + } + + fn all( + db: &crate::db::PgPool, + limit: i32, + offset: i32, + filter: Option<String>, + order: Self::OrderByEntity, + _: Vec<Uuid>, + parent_id_1: Option<Uuid>, + _: Option<Uuid>, + locale_codes: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<Vec<Biography>> { + use crate::schema::biography::dsl::*; + + let mut connection = db.get()?; + let mut query = biography + .select(( + biography_id, + contribution_id, + content, + canonical, + locale_code, + )) + .into_boxed(); + + query = match order.field { + BiographyField::BiographyId => { + apply_directional_order!(query, order.direction, order, biography_id) + } + BiographyField::ContributionId => { + apply_directional_order!(query, order.direction, order, contribution_id) + } + BiographyField::Content => { + apply_directional_order!(query, order.direction, order, content) + } + BiographyField::Canonical => { + apply_directional_order!(query, order.direction, order, canonical) + } + BiographyField::LocaleCode => { + apply_directional_order!(query, order.direction, order, locale_code) + } + }; + + if let Some(filter) = filter { + query = query.filter(content.ilike(format!("%{filter}%"))); + } + + if let Some(pid) = parent_id_1 { + query = query.filter(contribution_id.eq(pid)); + } + + if !locale_codes.is_empty() { + query = query.filter(locale_code.eq_any(&locale_codes)); + } + + query + .limit(limit.into()) + .offset(offset.into()) + .load::<Biography>(&mut connection) + .map_err(Into::into) + } + + fn count( + db: &crate::db::PgPool, + filter: Option<String>, + _: Vec<Uuid>, + _: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<i32> { + use crate::schema::biography::dsl::*; + let mut connection = db.get()?; + let mut query = biography.into_boxed(); + + if let Some(filter) = filter { + query = query.filter(content.ilike(format!("%{filter}%"))); + } + + query + .count() + .get_result::<i64>(&mut connection) + .map(|t| t.to_string().parse::<i32>().unwrap()) + .map_err(Into::into) + } + + crud_methods!(biography::table, biography::dsl::biography); +} + +publisher_id_impls!(Biography, NewBiography, PatchBiography, |s, db| { + let contribution = crate::model::contribution::Contribution::from_id(db, &s.contribution_id)?; + let work = crate::model::work::Work::from_id(db, &contribution.work_id)?; + <crate::model::work::Work as PublisherId>::publisher_id(&work, db) +}); + +impl HistoryEntry for Biography { + type NewHistoryEntity = NewBiographyHistory; + + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { + Self::NewHistoryEntity { + biography_id: self.biography_id, + user_id: user_id.to_string(), + data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), + } + } +} + +impl DbInsert for NewBiographyHistory { + type MainEntity = BiographyHistory; + + db_insert!(biography_history::table); +} diff --git a/thoth-api/src/model/biography/mod.rs b/thoth-api/src/model/biography/mod.rs new file mode 100644 index 00000000..debcf712 --- /dev/null +++ b/thoth-api/src/model/biography/mod.rs @@ -0,0 +1,110 @@ +use crate::model::locale::LocaleCode; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::graphql::types::inputs::Direction; + +#[cfg(feature = "backend")] +use crate::schema::biography; +#[cfg(feature = "backend")] +use crate::schema::biography_history; + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLEnum), + graphql(description = "Field to use when sorting biography list") +)] +pub enum BiographyField { + BiographyId, + ContributionId, + Content, + Canonical, + LocaleCode, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject), + graphql(description = "Field and order to use when sorting biography list") +)] +pub struct BiographyOrderBy { + pub field: BiographyField, + pub direction: Direction, +} + +impl Default for BiographyOrderBy { + fn default() -> Self { + Self { + field: BiographyField::Canonical, + direction: Direction::Desc, + } + } +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Biography { + pub biography_id: Uuid, + pub contribution_id: Uuid, + pub content: String, + pub canonical: bool, + pub locale_code: LocaleCode, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::Insertable, Clone), + graphql(description = "Set of values required to define a new work's biography"), + diesel(table_name = biography) +)] +#[derive(Default)] +pub struct NewBiography { + pub contribution_id: Uuid, + pub content: String, + pub canonical: bool, + pub locale_code: LocaleCode, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::AsChangeset, Clone), + graphql(description = "Set of values required to update an existing work's biography"), + diesel(table_name = biography, treat_none_as_null = true) +)] +pub struct PatchBiography { + pub biography_id: Uuid, + pub contribution_id: Uuid, + pub content: String, + pub canonical: bool, + pub locale_code: LocaleCode, +} + +#[cfg_attr( + feature = "backend", + derive(diesel::Insertable), + diesel(table_name = biography_history) +)] +pub struct NewBiographyHistory { + pub biography_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +pub struct BiographyHistory { + pub biography_history_id: Uuid, + pub biography_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, + pub timestamp: chrono::DateTime<chrono::Utc>, +} + +#[cfg(feature = "backend")] +pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::BiographyPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/biography/policy.rs b/thoth-api/src/model/biography/policy.rs new file mode 100644 index 00000000..f2b2306d --- /dev/null +++ b/thoth-api/src/model/biography/policy.rs @@ -0,0 +1,70 @@ +use diesel::dsl::{exists, select}; +use diesel::prelude::*; +use uuid::Uuid; + +use crate::markup::MarkupFormat; +use crate::model::biography::{Biography, NewBiography, PatchBiography}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use crate::schema::biography; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Biography`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +/// - requiring a markup format for biography writes +pub struct BiographyPolicy; + +fn has_canonical_biography(db: &crate::db::PgPool, contribution_id: &Uuid) -> ThothResult<bool> { + let mut connection = db.get()?; + let query = biography::table + .filter(biography::contribution_id.eq(contribution_id)) + .filter(biography::canonical.eq(true)); + + let result: bool = select(exists(query)).get_result(&mut connection)?; + Ok(result) +} + +impl CreatePolicy<NewBiography, Option<MarkupFormat>> for BiographyPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewBiography, + markup: Option<MarkupFormat>, + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + + // Biography creation requires a markup format. + markup.ok_or(ThothError::MissingMarkupFormat)?; + + if data.canonical && has_canonical_biography(ctx.db(), &data.contribution_id)? { + return Err(ThothError::CanonicalBiographyExistsError); + } + + Ok(()) + } +} + +impl UpdatePolicy<Biography, PatchBiography, Option<MarkupFormat>> for BiographyPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Biography, + patch: &PatchBiography, + markup: Option<MarkupFormat>, + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + // Biography updates require a markup format. + markup.ok_or(ThothError::MissingMarkupFormat)?; + + Ok(()) + } +} + +impl DeletePolicy<Biography> for BiographyPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Biography) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/biography/tests.rs b/thoth-api/src/model/biography/tests.rs new file mode 100644 index 00000000..5950a802 --- /dev/null +++ b/thoth-api/src/model/biography/tests.rs @@ -0,0 +1,692 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_new_biography( + contribution_id: Uuid, + content: impl Into<String>, + canonical: bool, + locale_code: LocaleCode, +) -> NewBiography { + NewBiography { + contribution_id, + content: content.into(), + canonical, + locale_code, + } +} + +fn make_patch_biography( + biography: &Biography, + content: impl Into<String>, + canonical: bool, +) -> PatchBiography { + PatchBiography { + biography_id: biography.biography_id, + contribution_id: biography.contribution_id, + content: content.into(), + canonical, + locale_code: biography.locale_code, + } +} + +fn make_biography( + pool: &crate::db::PgPool, + contribution_id: Uuid, + content: String, + locale_code: LocaleCode, +) -> Biography { + let new_biography = make_new_biography(contribution_id, content, false, locale_code); + + Biography::create(pool, &new_biography).expect("Failed to create biography") +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::markup::MarkupFormat; + use crate::model::biography::policy::BiographyPolicy; + use crate::model::tests::db::{ + create_contribution, create_contributor, create_imprint, create_publisher, create_work, + setup_test_db, test_context_with_user, test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + use thoth_errors::ThothError; + + #[test] + fn crud_policy_allows_publisher_user_with_markup() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("biography-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let new_biography = make_new_biography( + contribution.contribution_id, + "Policy Biography", + false, + LocaleCode::En, + ); + + let biography = Biography::create(pool.as_ref(), &new_biography).expect("Failed to create"); + let patch = + make_patch_biography(&biography, "Updated Policy Biography", biography.canonical); + + assert!( + BiographyPolicy::can_create(&ctx, &new_biography, Some(MarkupFormat::Html)).is_ok() + ); + assert!( + BiographyPolicy::can_update(&ctx, &biography, &patch, Some(MarkupFormat::Html)).is_ok() + ); + assert!(BiographyPolicy::can_delete(&ctx, &biography).is_ok()); + } + + #[test] + fn crud_policy_requires_markup_format() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("biography-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let new_biography = make_new_biography( + contribution.contribution_id, + "Policy Biography", + false, + LocaleCode::En, + ); + + let biography = Biography::create(pool.as_ref(), &new_biography).expect("Failed to create"); + let patch = + make_patch_biography(&biography, "Updated Policy Biography", biography.canonical); + + assert!(BiographyPolicy::can_create(&ctx, &new_biography, None).is_err()); + assert!(BiographyPolicy::can_update(&ctx, &biography, &patch, None).is_err()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let biography_item = make_biography( + pool.as_ref(), + contribution.contribution_id, + "Policy Biography".to_string(), + LocaleCode::En, + ); + let patch = make_patch_biography( + &biography_item, + "Updated Policy Biography", + biography_item.canonical, + ); + + let user = test_user_with_role("biography-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_biography = make_new_biography( + contribution.contribution_id, + "Policy Biography", + false, + LocaleCode::En, + ); + + assert!( + BiographyPolicy::can_create(&ctx, &new_biography, Some(MarkupFormat::Html)).is_err() + ); + assert!(BiographyPolicy::can_update( + &ctx, + &biography_item, + &patch, + Some(MarkupFormat::Html) + ) + .is_err()); + assert!(BiographyPolicy::can_delete(&ctx, &biography_item).is_err()); + } + + #[test] + fn crud_policy_rejects_duplicate_canonical_biography() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("biography-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + + let canonical = make_new_biography( + contribution.contribution_id, + "Canonical Biography", + true, + LocaleCode::En, + ); + Biography::create(pool.as_ref(), &canonical).expect("Failed to create canonical biography"); + + let new_biography = make_new_biography( + contribution.contribution_id, + "Second Canonical", + true, + LocaleCode::En, + ); + + let result = BiographyPolicy::can_create(&ctx, &new_biography, Some(MarkupFormat::Html)); + + assert!(matches!( + result, + Err(ThothError::CanonicalBiographyExistsError) + )); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::model::tests::db::{ + create_contribution, create_contributor, create_imprint, create_publisher, create_work, + setup_test_db, test_context, + }; + use crate::model::Crud; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + + let new_biography = make_new_biography( + contribution.contribution_id, + format!("Biography {}", Uuid::new_v4()), + false, + LocaleCode::En, + ); + + let biography = + Biography::create(pool.as_ref(), &new_biography).expect("Failed to create biography"); + let fetched = + Biography::from_id(pool.as_ref(), &biography.biography_id).expect("Failed to fetch"); + assert_eq!(biography.biography_id, fetched.biography_id); + + let patch = make_patch_biography(&biography, format!("Updated {}", Uuid::new_v4()), true); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = biography + .update(&ctx, &patch) + .expect("Failed to update biography"); + assert_eq!(updated.content, patch.content); + + let deleted = updated + .delete(pool.as_ref()) + .expect("Failed to delete biography"); + assert!(Biography::from_id(pool.as_ref(), &deleted.biography_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + + make_biography( + pool.as_ref(), + contribution.contribution_id, + format!("Biography {}", Uuid::new_v4()), + LocaleCode::En, + ); + make_biography( + pool.as_ref(), + contribution.contribution_id, + format!("Biography {}", Uuid::new_v4()), + LocaleCode::Fr, + ); + + let order = BiographyOrderBy { + field: BiographyField::BiographyId, + direction: Direction::Asc, + }; + + let first = Biography::all( + pool.as_ref(), + 1, + 0, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch biographies"); + let second = Biography::all( + pool.as_ref(), + 1, + 1, + None, + BiographyOrderBy { + field: BiographyField::BiographyId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch biographies"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].biography_id, second[0].biography_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + + make_biography( + pool.as_ref(), + contribution.contribution_id, + format!("Biography {}", Uuid::new_v4()), + LocaleCode::En, + ); + make_biography( + pool.as_ref(), + contribution.contribution_id, + format!("Biography {}", Uuid::new_v4()), + LocaleCode::Fr, + ); + + let count = Biography::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count biographies"); + assert_eq!(count, 2); + } + + #[test] + fn crud_filter_matches_content() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + + let marker = format!("Filter {}", Uuid::new_v4()); + let matches = make_biography( + pool.as_ref(), + contribution.contribution_id, + format!("Biography {marker}"), + LocaleCode::En, + ); + make_biography( + pool.as_ref(), + contribution.contribution_id, + "Other biography".to_string(), + LocaleCode::Fr, + ); + + let filtered = Biography::all( + pool.as_ref(), + 10, + 0, + Some(marker), + BiographyOrderBy { + field: BiographyField::BiographyId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter biographies"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].biography_id, matches.biography_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + + let first = make_biography( + pool.as_ref(), + contribution.contribution_id, + format!("Biography {}", Uuid::new_v4()), + LocaleCode::En, + ); + let second = make_biography( + pool.as_ref(), + contribution.contribution_id, + format!("Biography {}", Uuid::new_v4()), + LocaleCode::Fr, + ); + let mut ids = [first.biography_id, second.biography_id]; + ids.sort(); + + let asc = Biography::all( + pool.as_ref(), + 2, + 0, + None, + BiographyOrderBy { + field: BiographyField::BiographyId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order biographies (asc)"); + + let desc = Biography::all( + pool.as_ref(), + 2, + 0, + None, + BiographyOrderBy { + field: BiographyField::BiographyId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order biographies (desc)"); + + assert_eq!(asc[0].biography_id, ids[0]); + assert_eq!(desc[0].biography_id, ids[1]); + } + + #[test] + fn crud_canonical_from_contribution_id_returns_biography() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + + let biography = Biography::create( + pool.as_ref(), + &make_new_biography( + contribution.contribution_id, + "Canonical biography", + true, + LocaleCode::En, + ), + ) + .expect("Failed to create biography"); + + let fetched = + Biography::canonical_from_contribution_id(pool.as_ref(), &contribution.contribution_id) + .expect("Failed to fetch canonical biography"); + + assert_eq!(fetched.biography_id, biography.biography_id); + } + + #[test] + fn crud_filter_parent_contribution_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let other_contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + let other_contribution = + create_contribution(pool.as_ref(), &other_work, &other_contributor); + + let matches = make_biography( + pool.as_ref(), + contribution.contribution_id, + "Biography A".to_string(), + LocaleCode::En, + ); + make_biography( + pool.as_ref(), + other_contribution.contribution_id, + "Biography B".to_string(), + LocaleCode::Fr, + ); + + let filtered = Biography::all( + pool.as_ref(), + 10, + 0, + None, + BiographyOrderBy { + field: BiographyField::BiographyId, + direction: Direction::Asc, + }, + vec![], + Some(contribution.contribution_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter biographies by contribution"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].biography_id, matches.biography_id); + } + + #[test] + fn crud_filter_param_limits_locale_codes() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + + let matches = make_biography( + pool.as_ref(), + contribution.contribution_id, + "Biography EN".to_string(), + LocaleCode::En, + ); + make_biography( + pool.as_ref(), + contribution.contribution_id, + "Biography FR".to_string(), + LocaleCode::Fr, + ); + + let filtered = Biography::all( + pool.as_ref(), + 10, + 0, + None, + BiographyOrderBy { + field: BiographyField::BiographyId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![LocaleCode::En], + vec![], + None, + None, + ) + .expect("Failed to filter biographies by locale"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].biography_id, matches.biography_id); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + + Biography::create( + pool.as_ref(), + &make_new_biography( + contribution.contribution_id, + "Biography A", + true, + LocaleCode::En, + ), + ) + .expect("Failed to create biography"); + Biography::create( + pool.as_ref(), + &make_new_biography( + contribution.contribution_id, + "Biography B", + false, + LocaleCode::Fr, + ), + ) + .expect("Failed to create biography"); + + let fields: Vec<fn() -> BiographyField> = vec![ + || BiographyField::BiographyId, + || BiographyField::ContributionId, + || BiographyField::Content, + || BiographyField::Canonical, + || BiographyField::LocaleCode, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Biography::all( + pool.as_ref(), + 10, + 0, + None, + BiographyOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order biographies"); + + assert_eq!(results.len(), 2); + } + } + } + + #[test] + fn crud_count_with_filter_matches_content() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + + let marker = format!("Marker {}", Uuid::new_v4()); + make_biography( + pool.as_ref(), + contribution.contribution_id, + format!("Biography {marker}"), + LocaleCode::En, + ); + make_biography( + pool.as_ref(), + contribution.contribution_id, + "Other biography".to_string(), + LocaleCode::Fr, + ); + + let count = Biography::count( + pool.as_ref(), + Some(marker), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count filtered biographies"); + + assert_eq!(count, 1); + } +} diff --git a/thoth-api/src/model/book_review/crud.rs b/thoth-api/src/model/book_review/crud.rs new file mode 100644 index 00000000..cf26a223 --- /dev/null +++ b/thoth-api/src/model/book_review/crud.rs @@ -0,0 +1,200 @@ +use super::{ + BookReview, BookReviewField, BookReviewHistory, BookReviewOrderBy, NewBookReview, + NewBookReviewHistory, PatchBookReview, +}; +use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; +use crate::schema::{book_review, book_review_history}; +use diesel::{ + BoolExpressionMethods, Connection, ExpressionMethods, PgTextExpressionMethods, QueryDsl, + RunQueryDsl, +}; +use thoth_errors::ThothResult; +use uuid::Uuid; + +impl Crud for BookReview { + type NewEntity = NewBookReview; + type PatchEntity = PatchBookReview; + type OrderByEntity = BookReviewOrderBy; + type FilterParameter1 = (); + type FilterParameter2 = (); + type FilterParameter3 = (); + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.book_review_id + } + + fn all( + db: &crate::db::PgPool, + limit: i32, + offset: i32, + filter: Option<String>, + order: Self::OrderByEntity, + publishers: Vec<Uuid>, + parent_id_1: Option<Uuid>, + _: Option<Uuid>, + _: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<Vec<BookReview>> { + use crate::schema::book_review::dsl::*; + let mut connection = db.get()?; + let mut query = book_review + .inner_join(crate::schema::work::table.inner_join(crate::schema::imprint::table)) + .select(crate::schema::book_review::all_columns) + .into_boxed(); + + query = match order.field { + BookReviewField::BookReviewId => { + apply_directional_order!(query, order.direction, order, book_review_id) + } + BookReviewField::WorkId => { + apply_directional_order!(query, order.direction, order, work_id) + } + BookReviewField::ReviewOrdinal => { + apply_directional_order!(query, order.direction, order, review_ordinal) + } + BookReviewField::Title => { + apply_directional_order!(query, order.direction, order, title) + } + BookReviewField::AuthorName => { + apply_directional_order!(query, order.direction, order, author_name) + } + BookReviewField::JournalName => { + apply_directional_order!(query, order.direction, order, journal_name) + } + BookReviewField::ReviewDate => { + apply_directional_order!(query, order.direction, order, review_date) + } + BookReviewField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + BookReviewField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } + }; + + if !publishers.is_empty() { + query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); + } + if let Some(pid) = parent_id_1 { + query = query.filter(work_id.eq(pid)); + } + if let Some(filter) = filter { + if !filter.is_empty() { + query = query.filter( + title + .ilike(format!("%{filter}%")) + .or(author_name.ilike(format!("%{filter}%"))) + .or(reviewer_orcid.ilike(format!("%{filter}%"))) + .or(journal_name.ilike(format!("%{filter}%"))) + .or(journal_volume.ilike(format!("%{filter}%"))) + .or(journal_number.ilike(format!("%{filter}%"))) + .or(journal_issn.ilike(format!("%{filter}%"))) + .or(page_range.ilike(format!("%{filter}%"))) + .or(text.ilike(format!("%{filter}%"))) + .or(doi.ilike(format!("%{filter}%"))) + .or(url.ilike(format!("%{filter}%"))), + ); + } + } + + query + .limit(limit.into()) + .offset(offset.into()) + .load::<BookReview>(&mut connection) + .map_err(Into::into) + } + + fn count( + db: &crate::db::PgPool, + filter: Option<String>, + publishers: Vec<Uuid>, + _: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<i32> { + use crate::schema::book_review::dsl::*; + let mut connection = db.get()?; + let mut query = book_review + .inner_join(crate::schema::work::table.inner_join(crate::schema::imprint::table)) + .into_boxed(); + + if !publishers.is_empty() { + query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); + } + if let Some(filter) = filter { + if !filter.is_empty() { + query = query.filter( + title + .ilike(format!("%{filter}%")) + .or(author_name.ilike(format!("%{filter}%"))) + .or(reviewer_orcid.ilike(format!("%{filter}%"))) + .or(journal_name.ilike(format!("%{filter}%"))) + .or(journal_volume.ilike(format!("%{filter}%"))) + .or(journal_number.ilike(format!("%{filter}%"))) + .or(journal_issn.ilike(format!("%{filter}%"))) + .or(page_range.ilike(format!("%{filter}%"))) + .or(text.ilike(format!("%{filter}%"))) + .or(doi.ilike(format!("%{filter}%"))) + .or(url.ilike(format!("%{filter}%"))), + ); + } + } + + query + .count() + .get_result::<i64>(&mut connection) + .map(|t| t.to_string().parse::<i32>().unwrap()) + .map_err(Into::into) + } + + crud_methods!(book_review::table, book_review::dsl::book_review); +} + +publisher_id_impls!(BookReview, NewBookReview, PatchBookReview, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + +impl HistoryEntry for BookReview { + type NewHistoryEntity = NewBookReviewHistory; + + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { + Self::NewHistoryEntity { + book_review_id: self.book_review_id, + user_id: user_id.to_string(), + data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), + } + } +} + +impl DbInsert for NewBookReviewHistory { + type MainEntity = BookReviewHistory; + + db_insert!(book_review_history::table); +} + +impl Reorder for BookReview { + db_change_ordinal!( + book_review::table, + book_review::review_ordinal, + "book_review_review_ordinal_work_id_uniq" + ); + + fn get_other_objects( + &self, + connection: &mut diesel::PgConnection, + ) -> ThothResult<Vec<(Uuid, i32)>> { + book_review::table + .select((book_review::book_review_id, book_review::review_ordinal)) + .filter( + book_review::work_id + .eq(self.work_id) + .and(book_review::book_review_id.ne(self.book_review_id)), + ) + .load::<(Uuid, i32)>(connection) + .map_err(Into::into) + } +} diff --git a/thoth-api/src/model/book_review/mod.rs b/thoth-api/src/model/book_review/mod.rs new file mode 100644 index 00000000..643a2dd4 --- /dev/null +++ b/thoth-api/src/model/book_review/mod.rs @@ -0,0 +1,143 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::graphql::types::inputs::Direction; +use crate::model::{Doi, Orcid, Timestamp}; +#[cfg(feature = "backend")] +use crate::schema::book_review; +#[cfg(feature = "backend")] +use crate::schema::book_review_history; + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLEnum), + graphql(description = "Field to use when sorting book reviews list") +)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum BookReviewField { + BookReviewId, + WorkId, + #[default] + ReviewOrdinal, + Title, + AuthorName, + JournalName, + ReviewDate, + CreatedAt, + UpdatedAt, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BookReview { + pub book_review_id: Uuid, + pub work_id: Uuid, + pub title: Option<String>, + pub author_name: Option<String>, + pub reviewer_orcid: Option<Orcid>, + pub reviewer_institution_id: Option<Uuid>, + pub url: Option<String>, + pub doi: Option<Doi>, + pub review_date: Option<NaiveDate>, + pub journal_name: Option<String>, + pub journal_volume: Option<String>, + pub journal_number: Option<String>, + pub journal_issn: Option<String>, + pub page_range: Option<String>, + pub text: Option<String>, + pub review_ordinal: i32, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::Insertable), + graphql(description = "Set of values required to define a new book review linked to a work"), + diesel(table_name = book_review) +)] +pub struct NewBookReview { + pub work_id: Uuid, + pub title: Option<String>, + pub author_name: Option<String>, + pub reviewer_orcid: Option<Orcid>, + pub reviewer_institution_id: Option<Uuid>, + pub url: Option<String>, + pub doi: Option<Doi>, + pub review_date: Option<NaiveDate>, + pub journal_name: Option<String>, + pub journal_volume: Option<String>, + pub journal_number: Option<String>, + pub journal_issn: Option<String>, + pub page_range: Option<String>, + pub text: Option<String>, + pub review_ordinal: i32, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::AsChangeset), + graphql(description = "Set of values required to update an existing book review"), + diesel(table_name = book_review, treat_none_as_null = true) +)] +pub struct PatchBookReview { + pub book_review_id: Uuid, + pub work_id: Uuid, + pub title: Option<String>, + pub author_name: Option<String>, + pub reviewer_orcid: Option<Orcid>, + pub reviewer_institution_id: Option<Uuid>, + pub url: Option<String>, + pub doi: Option<Doi>, + pub review_date: Option<NaiveDate>, + pub journal_name: Option<String>, + pub journal_volume: Option<String>, + pub journal_number: Option<String>, + pub journal_issn: Option<String>, + pub page_range: Option<String>, + pub text: Option<String>, + pub review_ordinal: i32, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +pub struct BookReviewHistory { + pub book_review_history_id: Uuid, + pub book_review_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, + pub timestamp: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(diesel::Insertable), + diesel(table_name = book_review_history) +)] +pub struct NewBookReviewHistory { + pub book_review_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject), + graphql(description = "Field and order to use when sorting book reviews list") +)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct BookReviewOrderBy { + pub field: BookReviewField, + pub direction: Direction, +} + +#[cfg(feature = "backend")] +pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::BookReviewPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/book_review/policy.rs b/thoth-api/src/model/book_review/policy.rs new file mode 100644 index 00000000..09c6deea --- /dev/null +++ b/thoth-api/src/model/book_review/policy.rs @@ -0,0 +1,54 @@ +use crate::model::book_review::{BookReview, NewBookReview, PatchBookReview}; +use crate::model::work::{Work, WorkType}; +use crate::model::Crud; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `BookReview`. +/// +/// These policies enforce publisher scoping and prevent attachment to chapter records. +pub struct BookReviewPolicy; + +fn ensure_work_is_book(db: &crate::db::PgPool, work_id: uuid::Uuid) -> ThothResult<()> { + let work = Work::from_id(db, &work_id)?; + if work.work_type == WorkType::BookChapter { + Err(ThothError::ChapterBookMetadataError) + } else { + Ok(()) + } +} + +impl CreatePolicy<NewBookReview> for BookReviewPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewBookReview, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + ensure_work_is_book(ctx.db(), data.work_id) + } +} + +impl UpdatePolicy<BookReview, PatchBookReview> for BookReviewPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &BookReview, + patch: &PatchBookReview, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + ensure_work_is_book(ctx.db(), current.work_id)?; + ensure_work_is_book(ctx.db(), patch.work_id) + } +} + +impl DeletePolicy<BookReview> for BookReviewPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &BookReview) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ensure_work_is_book(ctx.db(), current.work_id) + } +} + +impl MovePolicy<BookReview> for BookReviewPolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &BookReview) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ensure_work_is_book(ctx.db(), current.work_id) + } +} diff --git a/thoth-api/src/model/book_review/tests.rs b/thoth-api/src/model/book_review/tests.rs new file mode 100644 index 00000000..192841bd --- /dev/null +++ b/thoth-api/src/model/book_review/tests.rs @@ -0,0 +1,377 @@ +use super::*; +use crate::model::Crud; +use std::str::FromStr; +use uuid::Uuid; + +fn make_book_review(pool: &crate::db::PgPool, work_id: Uuid, review_ordinal: i32) -> BookReview { + let data = NewBookReview { + work_id, + title: Some("Review title".to_string()), + author_name: Some("Reviewer".to_string()), + reviewer_orcid: Some( + crate::model::Orcid::from_str("https://orcid.org/0000-0002-1234-5678").unwrap(), + ), + reviewer_institution_id: None, + url: Some("https://example.com/review".to_string()), + doi: Some(crate::model::Doi::from_str("https://doi.org/10.1234/REVIEW.1").unwrap()), + review_date: chrono::NaiveDate::from_ymd_opt(2025, 1, 1), + journal_name: Some("Journal".to_string()), + journal_volume: Some("12".to_string()), + journal_number: Some("3".to_string()), + journal_issn: Some("1234-5678".to_string()), + page_range: Some("10-12".to_string()), + text: Some("Review text".to_string()), + review_ordinal, + }; + + BookReview::create(pool, &data).expect("Failed to create book review") +} + +mod defaults { + use super::*; + + #[test] + fn bookreviewfield_default_is_review_ordinal() { + let field: BookReviewField = Default::default(); + assert_eq!(field, BookReviewField::ReviewOrdinal); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let review: BookReview = Default::default(); + assert_eq!(review.pk(), review.book_review_id); + } + + #[test] + fn history_entry_serializes_model() { + let review: BookReview = Default::default(); + let user_id = "123456".to_string(); + let history = review.new_history_entry(&user_id); + assert_eq!(history.book_review_id, review.book_review_id); + assert_eq!(history.user_id, user_id); + assert_eq!( + history.data, + serde_json::Value::String(serde_json::to_string(&review).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::book_review::policy::BookReviewPolicy; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::work::{NewWork, Work, WorkStatus, WorkType}; + use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("review-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let data = NewBookReview { + work_id: work.work_id, + title: Some("Review title".to_string()), + author_name: Some("Reviewer".to_string()), + reviewer_orcid: None, + reviewer_institution_id: None, + url: Some("https://example.com/review".to_string()), + doi: None, + review_date: chrono::NaiveDate::from_ymd_opt(2025, 1, 1), + journal_name: Some("Journal".to_string()), + journal_volume: Some("12".to_string()), + journal_number: Some("3".to_string()), + journal_issn: Some("1234-5678".to_string()), + page_range: None, + text: Some("Review text".to_string()), + review_ordinal: 1, + }; + + let review = BookReview::create(pool.as_ref(), &data).expect("Failed to create"); + let patch = PatchBookReview { + book_review_id: review.book_review_id, + work_id: review.work_id, + title: review.title.clone(), + author_name: review.author_name.clone(), + reviewer_orcid: review.reviewer_orcid.clone(), + reviewer_institution_id: review.reviewer_institution_id, + url: review.url.clone(), + doi: review.doi.clone(), + review_date: review.review_date, + journal_name: review.journal_name.clone(), + journal_volume: review.journal_volume.clone(), + journal_number: review.journal_number.clone(), + journal_issn: review.journal_issn.clone(), + page_range: review.page_range.clone(), + text: Some("Updated review text".to_string()), + review_ordinal: 1, + }; + + assert!(BookReviewPolicy::can_create(&ctx, &data, ()).is_ok()); + assert!(BookReviewPolicy::can_update(&ctx, &review, &patch, ()).is_ok()); + assert!(BookReviewPolicy::can_delete(&ctx, &review).is_ok()); + assert!(BookReviewPolicy::can_move(&ctx, &review).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let review = make_book_review(pool.as_ref(), work.work_id, 1); + + let patch = PatchBookReview { + book_review_id: review.book_review_id, + work_id: review.work_id, + title: review.title.clone(), + author_name: review.author_name.clone(), + reviewer_orcid: review.reviewer_orcid.clone(), + reviewer_institution_id: review.reviewer_institution_id, + url: review.url.clone(), + doi: review.doi.clone(), + review_date: review.review_date, + journal_name: review.journal_name.clone(), + journal_volume: review.journal_volume.clone(), + journal_number: review.journal_number.clone(), + journal_issn: review.journal_issn.clone(), + page_range: review.page_range.clone(), + text: Some("Updated review text".to_string()), + review_ordinal: 2, + }; + + let user = test_user_with_role("review-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let data = NewBookReview { + work_id: work.work_id, + title: Some("Review title".to_string()), + author_name: Some("Reviewer".to_string()), + reviewer_orcid: None, + reviewer_institution_id: None, + url: Some("https://example.com/review".to_string()), + doi: None, + review_date: chrono::NaiveDate::from_ymd_opt(2025, 1, 1), + journal_name: Some("Journal".to_string()), + journal_volume: Some("12".to_string()), + journal_number: Some("3".to_string()), + journal_issn: Some("1234-5678".to_string()), + page_range: None, + text: Some("Review text".to_string()), + review_ordinal: 1, + }; + + assert!(BookReviewPolicy::can_create(&ctx, &data, ()).is_err()); + assert!(BookReviewPolicy::can_update(&ctx, &review, &patch, ()).is_err()); + assert!(BookReviewPolicy::can_delete(&ctx, &review).is_err()); + assert!(BookReviewPolicy::can_move(&ctx, &review).is_err()); + } + + #[test] + fn crud_policy_rejects_chapter_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("review-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let chapter = Work::create( + pool.as_ref(), + &NewWork { + work_type: WorkType::BookChapter, + work_status: WorkStatus::Forthcoming, + reference: None, + edition: None, + imprint_id: imprint.imprint_id, + doi: None, + publication_date: None, + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: Some("1".to_string()), + last_page: Some("10".to_string()), + page_interval: Some("1-10".to_string()), + }, + ) + .expect("Failed to create chapter work"); + + let data = NewBookReview { + work_id: chapter.work_id, + title: Some("Review title".to_string()), + author_name: Some("Reviewer".to_string()), + reviewer_orcid: None, + reviewer_institution_id: None, + url: Some("https://example.com/review".to_string()), + doi: None, + review_date: chrono::NaiveDate::from_ymd_opt(2025, 1, 1), + journal_name: Some("Journal".to_string()), + journal_volume: Some("12".to_string()), + journal_number: Some("3".to_string()), + journal_issn: Some("1234-5678".to_string()), + page_range: None, + text: Some("Review text".to_string()), + review_ordinal: 1, + }; + + assert!(matches!( + BookReviewPolicy::can_create(&ctx, &data, ()), + Err(thoth_errors::ThothError::ChapterBookMetadataError) + )); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::model::tests::db::{ + create_imprint, create_institution, create_publisher, create_work, setup_test_db, + test_context, + }; + use crate::model::Reorder; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let review = make_book_review(pool.as_ref(), work.work_id, 1); + let fetched = + BookReview::from_id(pool.as_ref(), &review.book_review_id).expect("Failed to fetch"); + assert_eq!(review.book_review_id, fetched.book_review_id); + + let patch = PatchBookReview { + book_review_id: review.book_review_id, + work_id: review.work_id, + title: review.title.clone(), + author_name: review.author_name.clone(), + reviewer_orcid: review.reviewer_orcid.clone(), + reviewer_institution_id: review.reviewer_institution_id, + url: review.url.clone(), + doi: review.doi.clone(), + review_date: review.review_date, + journal_name: review.journal_name.clone(), + journal_volume: review.journal_volume.clone(), + journal_number: review.journal_number.clone(), + journal_issn: review.journal_issn.clone(), + page_range: review.page_range.clone(), + text: Some("Updated review text".to_string()), + review_ordinal: 1, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = review.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.text, patch.text); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(BookReview::from_id(pool.as_ref(), &deleted.book_review_id).is_err()); + } + + #[test] + fn deleting_reviewer_institution_nulls_relation() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let institution = create_institution(pool.as_ref()); + + let review = BookReview::create( + pool.as_ref(), + &NewBookReview { + work_id: work.work_id, + title: Some("Review title".to_string()), + author_name: Some("Reviewer".to_string()), + reviewer_orcid: Some( + crate::model::Orcid::from_str("https://orcid.org/0000-0002-1234-5678").unwrap(), + ), + reviewer_institution_id: Some(institution.institution_id), + url: Some("https://example.com/review".to_string()), + doi: None, + review_date: chrono::NaiveDate::from_ymd_opt(2025, 1, 1), + journal_name: Some("Journal".to_string()), + journal_volume: Some("12".to_string()), + journal_number: Some("3".to_string()), + journal_issn: Some("1234-5678".to_string()), + page_range: Some("10-12".to_string()), + text: Some("Review text".to_string()), + review_ordinal: 1, + }, + ) + .expect("Failed to create review"); + + institution + .delete(pool.as_ref()) + .expect("Failed to delete institution"); + + let fetched = BookReview::from_id(pool.as_ref(), &review.book_review_id) + .expect("Failed to fetch review after deleting institution"); + assert_eq!(fetched.book_review_id, review.book_review_id); + assert!(fetched.reviewer_institution_id.is_none()); + } + + #[test] + fn crud_change_ordinal_reorders_within_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let first = make_book_review(pool.as_ref(), work.work_id, 1); + let second = make_book_review(pool.as_ref(), work.work_id, 2); + let ctx = test_context(pool.clone(), "test-user"); + + let moved = second + .change_ordinal(&ctx, second.review_ordinal, 1) + .expect("Failed to reorder book review"); + let shifted = BookReview::from_id(pool.as_ref(), &first.book_review_id) + .expect("Failed to fetch shifted book review"); + + assert_eq!(moved.review_ordinal, 1); + assert_eq!(shifted.review_ordinal, 2); + } +} diff --git a/thoth-api/src/model/contact/crud.rs b/thoth-api/src/model/contact/crud.rs new file mode 100644 index 00000000..df6ce345 --- /dev/null +++ b/thoth-api/src/model/contact/crud.rs @@ -0,0 +1,129 @@ +use super::{ + Contact, ContactField, ContactHistory, ContactOrderBy, ContactType, NewContact, + NewContactHistory, PatchContact, +}; +use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::schema::{contact, contact_history}; +use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; +use thoth_errors::ThothResult; +use uuid::Uuid; + +impl Crud for Contact { + type NewEntity = NewContact; + type PatchEntity = PatchContact; + type OrderByEntity = ContactOrderBy; + type FilterParameter1 = ContactType; + type FilterParameter2 = (); + type FilterParameter3 = (); + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.contact_id + } + + fn all( + db: &crate::db::PgPool, + limit: i32, + offset: i32, + _: Option<String>, + order: Self::OrderByEntity, + publishers: Vec<Uuid>, + parent_id_1: Option<Uuid>, + _: Option<Uuid>, + contact_types: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<Vec<Contact>> { + use crate::schema::contact::dsl::*; + let mut connection = db.get()?; + let mut query = contact.into_boxed(); + + query = match order.field { + ContactField::ContactId => { + apply_directional_order!(query, order.direction, order, contact_id) + } + ContactField::PublisherId => { + apply_directional_order!(query, order.direction, order, publisher_id) + } + ContactField::ContactType => { + apply_directional_order!(query, order.direction, order, contact_type) + } + ContactField::Email => apply_directional_order!(query, order.direction, order, email), + ContactField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + ContactField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } + }; + if !publishers.is_empty() { + query = query.filter(publisher_id.eq_any(publishers)); + } + if !contact_types.is_empty() { + query = query.filter(contact_type.eq_any(contact_types)); + } + if let Some(pid) = parent_id_1 { + query = query.filter(publisher_id.eq(pid)); + } + query + .limit(limit.into()) + .offset(offset.into()) + .load::<Contact>(&mut connection) + .map_err(Into::into) + } + + fn count( + db: &crate::db::PgPool, + _: Option<String>, + publishers: Vec<Uuid>, + contact_types: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<i32> { + use crate::schema::contact::dsl::*; + let mut connection = db.get()?; + let mut query = contact.into_boxed(); + if !publishers.is_empty() { + query = query.filter(publisher_id.eq_any(publishers)); + } + if !contact_types.is_empty() { + query = query.filter(contact_type.eq_any(contact_types)); + } + + // `SELECT COUNT(*)` in postgres returns a BIGINT, which diesel parses as i64. Juniper does + // not implement i64 yet, only i32. The only sensible way, albeit shameful, to solve this + // is converting i64 to string and then parsing it as i32. This should work until we reach + // 2147483647 records - if you are fixing this bug, congratulations on book number 2147483647! + query + .count() + .get_result::<i64>(&mut connection) + .map(|t| t.to_string().parse::<i32>().unwrap()) + .map_err(Into::into) + } + + crud_methods!(contact::table, contact::dsl::contact); +} + +publisher_id_impls!(Contact, NewContact, PatchContact, |s, _db| { + Ok(s.publisher_id) +}); + +impl HistoryEntry for Contact { + type NewHistoryEntity = NewContactHistory; + + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { + Self::NewHistoryEntity { + contact_id: self.contact_id, + user_id: user_id.to_string(), + data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), + } + } +} + +impl DbInsert for NewContactHistory { + type MainEntity = ContactHistory; + + db_insert!(contact_history::table); +} diff --git a/thoth-api/src/model/contact/mod.rs b/thoth-api/src/model/contact/mod.rs new file mode 100644 index 00000000..80921bb1 --- /dev/null +++ b/thoth-api/src/model/contact/mod.rs @@ -0,0 +1,124 @@ +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; +use uuid::Uuid; + +use crate::graphql::types::inputs::Direction; +use crate::model::Timestamp; +#[cfg(feature = "backend")] +use crate::schema::contact; +#[cfg(feature = "backend")] +use crate::schema::contact_history; + +#[cfg_attr( + feature = "backend", + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), + graphql(description = "Type of a contact"), + ExistingTypePath = "crate::schema::sql_types::ContactType" +)] +#[derive( + Debug, Copy, Clone, Default, PartialEq, Eq, Deserialize, Serialize, EnumString, Display, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ContactType { + #[cfg_attr( + feature = "backend", + db_rename = "Accessibility", + graphql(description = "Contact for accessibility queries") + )] + #[default] + Accessibility, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLEnum), + graphql(description = "Field to use when sorting contacts list") +)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, EnumString, Display)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ContactField { + ContactId, + PublisherId, + ContactType, + #[default] + Email, + CreatedAt, + UpdatedAt, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Contact { + pub contact_id: Uuid, + pub publisher_id: Uuid, + pub contact_type: ContactType, + pub email: String, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::Insertable), + graphql(description = "Set of values required to define a new way of getting in touch with a publisher"), + diesel(table_name = contact) +)] +pub struct NewContact { + pub publisher_id: Uuid, + pub contact_type: ContactType, + pub email: String, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::AsChangeset), + graphql(description = "Set of values required to update an existing way of getting in touch with a publisher"), + diesel(table_name = contact, treat_none_as_null = true) +)] +pub struct PatchContact { + pub contact_id: Uuid, + pub publisher_id: Uuid, + pub contact_type: ContactType, + pub email: String, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +pub struct ContactHistory { + pub contact_history_id: Uuid, + pub contact_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, + pub timestamp: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(diesel::Insertable), + diesel(table_name = contact_history) +)] +pub struct NewContactHistory { + pub contact_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject), + graphql(description = "Field and order to use when sorting contacts list") +)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContactOrderBy { + pub field: ContactField, + pub direction: Direction, +} + +#[cfg(feature = "backend")] +pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::ContactPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/contact/policy.rs b/thoth-api/src/model/contact/policy.rs new file mode 100644 index 00000000..dc4bb4fd --- /dev/null +++ b/thoth-api/src/model/contact/policy.rs @@ -0,0 +1,37 @@ +use crate::model::contact::{Contact, NewContact, PatchContact}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Contact`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct ContactPolicy; + +impl CreatePolicy<NewContact> for ContactPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewContact, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Contact, PatchContact> for ContactPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Contact, + patch: &PatchContact, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + Ok(()) + } +} + +impl DeletePolicy<Contact> for ContactPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Contact) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/contact/tests.rs b/thoth-api/src/model/contact/tests.rs new file mode 100644 index 00000000..d33c3484 --- /dev/null +++ b/thoth-api/src/model/contact/tests.rs @@ -0,0 +1,609 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_new_contact( + publisher_id: Uuid, + contact_type: ContactType, + email: impl Into<String>, +) -> NewContact { + NewContact { + publisher_id, + contact_type, + email: email.into(), + } +} + +fn make_patch_contact( + contact: &Contact, + contact_type: ContactType, + email: impl Into<String>, +) -> PatchContact { + PatchContact { + contact_id: contact.contact_id, + publisher_id: contact.publisher_id, + contact_type, + email: email.into(), + } +} + +fn make_contact(pool: &crate::db::PgPool, publisher_id: Uuid, email: String) -> Contact { + let new_contact = make_new_contact(publisher_id, ContactType::Accessibility, email); + + Contact::create(pool, &new_contact).expect("Failed to create contact") +} + +mod defaults { + use super::*; + + #[test] + fn contactfield_default_is_email() { + let contfield: ContactField = Default::default(); + assert_eq!(contfield, ContactField::Email); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn contacttype_display_formats_expected_strings() { + assert_eq!(format!("{}", ContactType::Accessibility), "Accessibility"); + } + + #[test] + fn contacttype_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + ContactType::from_str("Accessibility").unwrap(), + ContactType::Accessibility + ); + assert!(ContactType::from_str("Other").is_err()); + } +} + +#[cfg(feature = "backend")] +mod conversions { + use super::*; + use crate::model::tests::db::setup_test_db; + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn contacttype_graphql_roundtrip() { + assert_graphql_enum_roundtrip(ContactType::Accessibility); + } + + #[test] + fn contacttype_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<ContactType, crate::schema::sql_types::ContactType>( + pool.as_ref(), + "'Accessibility'::contact_type", + ContactType::Accessibility, + ); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let contact: Contact = Default::default(); + assert_eq!(contact.pk(), contact.contact_id); + } + + #[test] + fn history_entry_serializes_model() { + let contact: Contact = Default::default(); + let user_id = "12345"; + let new_contact_history = contact.new_history_entry(user_id); + assert_eq!(new_contact_history.contact_id, contact.contact_id); + assert_eq!(new_contact_history.user_id, user_id); + assert_eq!( + new_contact_history.data, + serde_json::Value::String(serde_json::to_string(&contact).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::contact::policy::ContactPolicy; + use crate::model::tests::db::{ + create_publisher, setup_test_db, test_context_with_user, test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("contact-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let new_contact = make_new_contact( + publisher.publisher_id, + ContactType::Accessibility, + "policy@example.com", + ); + + let contact = Contact::create(pool.as_ref(), &new_contact).expect("Failed to create"); + let patch = make_patch_contact(&contact, contact.contact_type, "policy-update@example.com"); + + assert!(ContactPolicy::can_create(&ctx, &new_contact, ()).is_ok()); + assert!(ContactPolicy::can_update(&ctx, &contact, &patch, ()).is_ok()); + assert!(ContactPolicy::can_delete(&ctx, &contact).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let contact = make_contact( + pool.as_ref(), + publisher.publisher_id, + format!("policy-{}@example.com", Uuid::new_v4()), + ); + let patch = make_patch_contact(&contact, contact.contact_type, "blocked@example.com"); + + let user = test_user_with_role("contact-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_contact = make_new_contact( + publisher.publisher_id, + ContactType::Accessibility, + "policy@example.com", + ); + + assert!(ContactPolicy::can_create(&ctx, &new_contact, ()).is_err()); + assert!(ContactPolicy::can_update(&ctx, &contact, &patch, ()).is_err()); + assert!(ContactPolicy::can_delete(&ctx, &contact).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::model::tests::db::{create_publisher, setup_test_db, test_context}; + use crate::model::Crud; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let new_contact = make_new_contact( + publisher.publisher_id, + ContactType::Accessibility, + "test@example.com", + ); + + let contact = Contact::create(pool.as_ref(), &new_contact).expect("Failed to create"); + let fetched = + Contact::from_id(pool.as_ref(), &contact.contact_id).expect("Failed to fetch"); + assert_eq!(contact.contact_id, fetched.contact_id); + + let patch = make_patch_contact(&contact, contact.contact_type, "updated@example.com"); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = contact.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.email, patch.email); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Contact::from_id(pool.as_ref(), &deleted.contact_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + make_contact( + pool.as_ref(), + publisher.publisher_id, + format!("first-{}@example.com", Uuid::new_v4()), + ); + make_contact( + pool.as_ref(), + other_publisher.publisher_id, + format!("second-{}@example.com", Uuid::new_v4()), + ); + + let order = ContactOrderBy { + field: ContactField::ContactId, + direction: Direction::Asc, + }; + + let first = Contact::all( + pool.as_ref(), + 1, + 0, + None, + order.clone(), + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch contacts"); + let second = Contact::all( + pool.as_ref(), + 1, + 1, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch contacts"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].contact_id, second[0].contact_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + make_contact( + pool.as_ref(), + publisher.publisher_id, + format!("first-{}@example.com", Uuid::new_v4()), + ); + make_contact( + pool.as_ref(), + other_publisher.publisher_id, + format!("second-{}@example.com", Uuid::new_v4()), + ); + + let count = Contact::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count contacts"); + assert_eq!(count, 2); + } + + #[test] + fn crud_count_filters_by_publishers() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + make_contact( + pool.as_ref(), + publisher.publisher_id, + format!("first-{}@example.com", Uuid::new_v4()), + ); + make_contact( + pool.as_ref(), + other_publisher.publisher_id, + format!("second-{}@example.com", Uuid::new_v4()), + ); + + let count = Contact::count( + pool.as_ref(), + None, + vec![publisher.publisher_id], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count contacts by publisher"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_filter_parent_publisher_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + let matches = make_contact( + pool.as_ref(), + publisher.publisher_id, + format!("match-{}@example.com", Uuid::new_v4()), + ); + make_contact( + pool.as_ref(), + other_publisher.publisher_id, + format!("other-{}@example.com", Uuid::new_v4()), + ); + + let filtered = Contact::all( + pool.as_ref(), + 10, + 0, + None, + ContactOrderBy { + field: ContactField::ContactId, + direction: Direction::Asc, + }, + vec![], + Some(publisher.publisher_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter contacts by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].contact_id, matches.contact_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + let first = make_contact( + pool.as_ref(), + publisher.publisher_id, + format!("first-{}@example.com", Uuid::new_v4()), + ); + let second = make_contact( + pool.as_ref(), + other_publisher.publisher_id, + format!("second-{}@example.com", Uuid::new_v4()), + ); + let mut ids = [first.contact_id, second.contact_id]; + ids.sort(); + + let asc = Contact::all( + pool.as_ref(), + 2, + 0, + None, + ContactOrderBy { + field: ContactField::ContactId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order contacts (asc)"); + + let desc = Contact::all( + pool.as_ref(), + 2, + 0, + None, + ContactOrderBy { + field: ContactField::ContactId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order contacts (desc)"); + + assert_eq!(asc[0].contact_id, ids[0]); + assert_eq!(desc[0].contact_id, ids[1]); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + let matches = make_contact( + pool.as_ref(), + publisher.publisher_id, + format!("match-{}@example.com", Uuid::new_v4()), + ); + make_contact( + pool.as_ref(), + other_publisher.publisher_id, + format!("other-{}@example.com", Uuid::new_v4()), + ); + + let filtered = Contact::all( + pool.as_ref(), + 10, + 0, + None, + ContactOrderBy { + field: ContactField::ContactId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter contacts by publishers"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].contact_id, matches.contact_id); + } + + #[test] + fn crud_filter_param_limits_contact_types() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + let matches = Contact::create( + pool.as_ref(), + &make_new_contact( + publisher.publisher_id, + ContactType::Accessibility, + format!("access-{}@example.com", Uuid::new_v4()), + ), + ) + .expect("Failed to create contact"); + Contact::create( + pool.as_ref(), + &make_new_contact( + other_publisher.publisher_id, + ContactType::Accessibility, + format!("request-{}@example.com", Uuid::new_v4()), + ), + ) + .expect("Failed to create contact"); + + let filtered = Contact::all( + pool.as_ref(), + 10, + 0, + None, + ContactOrderBy { + field: ContactField::ContactId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![ContactType::Accessibility], + vec![], + None, + None, + ) + .expect("Failed to filter contacts by type"); + + assert_eq!(filtered.len(), 2); + assert!(filtered + .iter() + .any(|contact| contact.contact_id == matches.contact_id)); + } + + #[test] + fn crud_count_with_filter_matches_contact_type() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + Contact::create( + pool.as_ref(), + &make_new_contact( + publisher.publisher_id, + ContactType::Accessibility, + format!("access-{}@example.com", Uuid::new_v4()), + ), + ) + .expect("Failed to create contact"); + Contact::create( + pool.as_ref(), + &make_new_contact( + other_publisher.publisher_id, + ContactType::Accessibility, + format!("request-{}@example.com", Uuid::new_v4()), + ), + ) + .expect("Failed to create contact"); + + let count = Contact::count( + pool.as_ref(), + None, + vec![], + vec![ContactType::Accessibility], + vec![], + None, + None, + ) + .expect("Failed to count contacts by type"); + + assert_eq!(count, 2); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + Contact::create( + pool.as_ref(), + &make_new_contact( + publisher.publisher_id, + ContactType::Accessibility, + "a@example.com", + ), + ) + .expect("Failed to create contact"); + Contact::create( + pool.as_ref(), + &make_new_contact( + other_publisher.publisher_id, + ContactType::Accessibility, + "b@example.com", + ), + ) + .expect("Failed to create contact"); + + let fields: Vec<fn() -> ContactField> = vec![ + || ContactField::ContactId, + || ContactField::PublisherId, + || ContactField::ContactType, + || ContactField::Email, + || ContactField::CreatedAt, + || ContactField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Contact::all( + pool.as_ref(), + 10, + 0, + None, + ContactOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order contacts"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/contribution/crud.rs b/thoth-api/src/model/contribution/crud.rs index 4f40e7e8..ac9662c0 100644 --- a/thoth-api/src/model/contribution/crud.rs +++ b/thoth-api/src/model/contribution/crud.rs @@ -2,12 +2,13 @@ use super::{ Contribution, ContributionField, ContributionHistory, ContributionType, NewContribution, NewContributionHistory, PatchContribution, }; -use crate::graphql::model::ContributionOrderBy; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::graphql::types::inputs::ContributionOrderBy; +use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; use crate::schema::{contribution, contribution_history}; -use crate::{crud_methods, db_insert}; -use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; +use diesel::{ + BoolExpressionMethods, Connection, ExpressionMethods, NullableExpressionMethods, QueryDsl, + RunQueryDsl, +}; use thoth_errors::ThothResult; use uuid::Uuid; @@ -18,6 +19,7 @@ impl Crud for Contribution { type FilterParameter1 = ContributionType; type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.contribution_id @@ -35,8 +37,10 @@ impl Crud for Contribution { contribution_types: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Contribution>> { use crate::schema::contribution::dsl::*; + let mut connection = db.get()?; let mut query = contribution .inner_join(crate::schema::work::table.inner_join(crate::schema::imprint::table)) @@ -44,54 +48,93 @@ impl Crud for Contribution { .into_boxed(); query = match order.field { - ContributionField::ContributionId => match order.direction { - Direction::Asc => query.order(contribution_id.asc()), - Direction::Desc => query.order(contribution_id.desc()), - }, - ContributionField::WorkId => match order.direction { - Direction::Asc => query.order(work_id.asc()), - Direction::Desc => query.order(work_id.desc()), - }, - ContributionField::ContributorId => match order.direction { - Direction::Asc => query.order(contributor_id.asc()), - Direction::Desc => query.order(contributor_id.desc()), - }, - ContributionField::ContributionType => match order.direction { - Direction::Asc => query.order(contribution_type.asc()), - Direction::Desc => query.order(contribution_type.desc()), - }, - ContributionField::MainContribution => match order.direction { - Direction::Asc => query.order(main_contribution.asc()), - Direction::Desc => query.order(main_contribution.desc()), - }, - ContributionField::Biography => match order.direction { - Direction::Asc => query.order(biography.asc()), - Direction::Desc => query.order(biography.desc()), - }, - ContributionField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - ContributionField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, - ContributionField::FirstName => match order.direction { - Direction::Asc => query.order(first_name.asc()), - Direction::Desc => query.order(first_name.desc()), - }, - ContributionField::LastName => match order.direction { - Direction::Asc => query.order(last_name.asc()), - Direction::Desc => query.order(last_name.desc()), - }, - ContributionField::FullName => match order.direction { - Direction::Asc => query.order(full_name.asc()), - Direction::Desc => query.order(full_name.desc()), - }, - ContributionField::ContributionOrdinal => match order.direction { - Direction::Asc => query.order(contribution_ordinal.asc()), - Direction::Desc => query.order(contribution_ordinal.desc()), - }, + ContributionField::ContributionId => { + apply_directional_order!(query, order.direction, order_by, contribution_id) + } + ContributionField::WorkId => { + apply_directional_order!(query, order.direction, order_by, work_id, contribution_id) + } + ContributionField::ContributorId => apply_directional_order!( + query, + order.direction, + order_by, + contributor_id, + contribution_id + ), + ContributionField::ContributionType => apply_directional_order!( + query, + order.direction, + order_by, + contribution_type, + contribution_id + ), + ContributionField::MainContribution => apply_directional_order!( + query, + order.direction, + order_by, + main_contribution, + contribution_id + ), + ContributionField::Biography => { + let biography_content = crate::schema::biography::table + .select(crate::schema::biography::content.nullable()) + .filter(crate::schema::biography::contribution_id.eq(contribution_id)) + .order(( + crate::schema::biography::canonical.desc(), + crate::schema::biography::biography_id.asc(), + )) + .limit(1) + .single_value(); + apply_directional_order!( + query, + order.direction, + order_by, + biography_content, + contribution_id + ) + } + ContributionField::CreatedAt => apply_directional_order!( + query, + order.direction, + order_by, + created_at, + contribution_id + ), + ContributionField::UpdatedAt => apply_directional_order!( + query, + order.direction, + order_by, + updated_at, + contribution_id + ), + ContributionField::FirstName => apply_directional_order!( + query, + order.direction, + order_by, + first_name, + contribution_id + ), + ContributionField::LastName => apply_directional_order!( + query, + order.direction, + order_by, + last_name, + contribution_id + ), + ContributionField::FullName => apply_directional_order!( + query, + order.direction, + order_by, + full_name, + contribution_id + ), + ContributionField::ContributionOrdinal => apply_directional_order!( + query, + order.direction, + order_by, + contribution_ordinal, + contribution_id + ), }; if !publishers.is_empty() { query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); @@ -119,6 +162,7 @@ impl Crud for Contribution { contribution_types: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::contribution::dsl::*; let mut connection = db.get()?; @@ -138,20 +182,20 @@ impl Crud for Contribution { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } - crud_methods!(contribution::table, contribution::dsl::contribution); } +publisher_id_impls!(Contribution, NewContribution, PatchContribution, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Contribution { type NewHistoryEntity = NewContributionHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { contribution_id: self.contribution_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -163,29 +207,28 @@ impl DbInsert for NewContributionHistory { db_insert!(contribution_history::table); } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_contribution_pk() { - let contribution: Contribution = Default::default(); - assert_eq!(contribution.pk(), contribution.contribution_id); - } +impl Reorder for Contribution { + db_change_ordinal!( + contribution::table, + contribution::contribution_ordinal, + "contribution_contribution_ordinal_work_id_uniq" + ); - #[test] - fn test_new_contribution_history_from_contribution() { - let contribution: Contribution = Default::default(); - let account_id: Uuid = Default::default(); - let new_contribution_history = contribution.new_history_entry(&account_id); - assert_eq!( - new_contribution_history.contribution_id, - contribution.contribution_id - ); - assert_eq!(new_contribution_history.account_id, account_id); - assert_eq!( - new_contribution_history.data, - serde_json::Value::String(serde_json::to_string(&contribution).unwrap()) - ); + fn get_other_objects( + &self, + connection: &mut diesel::PgConnection, + ) -> ThothResult<Vec<(Uuid, i32)>> { + contribution::table + .select(( + contribution::contribution_id, + contribution::contribution_ordinal, + )) + .filter( + contribution::work_id + .eq(self.work_id) + .and(contribution::contribution_id.ne(self.contribution_id)), + ) + .load::<(Uuid, i32)>(connection) + .map_err(Into::into) } } diff --git a/thoth-api/src/model/contribution/mod.rs b/thoth-api/src/model/contribution/mod.rs index bf8265d9..3d25e99b 100644 --- a/thoth-api/src/model/contribution/mod.rs +++ b/thoth-api/src/model/contribution/mod.rs @@ -3,8 +3,6 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::model::affiliation::AffiliationWithInstitution; -use crate::model::work::WorkWithRelations; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::contribution; @@ -13,7 +11,7 @@ use crate::schema::contribution_history; #[cfg_attr( feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), graphql(description = "Role describing the type of contribution to the work"), ExistingTypePath = "crate::schema::sql_types::ContributionType" )] @@ -120,7 +118,7 @@ pub enum ContributionField { ContributionOrdinal, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Contribution { @@ -129,7 +127,6 @@ pub struct Contribution { pub contributor_id: Uuid, pub contribution_type: ContributionType, pub main_contribution: bool, - pub biography: Option<String>, pub created_at: Timestamp, pub updated_at: Timestamp, pub first_name: Option<String>, @@ -137,22 +134,9 @@ pub struct Contribution { pub full_name: String, pub contribution_ordinal: i32, } - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ContributionWithAffiliations { - pub affiliations: Option<Vec<AffiliationWithInstitution>>, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ContributionWithWork { - pub work: WorkWithRelations, -} - #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new individual involvement in the production of a work"), diesel(table_name = contribution) )] @@ -161,7 +145,6 @@ pub struct NewContribution { pub contributor_id: Uuid, pub contribution_type: ContributionType, pub main_contribution: bool, - pub biography: Option<String>, pub first_name: Option<String>, pub last_name: String, pub full_name: String, @@ -170,7 +153,7 @@ pub struct NewContribution { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an individual involvement in the production of a work"), diesel(table_name = contribution, treat_none_as_null = true) )] @@ -180,30 +163,29 @@ pub struct PatchContribution { pub contributor_id: Uuid, pub contribution_type: ContributionType, pub main_contribution: bool, - pub biography: Option<String>, pub first_name: Option<String>, pub last_name: String, pub full_name: String, pub contribution_ordinal: i32, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct ContributionHistory { pub contribution_history_id: Uuid, pub contribution_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = contribution_history) )] pub struct NewContributionHistory { pub contribution_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -215,7 +197,6 @@ impl Default for Contribution { contributor_id: Default::default(), contribution_type: Default::default(), main_contribution: true, - biography: Default::default(), created_at: Default::default(), updated_at: Default::default(), first_name: Default::default(), @@ -226,102 +207,11 @@ impl Default for Contribution { } } -#[test] -fn test_contributiontype_default() { - let contributiontype: ContributionType = Default::default(); - assert_eq!(contributiontype, ContributionType::Author); -} - -#[test] -fn test_contributiontype_display() { - assert_eq!(format!("{}", ContributionType::Author), "Author"); - assert_eq!(format!("{}", ContributionType::Editor), "Editor"); - assert_eq!(format!("{}", ContributionType::Translator), "Translator"); - assert_eq!( - format!("{}", ContributionType::Photographer), - "Photographer" - ); - assert_eq!(format!("{}", ContributionType::Illustrator), "Illustrator"); - assert_eq!(format!("{}", ContributionType::MusicEditor), "Music Editor"); - assert_eq!(format!("{}", ContributionType::ForewordBy), "Foreword By"); - assert_eq!( - format!("{}", ContributionType::IntroductionBy), - "Introduction By" - ); - assert_eq!(format!("{}", ContributionType::AfterwordBy), "Afterword By"); - assert_eq!(format!("{}", ContributionType::PrefaceBy), "Preface By"); - assert_eq!(format!("{}", ContributionType::SoftwareBy), "Software By"); - assert_eq!(format!("{}", ContributionType::ResearchBy), "Research By"); - assert_eq!( - format!("{}", ContributionType::ContributionsBy), - "Contributions By" - ); - assert_eq!(format!("{}", ContributionType::Indexer), "Indexer"); -} - -#[test] -fn test_contributiontype_fromstr() { - use std::str::FromStr; - assert_eq!( - ContributionType::from_str("Author").unwrap(), - ContributionType::Author - ); - assert_eq!( - ContributionType::from_str("Editor").unwrap(), - ContributionType::Editor - ); - assert_eq!( - ContributionType::from_str("Translator").unwrap(), - ContributionType::Translator - ); - assert_eq!( - ContributionType::from_str("Photographer").unwrap(), - ContributionType::Photographer - ); - assert_eq!( - ContributionType::from_str("Illustrator").unwrap(), - ContributionType::Illustrator - ); - assert_eq!( - ContributionType::from_str("Music Editor").unwrap(), - ContributionType::MusicEditor - ); - assert_eq!( - ContributionType::from_str("Foreword By").unwrap(), - ContributionType::ForewordBy - ); - assert_eq!( - ContributionType::from_str("Introduction By").unwrap(), - ContributionType::IntroductionBy - ); - assert_eq!( - ContributionType::from_str("Afterword By").unwrap(), - ContributionType::AfterwordBy - ); - assert_eq!( - ContributionType::from_str("Preface By").unwrap(), - ContributionType::PrefaceBy - ); - assert_eq!( - ContributionType::from_str("Software By").unwrap(), - ContributionType::SoftwareBy - ); - assert_eq!( - ContributionType::from_str("Research By").unwrap(), - ContributionType::ResearchBy - ); - assert_eq!( - ContributionType::from_str("Contributions By").unwrap(), - ContributionType::ContributionsBy - ); - assert_eq!( - ContributionType::from_str("Indexer").unwrap(), - ContributionType::Indexer - ); - - assert!(ContributionType::from_str("Juggler").is_err()); - assert!(ContributionType::from_str("Supervisor").is_err()); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::ContributionPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/contribution/policy.rs b/thoth-api/src/model/contribution/policy.rs new file mode 100644 index 00000000..9d7abdef --- /dev/null +++ b/thoth-api/src/model/contribution/policy.rs @@ -0,0 +1,52 @@ +use crate::model::contribution::{Contribution, NewContribution, PatchContribution}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Contribution`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +/// +/// `Contribution` is scoped to a parent `Work`, and publisher membership is derived from the +/// `PublisherId` implementation (via `work_id`). +pub struct ContributionPolicy; + +impl CreatePolicy<NewContribution> for ContributionPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewContribution, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Contribution, PatchContribution> for ContributionPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Contribution, + patch: &PatchContribution, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy<Contribution> for ContributionPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Contribution) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} + +impl MovePolicy<Contribution> for ContributionPolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &Contribution) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/contribution/tests.rs b/thoth-api/src/model/contribution/tests.rs new file mode 100644 index 00000000..c725ce7b --- /dev/null +++ b/thoth-api/src/model/contribution/tests.rs @@ -0,0 +1,1023 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_new_contribution( + work_id: Uuid, + contributor_id: Uuid, + contribution_type: ContributionType, + contribution_ordinal: i32, +) -> NewContribution { + let suffix = Uuid::new_v4(); + make_new_contribution_with_names( + work_id, + contributor_id, + contribution_type, + contribution_ordinal, + Some("Test".to_string()), + "Contributor", + format!("Test Contributor {suffix}"), + ) +} + +fn make_new_contribution_with_names( + work_id: Uuid, + contributor_id: Uuid, + contribution_type: ContributionType, + contribution_ordinal: i32, + first_name: Option<String>, + last_name: impl Into<String>, + full_name: impl Into<String>, +) -> NewContribution { + NewContribution { + work_id, + contributor_id, + contribution_type, + main_contribution: contribution_ordinal == 1, + first_name, + last_name: last_name.into(), + full_name: full_name.into(), + contribution_ordinal, + } +} + +fn make_patch_contribution( + contribution: &Contribution, + contribution_type: ContributionType, + full_name: impl Into<String>, + contribution_ordinal: i32, +) -> PatchContribution { + PatchContribution { + contribution_id: contribution.contribution_id, + work_id: contribution.work_id, + contributor_id: contribution.contributor_id, + contribution_type, + main_contribution: contribution_ordinal == 1, + first_name: contribution.first_name.clone(), + last_name: contribution.last_name.clone(), + full_name: full_name.into(), + contribution_ordinal, + } +} + +fn make_contribution( + pool: &crate::db::PgPool, + work_id: Uuid, + contributor_id: Uuid, + contribution_type: ContributionType, + contribution_ordinal: i32, +) -> Contribution { + let new_contribution = make_new_contribution( + work_id, + contributor_id, + contribution_type, + contribution_ordinal, + ); + + Contribution::create(pool, &new_contribution).expect("Failed to create contribution") +} + +mod defaults { + use super::*; + + #[test] + fn contributiontype_default_is_author() { + let contributiontype: ContributionType = Default::default(); + assert_eq!(contributiontype, ContributionType::Author); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn contributiontype_display_formats_expected_strings() { + assert_eq!(format!("{}", ContributionType::Author), "Author"); + assert_eq!(format!("{}", ContributionType::Editor), "Editor"); + assert_eq!(format!("{}", ContributionType::Translator), "Translator"); + assert_eq!( + format!("{}", ContributionType::Photographer), + "Photographer" + ); + assert_eq!(format!("{}", ContributionType::Illustrator), "Illustrator"); + assert_eq!(format!("{}", ContributionType::MusicEditor), "Music Editor"); + assert_eq!(format!("{}", ContributionType::ForewordBy), "Foreword By"); + assert_eq!( + format!("{}", ContributionType::IntroductionBy), + "Introduction By" + ); + assert_eq!(format!("{}", ContributionType::AfterwordBy), "Afterword By"); + assert_eq!(format!("{}", ContributionType::PrefaceBy), "Preface By"); + assert_eq!(format!("{}", ContributionType::SoftwareBy), "Software By"); + assert_eq!(format!("{}", ContributionType::ResearchBy), "Research By"); + assert_eq!( + format!("{}", ContributionType::ContributionsBy), + "Contributions By" + ); + assert_eq!(format!("{}", ContributionType::Indexer), "Indexer"); + } + + #[test] + fn contributiontype_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + ContributionType::from_str("Author").unwrap(), + ContributionType::Author + ); + assert_eq!( + ContributionType::from_str("Editor").unwrap(), + ContributionType::Editor + ); + assert_eq!( + ContributionType::from_str("Translator").unwrap(), + ContributionType::Translator + ); + assert_eq!( + ContributionType::from_str("Photographer").unwrap(), + ContributionType::Photographer + ); + assert_eq!( + ContributionType::from_str("Illustrator").unwrap(), + ContributionType::Illustrator + ); + assert_eq!( + ContributionType::from_str("Music Editor").unwrap(), + ContributionType::MusicEditor + ); + assert_eq!( + ContributionType::from_str("Foreword By").unwrap(), + ContributionType::ForewordBy + ); + assert_eq!( + ContributionType::from_str("Introduction By").unwrap(), + ContributionType::IntroductionBy + ); + assert_eq!( + ContributionType::from_str("Afterword By").unwrap(), + ContributionType::AfterwordBy + ); + assert_eq!( + ContributionType::from_str("Preface By").unwrap(), + ContributionType::PrefaceBy + ); + assert_eq!( + ContributionType::from_str("Software By").unwrap(), + ContributionType::SoftwareBy + ); + assert_eq!( + ContributionType::from_str("Research By").unwrap(), + ContributionType::ResearchBy + ); + assert_eq!( + ContributionType::from_str("Contributions By").unwrap(), + ContributionType::ContributionsBy + ); + assert_eq!( + ContributionType::from_str("Indexer").unwrap(), + ContributionType::Indexer + ); + + assert!(ContributionType::from_str("Juggler").is_err()); + assert!(ContributionType::from_str("Supervisor").is_err()); + } +} + +#[cfg(feature = "backend")] +mod conversions { + use super::*; + use crate::model::tests::db::setup_test_db; + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn contributiontype_graphql_roundtrip() { + assert_graphql_enum_roundtrip(ContributionType::Author); + } + + #[test] + fn contributiontype_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<ContributionType, crate::schema::sql_types::ContributionType>( + pool.as_ref(), + "'author'::contribution_type", + ContributionType::Author, + ); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let contribution: Contribution = Default::default(); + assert_eq!(contribution.pk(), contribution.contribution_id); + } + + #[test] + fn history_entry_serializes_model() { + let contribution: Contribution = Default::default(); + let user_id = "123456".to_string(); + let new_contribution_history = contribution.new_history_entry(&user_id); + assert_eq!( + new_contribution_history.contribution_id, + contribution.contribution_id + ); + assert_eq!(new_contribution_history.user_id, user_id); + assert_eq!( + new_contribution_history.data, + serde_json::Value::String(serde_json::to_string(&contribution).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::contribution::policy::ContributionPolicy; + use crate::model::tests::db::{ + create_contributor, create_imprint, create_publisher, create_work, setup_test_db, + test_context_with_user, test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("contribution-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let new_contribution = make_new_contribution_with_names( + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + contributor.first_name.clone(), + contributor.last_name.clone(), + contributor.full_name.clone(), + ); + + let contribution = + Contribution::create(pool.as_ref(), &new_contribution).expect("Failed to create"); + let patch = make_patch_contribution( + &contribution, + ContributionType::Editor, + format!("Updated {}", Uuid::new_v4()), + 2, + ); + + assert!(ContributionPolicy::can_create(&ctx, &new_contribution, ()).is_ok()); + assert!(ContributionPolicy::can_update(&ctx, &contribution, &patch, ()).is_ok()); + assert!(ContributionPolicy::can_delete(&ctx, &contribution).is_ok()); + assert!(ContributionPolicy::can_move(&ctx, &contribution).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + ); + let patch = make_patch_contribution( + &contribution, + ContributionType::Editor, + format!("Updated {}", Uuid::new_v4()), + 2, + ); + + let user = test_user_with_role("contribution-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_contribution = make_new_contribution_with_names( + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + contributor.first_name.clone(), + contributor.last_name.clone(), + contributor.full_name.clone(), + ); + + assert!(ContributionPolicy::can_create(&ctx, &new_contribution, ()).is_err()); + assert!(ContributionPolicy::can_update(&ctx, &contribution, &patch, ()).is_err()); + assert!(ContributionPolicy::can_delete(&ctx, &contribution).is_err()); + assert!(ContributionPolicy::can_move(&ctx, &contribution).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::graphql::types::inputs::{ContributionOrderBy, Direction}; + use crate::model::biography::{Biography, NewBiography}; + use crate::model::locale::LocaleCode; + use crate::model::tests::db::{ + create_contribution, create_contributor, create_imprint, create_publisher, create_work, + setup_test_db, test_context, + }; + use crate::model::{Crud, Reorder}; + + #[allow(clippy::too_many_arguments)] + fn make_contribution_with_names( + pool: &crate::db::PgPool, + work_id: Uuid, + contributor_id: Uuid, + contribution_type: ContributionType, + contribution_ordinal: i32, + first_name: &str, + last_name: &str, + full_name: &str, + ) -> Contribution { + let new_contribution = make_new_contribution_with_names( + work_id, + contributor_id, + contribution_type, + contribution_ordinal, + Some(first_name.to_string()), + last_name, + full_name, + ); + + Contribution::create(pool, &new_contribution).expect("Failed to create contribution") + } + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let contribution = create_contribution(pool.as_ref(), &work, &contributor); + + let fetched = Contribution::from_id(pool.as_ref(), &contribution.contribution_id) + .expect("Failed to fetch"); + assert_eq!(contribution.contribution_id, fetched.contribution_id); + + let patch = make_patch_contribution( + &contribution, + ContributionType::Editor, + format!("Updated {}", Uuid::new_v4()), + 2, + ); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = contribution.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.full_name, patch.full_name); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Contribution::from_id(pool.as_ref(), &deleted.contribution_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + + make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + ); + make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Editor, + 2, + ); + + let order = ContributionOrderBy { + field: ContributionField::ContributionId, + direction: Direction::Asc, + }; + + let first = Contribution::all( + pool.as_ref(), + 1, + 0, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch contributions"); + let second = Contribution::all( + pool.as_ref(), + 1, + 1, + None, + ContributionOrderBy { + field: ContributionField::ContributionId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch contributions"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].contribution_id, second[0].contribution_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + + make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + ); + make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Editor, + 2, + ); + + let count = Contribution::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count contributions"); + assert_eq!(count, 2); + } + + #[test] + fn crud_filter_param_limits_contribution_types() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + + let matches = make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + ); + make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Editor, + 2, + ); + + let filtered = Contribution::all( + pool.as_ref(), + 10, + 0, + None, + ContributionOrderBy { + field: ContributionField::ContributionId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![ContributionType::Author], + vec![], + None, + None, + ) + .expect("Failed to filter contributions by type"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].contribution_id, matches.contribution_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + + let _first = make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + ); + let _second = make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Editor, + 2, + ); + let asc = Contribution::all( + pool.as_ref(), + 2, + 0, + None, + ContributionOrderBy { + field: ContributionField::ContributionId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order contributions (asc)"); + + let desc = Contribution::all( + pool.as_ref(), + 2, + 0, + None, + ContributionOrderBy { + field: ContributionField::ContributionId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order contributions (desc)"); + + assert_eq!(asc.len(), 2); + assert_eq!(desc.len(), 2); + let asc_ids = [asc[0].contribution_id, asc[1].contribution_id]; + let desc_ids = [desc[0].contribution_id, desc[1].contribution_id]; + assert_ne!(asc_ids[0], asc_ids[1]); + assert_eq!(desc_ids, [asc_ids[1], asc_ids[0]]); + } + + #[test] + fn crud_ordering_by_ordinal_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let ctx = test_context(pool.clone(), "contribution-order-user"); + + let first = make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + ); + let second = make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Editor, + 2, + ); + + let mut by_id = [first, second]; + by_id.sort_by_key(|contribution| contribution.contribution_id); + + let high_ordinal_patch = make_patch_contribution( + &by_id[0], + by_id[0].contribution_type, + format!("{}-high", by_id[0].full_name), + 3, + ); + by_id[0] + .update(&ctx, &high_ordinal_patch) + .expect("Failed to raise first contribution ordinal"); + + let low_ordinal_patch = make_patch_contribution( + &by_id[1], + by_id[1].contribution_type, + format!("{}-low", by_id[1].full_name), + 1, + ); + by_id[1] + .update(&ctx, &low_ordinal_patch) + .expect("Failed to lower second contribution ordinal"); + + let mid_ordinal_patch = make_patch_contribution( + &by_id[0], + by_id[0].contribution_type, + format!("{}-mid", by_id[0].full_name), + 2, + ); + by_id[0] + .update(&ctx, &mid_ordinal_patch) + .expect("Failed to set first contribution ordinal"); + + let asc = Contribution::all( + pool.as_ref(), + 2, + 0, + None, + ContributionOrderBy { + field: ContributionField::ContributionOrdinal, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order contributions by ordinal (asc)"); + + let desc = Contribution::all( + pool.as_ref(), + 2, + 0, + None, + ContributionOrderBy { + field: ContributionField::ContributionOrdinal, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order contributions by ordinal (desc)"); + + assert_eq!(asc[0].contribution_id, by_id[1].contribution_id); + assert_eq!(desc[0].contribution_id, by_id[0].contribution_id); + } + + #[test] + fn crud_filter_parent_work_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + + let matches = make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + ); + make_contribution( + pool.as_ref(), + other_work.work_id, + contributor.contributor_id, + ContributionType::Editor, + 1, + ); + + let filtered = Contribution::all( + pool.as_ref(), + 10, + 0, + None, + ContributionOrderBy { + field: ContributionField::ContributionId, + direction: Direction::Asc, + }, + vec![], + Some(work.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter contributions by work"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].contribution_id, matches.contribution_id); + } + + #[test] + fn crud_filter_parent_contributor_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let other_contributor = create_contributor(pool.as_ref()); + + let matches = make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + ); + make_contribution( + pool.as_ref(), + work.work_id, + other_contributor.contributor_id, + ContributionType::Editor, + 2, + ); + + let filtered = Contribution::all( + pool.as_ref(), + 10, + 0, + None, + ContributionOrderBy { + field: ContributionField::ContributionId, + direction: Direction::Asc, + }, + vec![], + None, + Some(contributor.contributor_id), + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter contributions by contributor"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].contribution_id, matches.contribution_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let matches = make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let other_work = create_work(pool.as_ref(), &other_imprint); + let other_contributor = create_contributor(pool.as_ref()); + make_contribution( + pool.as_ref(), + other_work.work_id, + other_contributor.contributor_id, + ContributionType::Editor, + 1, + ); + + let filtered = Contribution::all( + pool.as_ref(), + 10, + 0, + None, + ContributionOrderBy { + field: ContributionField::ContributionId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter contributions by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].contribution_id, matches.contribution_id); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + let other_contributor = create_contributor(pool.as_ref()); + + let first = make_contribution_with_names( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + "Alice", + "Alpha", + "Alice Alpha", + ); + let second = make_contribution_with_names( + pool.as_ref(), + work.work_id, + other_contributor.contributor_id, + ContributionType::Editor, + 2, + "Bob", + "Beta", + "Bob Beta", + ); + + Biography::create( + pool.as_ref(), + &NewBiography { + contribution_id: first.contribution_id, + content: "Bio A".to_string(), + canonical: true, + locale_code: LocaleCode::En, + }, + ) + .expect("Failed to create biography"); + Biography::create( + pool.as_ref(), + &NewBiography { + contribution_id: second.contribution_id, + content: "Bio B".to_string(), + canonical: true, + locale_code: LocaleCode::En, + }, + ) + .expect("Failed to create biography"); + + let fields: Vec<fn() -> ContributionField> = vec![ + || ContributionField::ContributionId, + || ContributionField::WorkId, + || ContributionField::ContributorId, + || ContributionField::ContributionType, + || ContributionField::MainContribution, + || ContributionField::Biography, + || ContributionField::CreatedAt, + || ContributionField::UpdatedAt, + || ContributionField::FirstName, + || ContributionField::LastName, + || ContributionField::FullName, + || ContributionField::ContributionOrdinal, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Contribution::all( + pool.as_ref(), + 10, + 0, + None, + ContributionOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order contributions"); + + assert_eq!(results.len(), 2); + } + } + } + + #[test] + fn crud_count_with_filter_matches_contribution_type() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + + make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + ); + make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Editor, + 2, + ); + + let count = Contribution::count( + pool.as_ref(), + None, + vec![], + vec![ContributionType::Author], + vec![], + None, + None, + ) + .expect("Failed to count filtered contributions"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_change_ordinal_reorders_contributions() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = create_contributor(pool.as_ref()); + + let first = make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Author, + 1, + ); + let second = make_contribution( + pool.as_ref(), + work.work_id, + contributor.contributor_id, + ContributionType::Editor, + 2, + ); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = first + .change_ordinal(&ctx, first.contribution_ordinal, 2) + .expect("Failed to change contribution ordinal"); + + let refreshed_first = Contribution::from_id(pool.as_ref(), &updated.contribution_id) + .expect("Failed to fetch"); + let refreshed_second = + Contribution::from_id(pool.as_ref(), &second.contribution_id).expect("Failed to fetch"); + + assert_eq!(refreshed_first.contribution_ordinal, 2); + assert_eq!(refreshed_second.contribution_ordinal, 1); + } +} diff --git a/thoth-api/src/model/contributor/crud.rs b/thoth-api/src/model/contributor/crud.rs index d3c96037..26c3f8fd 100644 --- a/thoth-api/src/model/contributor/crud.rs +++ b/thoth-api/src/model/contributor/crud.rs @@ -2,14 +2,13 @@ use super::{ Contributor, ContributorField, ContributorHistory, ContributorOrderBy, NewContributor, NewContributorHistory, PatchContributor, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::db::PgPool; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherIds}; use crate::schema::{contributor, contributor_history}; -use crate::{crud_methods, db_insert}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, }; -use thoth_errors::{ThothError, ThothResult}; +use thoth_errors::ThothResult; use uuid::Uuid; impl Crud for Contributor { @@ -19,6 +18,7 @@ impl Crud for Contributor { type FilterParameter1 = (); type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.contributor_id @@ -36,44 +36,37 @@ impl Crud for Contributor { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Contributor>> { use crate::schema::contributor::dsl::*; let mut connection = db.get()?; let mut query = contributor.into_boxed(); query = match order.field { - ContributorField::ContributorId => match order.direction { - Direction::Asc => query.order(contributor_id.asc()), - Direction::Desc => query.order(contributor_id.desc()), - }, - ContributorField::FirstName => match order.direction { - Direction::Asc => query.order(first_name.asc()), - Direction::Desc => query.order(first_name.desc()), - }, - ContributorField::LastName => match order.direction { - Direction::Asc => query.order(last_name.asc()), - Direction::Desc => query.order(last_name.desc()), - }, - ContributorField::FullName => match order.direction { - Direction::Asc => query.order(full_name.asc()), - Direction::Desc => query.order(full_name.desc()), - }, - ContributorField::Orcid => match order.direction { - Direction::Asc => query.order(orcid.asc()), - Direction::Desc => query.order(orcid.desc()), - }, - ContributorField::Website => match order.direction { - Direction::Asc => query.order(website.asc()), - Direction::Desc => query.order(website.desc()), - }, - ContributorField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - ContributorField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + ContributorField::ContributorId => { + apply_directional_order!(query, order.direction, order, contributor_id) + } + ContributorField::FirstName => { + apply_directional_order!(query, order.direction, order, first_name) + } + ContributorField::LastName => { + apply_directional_order!(query, order.direction, order, last_name) + } + ContributorField::FullName => { + apply_directional_order!(query, order.direction, order, full_name) + } + ContributorField::Orcid => { + apply_directional_order!(query, order.direction, order, orcid) + } + ContributorField::Website => { + apply_directional_order!(query, order.direction, order, website) + } + ContributorField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + ContributorField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if let Some(filter) = filter { query = query.filter( @@ -97,6 +90,7 @@ impl Crud for Contributor { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::contributor::dsl::*; let mut connection = db.get()?; @@ -120,23 +114,31 @@ impl Crud for Contributor { .map(|t| t.to_string().parse::<i32>().unwrap()) .map_err(Into::into) } + crud_methods!(contributor::table, contributor::dsl::contributor); +} - fn publisher_id(&self, _db: &crate::db::PgPool) -> ThothResult<Uuid> { - Err(ThothError::InternalError( - "Method publisher_id() is not supported for Contributor objects".to_string(), - )) +impl PublisherIds for Contributor { + fn publisher_ids(&self, db: &PgPool) -> ThothResult<Vec<Uuid>> { + let mut connection = db.get()?; + crate::schema::publisher::table + .inner_join(crate::schema::imprint::table.inner_join( + crate::schema::work::table.inner_join(crate::schema::contribution::table), + )) + .select(crate::schema::publisher::publisher_id) + .filter(crate::schema::contribution::contributor_id.eq(self.contributor_id)) + .distinct() + .load::<Uuid>(&mut connection) + .map_err(Into::into) } - - crud_methods!(contributor::table, contributor::dsl::contributor); } impl HistoryEntry for Contributor { type NewHistoryEntity = NewContributorHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { contributor_id: self.contributor_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -147,54 +149,3 @@ impl DbInsert for NewContributorHistory { db_insert!(contributor_history::table); } - -impl Contributor { - pub fn linked_publisher_ids(&self, db: &crate::db::PgPool) -> ThothResult<Vec<Uuid>> { - contributor_linked_publisher_ids(self.contributor_id, db) - } -} - -fn contributor_linked_publisher_ids( - contributor_id: Uuid, - db: &crate::db::PgPool, -) -> ThothResult<Vec<Uuid>> { - let mut connection = db.get()?; - crate::schema::publisher::table - .inner_join( - crate::schema::imprint::table.inner_join( - crate::schema::work::table.inner_join(crate::schema::contribution::table), - ), - ) - .select(crate::schema::publisher::publisher_id) - .filter(crate::schema::contribution::contributor_id.eq(contributor_id)) - .distinct() - .load::<Uuid>(&mut connection) - .map_err(Into::into) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_contributor_pk() { - let contributor: Contributor = Default::default(); - assert_eq!(contributor.pk(), contributor.contributor_id); - } - - #[test] - fn test_new_contributor_history_from_contributor() { - let contributor: Contributor = Default::default(); - let account_id: Uuid = Default::default(); - let new_contributor_history = contributor.new_history_entry(&account_id); - assert_eq!( - new_contributor_history.contributor_id, - contributor.contributor_id - ); - assert_eq!(new_contributor_history.account_id, account_id); - assert_eq!( - new_contributor_history.data, - serde_json::Value::String(serde_json::to_string(&contributor).unwrap()) - ); - } -} diff --git a/thoth-api/src/model/contributor/mod.rs b/thoth-api/src/model/contributor/mod.rs index 67d97fd2..62476990 100644 --- a/thoth-api/src/model/contributor/mod.rs +++ b/thoth-api/src/model/contributor/mod.rs @@ -5,7 +5,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::types::inputs::Direction; use crate::model::Orcid; use crate::model::Timestamp; #[cfg(feature = "backend")] @@ -35,7 +35,7 @@ pub enum ContributorField { UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Contributor { @@ -51,7 +51,7 @@ pub struct Contributor { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new individual involved in the production of works"), diesel(table_name = contributor) )] @@ -65,7 +65,7 @@ pub struct NewContributor { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing individual involved in the production of works"), diesel(table_name = contributor, treat_none_as_null = true) )] @@ -78,23 +78,23 @@ pub struct PatchContributor { pub website: Option<String>, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct ContributorHistory { pub contributor_history_id: Uuid, pub contributor_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = contributor_history) )] pub struct NewContributorHistory { pub contributor_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -119,55 +119,11 @@ impl fmt::Display for Contributor { } } -#[test] -fn test_contributorfield_default() { - let contfield: ContributorField = Default::default(); - assert_eq!(contfield, ContributorField::FullName); -} - -#[test] -fn test_contributorfield_display() { - assert_eq!(format!("{}", ContributorField::ContributorId), "ID"); - assert_eq!(format!("{}", ContributorField::FirstName), "FirstName"); - assert_eq!(format!("{}", ContributorField::LastName), "LastName"); - assert_eq!(format!("{}", ContributorField::FullName), "FullName"); - assert_eq!(format!("{}", ContributorField::Orcid), "ORCID"); - assert_eq!(format!("{}", ContributorField::Website), "Website"); - assert_eq!(format!("{}", ContributorField::CreatedAt), "CreatedAt"); - assert_eq!(format!("{}", ContributorField::UpdatedAt), "UpdatedAt"); -} - -#[test] -fn test_contributorfield_fromstr() { - use std::str::FromStr; - assert_eq!( - ContributorField::from_str("ID").unwrap(), - ContributorField::ContributorId - ); - assert_eq!( - ContributorField::from_str("FirstName").unwrap(), - ContributorField::FirstName - ); - assert_eq!( - ContributorField::from_str("LastName").unwrap(), - ContributorField::LastName - ); - assert_eq!( - ContributorField::from_str("FullName").unwrap(), - ContributorField::FullName - ); - assert_eq!( - ContributorField::from_str("ORCID").unwrap(), - ContributorField::Orcid - ); - assert_eq!( - ContributorField::from_str("UpdatedAt").unwrap(), - ContributorField::UpdatedAt - ); - assert!(ContributorField::from_str("ContributorID").is_err()); - assert!(ContributorField::from_str("Biography").is_err()); - assert!(ContributorField::from_str("Institution").is_err()); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::ContributorPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/contributor/policy.rs b/thoth-api/src/model/contributor/policy.rs new file mode 100644 index 00000000..4b0d5c58 --- /dev/null +++ b/thoth-api/src/model/contributor/policy.rs @@ -0,0 +1,41 @@ +use crate::model::contributor::{Contributor, NewContributor, PatchContributor}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Contributor`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct ContributorPolicy; + +impl CreatePolicy<NewContributor> for ContributorPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + _data: &NewContributor, + _params: (), + ) -> ThothResult<()> { + ctx.require_authentication()?; + Ok(()) + } +} + +impl UpdatePolicy<Contributor, PatchContributor> for ContributorPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + _current: &Contributor, + _patch: &PatchContributor, + _params: (), + ) -> ThothResult<()> { + ctx.require_authentication()?; + + Ok(()) + } +} + +impl DeletePolicy<Contributor> for ContributorPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Contributor) -> ThothResult<()> { + ctx.require_publishers_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/contributor/tests.rs b/thoth-api/src/model/contributor/tests.rs new file mode 100644 index 00000000..6c7106d1 --- /dev/null +++ b/thoth-api/src/model/contributor/tests.rs @@ -0,0 +1,544 @@ +use super::*; +use crate::model::{Crud, Orcid}; +use uuid::Uuid; + +fn make_contributor(pool: &crate::db::PgPool, full_name: String, last_name: String) -> Contributor { + let new_contributor = NewContributor { + first_name: Some("Test".to_string()), + last_name, + full_name, + orcid: None, + website: None, + }; + + Contributor::create(pool, &new_contributor).expect("Failed to create contributor") +} + +mod defaults { + use super::*; + + #[test] + fn contributorfield_default_is_full_name() { + let contfield: ContributorField = Default::default(); + assert_eq!(contfield, ContributorField::FullName); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn contributorfield_display_formats_expected_strings() { + assert_eq!(format!("{}", ContributorField::ContributorId), "ID"); + assert_eq!(format!("{}", ContributorField::FirstName), "FirstName"); + assert_eq!(format!("{}", ContributorField::LastName), "LastName"); + assert_eq!(format!("{}", ContributorField::FullName), "FullName"); + assert_eq!(format!("{}", ContributorField::Orcid), "ORCID"); + assert_eq!(format!("{}", ContributorField::Website), "Website"); + assert_eq!(format!("{}", ContributorField::CreatedAt), "CreatedAt"); + assert_eq!(format!("{}", ContributorField::UpdatedAt), "UpdatedAt"); + } + + #[test] + fn contributorfield_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + ContributorField::from_str("ID").unwrap(), + ContributorField::ContributorId + ); + assert_eq!( + ContributorField::from_str("FirstName").unwrap(), + ContributorField::FirstName + ); + assert_eq!( + ContributorField::from_str("LastName").unwrap(), + ContributorField::LastName + ); + assert_eq!( + ContributorField::from_str("FullName").unwrap(), + ContributorField::FullName + ); + assert_eq!( + ContributorField::from_str("ORCID").unwrap(), + ContributorField::Orcid + ); + assert_eq!( + ContributorField::from_str("UpdatedAt").unwrap(), + ContributorField::UpdatedAt + ); + assert!(ContributorField::from_str("ContributorID").is_err()); + assert!(ContributorField::from_str("Biography").is_err()); + assert!(ContributorField::from_str("Institution").is_err()); + } + + #[test] + fn contributor_display_includes_orcid_when_present() { + let contributor = Contributor { + full_name: "Jane Doe".to_string(), + orcid: Some(Orcid("https://orcid.org/0000-0002-1234-5678".to_string())), + ..Default::default() + }; + assert_eq!(format!("{contributor}"), "Jane Doe - 0000-0002-1234-5678"); + } + + #[test] + fn contributor_display_omits_orcid_when_absent() { + let contributor = Contributor { + full_name: "Jane Doe".to_string(), + orcid: None, + ..Default::default() + }; + assert_eq!(format!("{contributor}"), "Jane Doe"); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let contributor: Contributor = Default::default(); + assert_eq!(contributor.pk(), contributor.contributor_id); + } + + #[test] + fn history_entry_serializes_model() { + let contributor: Contributor = Default::default(); + let user_id = "123456".to_string(); + let new_contributor_history = contributor.new_history_entry(&user_id); + assert_eq!( + new_contributor_history.contributor_id, + contributor.contributor_id + ); + assert_eq!(new_contributor_history.user_id, user_id); + assert_eq!( + new_contributor_history.data, + serde_json::Value::String(serde_json::to_string(&contributor).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::contributor::policy::ContributorPolicy; + use crate::model::tests::db::{ + create_contribution, create_imprint, create_publisher, create_work, setup_test_db, + test_context, test_context_anonymous, test_context_with_user, test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_requires_authentication_for_create_update() { + let (_guard, pool) = setup_test_db(); + + let ctx = test_context_anonymous(pool.clone()); + + let new_contributor = NewContributor { + first_name: Some("Test".to_string()), + last_name: "Contributor".to_string(), + full_name: "Test Contributor".to_string(), + orcid: None, + website: None, + }; + + let contributor = + Contributor::create(pool.as_ref(), &new_contributor).expect("Failed to create"); + let patch = PatchContributor { + contributor_id: contributor.contributor_id, + first_name: contributor.first_name.clone(), + last_name: contributor.last_name.clone(), + full_name: "Updated Contributor".to_string(), + orcid: contributor.orcid.clone(), + website: contributor.website.clone(), + }; + + assert!(ContributorPolicy::can_create(&ctx, &new_contributor, ()).is_err()); + assert!(ContributorPolicy::can_update(&ctx, &contributor, &patch, ()).is_err()); + } + + #[test] + fn crud_policy_allows_authenticated_user_for_create_update() { + let (_guard, pool) = setup_test_db(); + + let ctx = test_context(pool.clone(), "contributor-user"); + + let new_contributor = NewContributor { + first_name: Some("Test".to_string()), + last_name: "Contributor".to_string(), + full_name: "Test Contributor".to_string(), + orcid: None, + website: None, + }; + + let contributor = + Contributor::create(pool.as_ref(), &new_contributor).expect("Failed to create"); + let patch = PatchContributor { + contributor_id: contributor.contributor_id, + first_name: contributor.first_name.clone(), + last_name: contributor.last_name.clone(), + full_name: "Updated Contributor".to_string(), + orcid: contributor.orcid.clone(), + website: contributor.website.clone(), + }; + + assert!(ContributorPolicy::can_create(&ctx, &new_contributor, ()).is_ok()); + assert!(ContributorPolicy::can_update(&ctx, &contributor, &patch, ()).is_ok()); + } + + #[test] + fn crud_policy_delete_requires_publisher_membership() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let contributor = make_contributor( + pool.as_ref(), + format!("Contributor {}", Uuid::new_v4()), + "Contributor".to_string(), + ); + create_contribution(pool.as_ref(), &work, &contributor); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("contributor-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + assert!(ContributorPolicy::can_delete(&ctx, &contributor).is_ok()); + + let other_user = test_user_with_role("contributor-user", Role::PublisherUser, "org-other"); + let other_ctx = test_context_with_user(pool.clone(), other_user); + assert!(ContributorPolicy::can_delete(&other_ctx, &contributor).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::graphql::types::inputs::Direction; + use crate::model::contributor::ContributorOrderBy; + use crate::model::tests::db::{setup_test_db, test_context}; + use crate::model::Crud; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let suffix = Uuid::new_v4(); + let new_contributor = NewContributor { + first_name: Some("Test".to_string()), + last_name: format!("Contributor {suffix}"), + full_name: format!("Test Contributor {suffix}"), + orcid: None, + website: None, + }; + + let contributor = + Contributor::create(pool.as_ref(), &new_contributor).expect("Failed to create"); + let fetched = Contributor::from_id(pool.as_ref(), &contributor.contributor_id) + .expect("Failed to fetch"); + assert_eq!(contributor.contributor_id, fetched.contributor_id); + + let patch = PatchContributor { + contributor_id: contributor.contributor_id, + first_name: contributor.first_name.clone(), + last_name: contributor.last_name.clone(), + full_name: format!("Updated {suffix}"), + orcid: contributor.orcid.clone(), + website: Some("https://example.com".to_string()), + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = contributor.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.full_name, patch.full_name); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Contributor::from_id(pool.as_ref(), &deleted.contributor_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + make_contributor( + pool.as_ref(), + format!("Contributor {}", Uuid::new_v4()), + "Alpha".to_string(), + ); + make_contributor( + pool.as_ref(), + format!("Contributor {}", Uuid::new_v4()), + "Beta".to_string(), + ); + + let order = ContributorOrderBy { + field: ContributorField::ContributorId, + direction: Direction::Asc, + }; + + let first = Contributor::all( + pool.as_ref(), + 1, + 0, + None, + order.clone(), + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch contributors"); + let second = Contributor::all( + pool.as_ref(), + 1, + 1, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch contributors"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].contributor_id, second[0].contributor_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + make_contributor( + pool.as_ref(), + format!("Contributor {}", Uuid::new_v4()), + "Alpha".to_string(), + ); + make_contributor( + pool.as_ref(), + format!("Contributor {}", Uuid::new_v4()), + "Beta".to_string(), + ); + + let count = Contributor::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count contributors"); + assert_eq!(count, 2); + } + + #[test] + fn crud_filter_matches_full_name() { + let (_guard, pool) = setup_test_db(); + + let marker = format!("Filter {}", Uuid::new_v4()); + let matches = make_contributor( + pool.as_ref(), + format!("Contributor {marker}"), + "Alpha".to_string(), + ); + make_contributor( + pool.as_ref(), + "Other Contributor".to_string(), + "Beta".to_string(), + ); + + let filtered = Contributor::all( + pool.as_ref(), + 10, + 0, + Some(marker), + ContributorOrderBy { + field: ContributorField::ContributorId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter contributors"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].contributor_id, matches.contributor_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let first = make_contributor( + pool.as_ref(), + format!("Contributor {}", Uuid::new_v4()), + "Alpha".to_string(), + ); + let second = make_contributor( + pool.as_ref(), + format!("Contributor {}", Uuid::new_v4()), + "Beta".to_string(), + ); + let mut ids = [first.contributor_id, second.contributor_id]; + ids.sort(); + + let asc = Contributor::all( + pool.as_ref(), + 2, + 0, + None, + ContributorOrderBy { + field: ContributorField::ContributorId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order contributors (asc)"); + + let desc = Contributor::all( + pool.as_ref(), + 2, + 0, + None, + ContributorOrderBy { + field: ContributorField::ContributorId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order contributors (desc)"); + + assert_eq!(asc[0].contributor_id, ids[0]); + assert_eq!(desc[0].contributor_id, ids[1]); + } + + #[test] + fn crud_count_with_filter_matches_orcid() { + let (_guard, pool) = setup_test_db(); + + let marker = "0000-0002-1825-0097"; + Contributor::create( + pool.as_ref(), + &NewContributor { + first_name: Some("Filter".to_string()), + last_name: "Match".to_string(), + full_name: "Filter Match".to_string(), + orcid: Some(Orcid(format!("https://orcid.org/{marker}"))), + website: None, + }, + ) + .expect("Failed to create contributor"); + Contributor::create( + pool.as_ref(), + &NewContributor { + first_name: Some("Other".to_string()), + last_name: "Person".to_string(), + full_name: "Other Person".to_string(), + orcid: None, + website: None, + }, + ) + .expect("Failed to create contributor"); + + let count = Contributor::count( + pool.as_ref(), + Some(marker.to_string()), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count contributors by filter"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + Contributor::create( + pool.as_ref(), + &NewContributor { + first_name: Some("Alice".to_string()), + last_name: "Alpha".to_string(), + full_name: "Alice Alpha".to_string(), + orcid: Some(Orcid("https://orcid.org/0000-0001-2345-6789".to_string())), + website: Some("https://example.com/a".to_string()), + }, + ) + .expect("Failed to create contributor"); + Contributor::create( + pool.as_ref(), + &NewContributor { + first_name: Some("Bob".to_string()), + last_name: "Beta".to_string(), + full_name: "Bob Beta".to_string(), + orcid: Some(Orcid("https://orcid.org/0000-0002-3456-7890".to_string())), + website: Some("https://example.com/b".to_string()), + }, + ) + .expect("Failed to create contributor"); + + let fields: Vec<fn() -> ContributorField> = vec![ + || ContributorField::ContributorId, + || ContributorField::FirstName, + || ContributorField::LastName, + || ContributorField::FullName, + || ContributorField::Orcid, + || ContributorField::Website, + || ContributorField::CreatedAt, + || ContributorField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Contributor::all( + pool.as_ref(), + 10, + 0, + None, + ContributorOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order contributors"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/endorsement/crud.rs b/thoth-api/src/model/endorsement/crud.rs new file mode 100644 index 00000000..06362622 --- /dev/null +++ b/thoth-api/src/model/endorsement/crud.rs @@ -0,0 +1,186 @@ +use super::{ + Endorsement, EndorsementField, EndorsementHistory, EndorsementOrderBy, NewEndorsement, + NewEndorsementHistory, PatchEndorsement, +}; +use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; +use crate::schema::{endorsement, endorsement_history}; +use diesel::{ + BoolExpressionMethods, Connection, ExpressionMethods, PgTextExpressionMethods, QueryDsl, + RunQueryDsl, +}; +use thoth_errors::ThothResult; +use uuid::Uuid; + +impl Crud for Endorsement { + type NewEntity = NewEndorsement; + type PatchEntity = PatchEndorsement; + type OrderByEntity = EndorsementOrderBy; + type FilterParameter1 = (); + type FilterParameter2 = (); + type FilterParameter3 = (); + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.endorsement_id + } + + fn all( + db: &crate::db::PgPool, + limit: i32, + offset: i32, + filter: Option<String>, + order: Self::OrderByEntity, + publishers: Vec<Uuid>, + parent_id_1: Option<Uuid>, + _: Option<Uuid>, + _: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<Vec<Endorsement>> { + use crate::schema::endorsement::dsl::*; + let mut connection = db.get()?; + let mut query = endorsement + .inner_join(crate::schema::work::table.inner_join(crate::schema::imprint::table)) + .select(crate::schema::endorsement::all_columns) + .into_boxed(); + + query = match order.field { + EndorsementField::EndorsementId => { + apply_directional_order!(query, order.direction, order, endorsement_id) + } + EndorsementField::WorkId => { + apply_directional_order!(query, order.direction, order, work_id) + } + EndorsementField::EndorsementOrdinal => { + apply_directional_order!(query, order.direction, order, endorsement_ordinal) + } + EndorsementField::AuthorName => { + apply_directional_order!(query, order.direction, order, author_name) + } + EndorsementField::AuthorRole => { + apply_directional_order!(query, order.direction, order, author_role) + } + EndorsementField::Url => apply_directional_order!(query, order.direction, order, url), + EndorsementField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + EndorsementField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } + }; + + if !publishers.is_empty() { + query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); + } + if let Some(pid) = parent_id_1 { + query = query.filter(work_id.eq(pid)); + } + if let Some(filter) = filter { + if !filter.is_empty() { + query = query.filter( + author_name + .ilike(format!("%{filter}%")) + .or(author_role.ilike(format!("%{filter}%"))) + .or(author_orcid.ilike(format!("%{filter}%"))) + .or(text.ilike(format!("%{filter}%"))) + .or(url.ilike(format!("%{filter}%"))), + ); + } + } + + query + .limit(limit.into()) + .offset(offset.into()) + .load::<Endorsement>(&mut connection) + .map_err(Into::into) + } + + fn count( + db: &crate::db::PgPool, + filter: Option<String>, + publishers: Vec<Uuid>, + _: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<i32> { + use crate::schema::endorsement::dsl::*; + let mut connection = db.get()?; + let mut query = endorsement + .inner_join(crate::schema::work::table.inner_join(crate::schema::imprint::table)) + .into_boxed(); + + if !publishers.is_empty() { + query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); + } + if let Some(filter) = filter { + if !filter.is_empty() { + query = query.filter( + author_name + .ilike(format!("%{filter}%")) + .or(author_role.ilike(format!("%{filter}%"))) + .or(author_orcid.ilike(format!("%{filter}%"))) + .or(text.ilike(format!("%{filter}%"))) + .or(url.ilike(format!("%{filter}%"))), + ); + } + } + + query + .count() + .get_result::<i64>(&mut connection) + .map(|t| t.to_string().parse::<i32>().unwrap()) + .map_err(Into::into) + } + + crud_methods!(endorsement::table, endorsement::dsl::endorsement); +} + +publisher_id_impls!(Endorsement, NewEndorsement, PatchEndorsement, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + +impl HistoryEntry for Endorsement { + type NewHistoryEntity = NewEndorsementHistory; + + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { + Self::NewHistoryEntity { + endorsement_id: self.endorsement_id, + user_id: user_id.to_string(), + data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), + } + } +} + +impl DbInsert for NewEndorsementHistory { + type MainEntity = EndorsementHistory; + + db_insert!(endorsement_history::table); +} + +impl Reorder for Endorsement { + db_change_ordinal!( + endorsement::table, + endorsement::endorsement_ordinal, + "endorsement_endorsement_ordinal_work_id_uniq" + ); + + fn get_other_objects( + &self, + connection: &mut diesel::PgConnection, + ) -> ThothResult<Vec<(Uuid, i32)>> { + endorsement::table + .select(( + endorsement::endorsement_id, + endorsement::endorsement_ordinal, + )) + .filter( + endorsement::work_id + .eq(self.work_id) + .and(endorsement::endorsement_id.ne(self.endorsement_id)), + ) + .load::<(Uuid, i32)>(connection) + .map_err(Into::into) + } +} diff --git a/thoth-api/src/model/endorsement/mod.rs b/thoth-api/src/model/endorsement/mod.rs new file mode 100644 index 00000000..73966579 --- /dev/null +++ b/thoth-api/src/model/endorsement/mod.rs @@ -0,0 +1,120 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::graphql::types::inputs::Direction; +use crate::model::{Orcid, Timestamp}; +#[cfg(feature = "backend")] +use crate::schema::endorsement; +#[cfg(feature = "backend")] +use crate::schema::endorsement_history; + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLEnum), + graphql(description = "Field to use when sorting endorsements list") +)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum EndorsementField { + EndorsementId, + WorkId, + #[default] + EndorsementOrdinal, + AuthorName, + AuthorRole, + Url, + CreatedAt, + UpdatedAt, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Endorsement { + pub endorsement_id: Uuid, + pub work_id: Uuid, + pub author_name: Option<String>, + pub author_role: Option<String>, + pub author_orcid: Option<Orcid>, + pub author_institution_id: Option<Uuid>, + pub url: Option<String>, + pub text: Option<String>, + pub endorsement_ordinal: i32, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::Insertable), + graphql(description = "Set of values required to define a new endorsement linked to a work"), + diesel(table_name = endorsement) +)] +pub struct NewEndorsement { + pub work_id: Uuid, + pub author_name: Option<String>, + pub author_role: Option<String>, + pub author_orcid: Option<Orcid>, + pub author_institution_id: Option<Uuid>, + pub url: Option<String>, + pub text: Option<String>, + pub endorsement_ordinal: i32, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::AsChangeset), + graphql(description = "Set of values required to update an existing endorsement"), + diesel(table_name = endorsement, treat_none_as_null = true) +)] +pub struct PatchEndorsement { + pub endorsement_id: Uuid, + pub work_id: Uuid, + pub author_name: Option<String>, + pub author_role: Option<String>, + pub author_orcid: Option<Orcid>, + pub author_institution_id: Option<Uuid>, + pub url: Option<String>, + pub text: Option<String>, + pub endorsement_ordinal: i32, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +pub struct EndorsementHistory { + pub endorsement_history_id: Uuid, + pub endorsement_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, + pub timestamp: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(diesel::Insertable), + diesel(table_name = endorsement_history) +)] +pub struct NewEndorsementHistory { + pub endorsement_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject), + graphql(description = "Field and order to use when sorting endorsements list") +)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct EndorsementOrderBy { + pub field: EndorsementField, + pub direction: Direction, +} + +#[cfg(feature = "backend")] +pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::EndorsementPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/endorsement/policy.rs b/thoth-api/src/model/endorsement/policy.rs new file mode 100644 index 00000000..4a9e5922 --- /dev/null +++ b/thoth-api/src/model/endorsement/policy.rs @@ -0,0 +1,58 @@ +use crate::model::endorsement::{Endorsement, NewEndorsement, PatchEndorsement}; +use crate::model::work::{Work, WorkType}; +use crate::model::Crud; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Endorsement`. +/// +/// These policies enforce publisher scoping and prevent attachment to chapter records. +pub struct EndorsementPolicy; + +fn ensure_work_is_book(db: &crate::db::PgPool, work_id: uuid::Uuid) -> ThothResult<()> { + let work = Work::from_id(db, &work_id)?; + if work.work_type == WorkType::BookChapter { + Err(ThothError::ChapterBookMetadataError) + } else { + Ok(()) + } +} + +impl CreatePolicy<NewEndorsement> for EndorsementPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewEndorsement, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + ensure_work_is_book(ctx.db(), data.work_id) + } +} + +impl UpdatePolicy<Endorsement, PatchEndorsement> for EndorsementPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Endorsement, + patch: &PatchEndorsement, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + ensure_work_is_book(ctx.db(), current.work_id)?; + ensure_work_is_book(ctx.db(), patch.work_id) + } +} + +impl DeletePolicy<Endorsement> for EndorsementPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Endorsement) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ensure_work_is_book(ctx.db(), current.work_id) + } +} + +impl MovePolicy<Endorsement> for EndorsementPolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &Endorsement) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ensure_work_is_book(ctx.db(), current.work_id) + } +} diff --git a/thoth-api/src/model/endorsement/tests.rs b/thoth-api/src/model/endorsement/tests.rs new file mode 100644 index 00000000..34dad811 --- /dev/null +++ b/thoth-api/src/model/endorsement/tests.rs @@ -0,0 +1,340 @@ +use super::*; +use crate::model::Crud; +use std::str::FromStr; +use uuid::Uuid; + +fn make_endorsement( + pool: &crate::db::PgPool, + work_id: Uuid, + endorsement_ordinal: i32, + author_name: Option<String>, +) -> Endorsement { + let data = NewEndorsement { + work_id, + author_name, + author_role: Some("Author".to_string()), + author_orcid: Some( + crate::model::Orcid::from_str("https://orcid.org/0000-0002-1234-5678").unwrap(), + ), + author_institution_id: None, + url: Some("https://example.com/endorsement".to_string()), + text: Some("Endorsement text".to_string()), + endorsement_ordinal, + }; + + Endorsement::create(pool, &data).expect("Failed to create endorsement") +} + +mod defaults { + use super::*; + + #[test] + fn endorsementfield_default_is_endorsement_ordinal() { + let field: EndorsementField = Default::default(); + assert_eq!(field, EndorsementField::EndorsementOrdinal); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let endorsement: Endorsement = Default::default(); + assert_eq!(endorsement.pk(), endorsement.endorsement_id); + } + + #[test] + fn history_entry_serializes_model() { + let endorsement: Endorsement = Default::default(); + let user_id = "123456".to_string(); + let history = endorsement.new_history_entry(&user_id); + assert_eq!(history.endorsement_id, endorsement.endorsement_id); + assert_eq!(history.user_id, user_id); + assert_eq!( + history.data, + serde_json::Value::String(serde_json::to_string(&endorsement).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::endorsement::policy::EndorsementPolicy; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::work::{NewWork, Work, WorkStatus, WorkType}; + use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("endorsement-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let data = NewEndorsement { + work_id: work.work_id, + author_name: Some("Author".to_string()), + author_role: Some("Role".to_string()), + author_orcid: None, + author_institution_id: None, + url: Some("https://example.com/endorsement".to_string()), + text: Some("Endorsement text".to_string()), + endorsement_ordinal: 1, + }; + + let endorsement = Endorsement::create(pool.as_ref(), &data).expect("Failed to create"); + let patch = PatchEndorsement { + endorsement_id: endorsement.endorsement_id, + work_id: endorsement.work_id, + author_name: endorsement.author_name.clone(), + author_role: endorsement.author_role.clone(), + author_orcid: endorsement.author_orcid.clone(), + author_institution_id: endorsement.author_institution_id, + url: endorsement.url.clone(), + text: Some("Updated endorsement text".to_string()), + endorsement_ordinal: 1, + }; + + assert!(EndorsementPolicy::can_create(&ctx, &data, ()).is_ok()); + assert!(EndorsementPolicy::can_update(&ctx, &endorsement, &patch, ()).is_ok()); + assert!(EndorsementPolicy::can_delete(&ctx, &endorsement).is_ok()); + assert!(EndorsementPolicy::can_move(&ctx, &endorsement).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let endorsement = + make_endorsement(pool.as_ref(), work.work_id, 1, Some("Author".to_string())); + + let patch = PatchEndorsement { + endorsement_id: endorsement.endorsement_id, + work_id: endorsement.work_id, + author_name: endorsement.author_name.clone(), + author_role: endorsement.author_role.clone(), + author_orcid: endorsement.author_orcid.clone(), + author_institution_id: endorsement.author_institution_id, + url: endorsement.url.clone(), + text: Some("Updated endorsement text".to_string()), + endorsement_ordinal: 2, + }; + + let user = test_user_with_role("endorsement-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let data = NewEndorsement { + work_id: work.work_id, + author_name: Some("Author".to_string()), + author_role: Some("Role".to_string()), + author_orcid: None, + author_institution_id: None, + url: Some("https://example.com/endorsement".to_string()), + text: Some("Endorsement text".to_string()), + endorsement_ordinal: 1, + }; + + assert!(EndorsementPolicy::can_create(&ctx, &data, ()).is_err()); + assert!(EndorsementPolicy::can_update(&ctx, &endorsement, &patch, ()).is_err()); + assert!(EndorsementPolicy::can_delete(&ctx, &endorsement).is_err()); + assert!(EndorsementPolicy::can_move(&ctx, &endorsement).is_err()); + } + + #[test] + fn crud_policy_rejects_chapter_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("endorsement-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let chapter = Work::create( + pool.as_ref(), + &NewWork { + work_type: WorkType::BookChapter, + work_status: WorkStatus::Forthcoming, + reference: None, + edition: None, + imprint_id: imprint.imprint_id, + doi: None, + publication_date: None, + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: Some("1".to_string()), + last_page: Some("10".to_string()), + page_interval: Some("1-10".to_string()), + }, + ) + .expect("Failed to create chapter work"); + + let data = NewEndorsement { + work_id: chapter.work_id, + author_name: Some("Author".to_string()), + author_role: Some("Role".to_string()), + author_orcid: None, + author_institution_id: None, + url: Some("https://example.com/endorsement".to_string()), + text: Some("Endorsement text".to_string()), + endorsement_ordinal: 1, + }; + + assert!(matches!( + EndorsementPolicy::can_create(&ctx, &data, ()), + Err(thoth_errors::ThothError::ChapterBookMetadataError) + )); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::model::tests::db::{ + create_imprint, create_institution, create_publisher, create_work, setup_test_db, + test_context, + }; + use crate::model::Reorder; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let data = NewEndorsement { + work_id: work.work_id, + author_name: Some("Author".to_string()), + author_role: Some("Role".to_string()), + author_orcid: Some( + crate::model::Orcid::from_str("https://orcid.org/0000-0002-1234-5678").unwrap(), + ), + author_institution_id: None, + url: Some("https://example.com/endorsement".to_string()), + text: Some("Endorsement text".to_string()), + endorsement_ordinal: 1, + }; + + let endorsement = Endorsement::create(pool.as_ref(), &data).expect("Failed to create"); + let fetched = Endorsement::from_id(pool.as_ref(), &endorsement.endorsement_id) + .expect("Failed to fetch"); + assert_eq!(endorsement.endorsement_id, fetched.endorsement_id); + + let patch = PatchEndorsement { + endorsement_id: endorsement.endorsement_id, + work_id: endorsement.work_id, + author_name: endorsement.author_name.clone(), + author_role: endorsement.author_role.clone(), + author_orcid: endorsement.author_orcid.clone(), + author_institution_id: endorsement.author_institution_id, + url: endorsement.url.clone(), + text: Some("Updated endorsement text".to_string()), + endorsement_ordinal: 1, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = endorsement.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.text, patch.text); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Endorsement::from_id(pool.as_ref(), &deleted.endorsement_id).is_err()); + } + + #[test] + fn deleting_author_institution_nulls_relation() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let institution = create_institution(pool.as_ref()); + + let endorsement = Endorsement::create( + pool.as_ref(), + &NewEndorsement { + work_id: work.work_id, + author_name: Some("Author".to_string()), + author_role: Some("Role".to_string()), + author_orcid: Some( + crate::model::Orcid::from_str("https://orcid.org/0000-0002-1234-5678").unwrap(), + ), + author_institution_id: Some(institution.institution_id), + url: Some("https://example.com/endorsement".to_string()), + text: Some("Endorsement text".to_string()), + endorsement_ordinal: 1, + }, + ) + .expect("Failed to create endorsement"); + + institution + .delete(pool.as_ref()) + .expect("Failed to delete institution"); + + let fetched = Endorsement::from_id(pool.as_ref(), &endorsement.endorsement_id) + .expect("Failed to fetch endorsement after deleting institution"); + assert_eq!(fetched.endorsement_id, endorsement.endorsement_id); + assert!(fetched.author_institution_id.is_none()); + } + + #[test] + fn crud_change_ordinal_reorders_within_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let first = make_endorsement(pool.as_ref(), work.work_id, 1, Some("Author 1".to_string())); + let second = make_endorsement(pool.as_ref(), work.work_id, 2, Some("Author 2".to_string())); + let ctx = test_context(pool.clone(), "test-user"); + + let moved = second + .change_ordinal(&ctx, second.endorsement_ordinal, 1) + .expect("Failed to reorder endorsement"); + let shifted = Endorsement::from_id(pool.as_ref(), &first.endorsement_id) + .expect("Failed to fetch shifted endorsement"); + + assert_eq!(moved.endorsement_ordinal, 1); + assert_eq!(shifted.endorsement_ordinal, 2); + } +} diff --git a/thoth-api/src/model/file/crud.rs b/thoth-api/src/model/file/crud.rs new file mode 100644 index 00000000..251b20b1 --- /dev/null +++ b/thoth-api/src/model/file/crud.rs @@ -0,0 +1,855 @@ +use super::FileType; +use super::{ + upload_request_headers, File, FileCleanupCandidate, FilePolicy, FileUpload, FileUploadResponse, + NewFile, NewFileUpload, +}; +use crate::db::PgPool; +use crate::model::{ + additional_resource::{AdditionalResource, PatchAdditionalResource}, + location::{Location, LocationPlatform, NewLocation, PatchLocation}, + publication::Publication, + work::{PatchWork, Work}, + work_featured_video::{PatchWorkFeaturedVideo, WorkFeaturedVideo}, + Crud, Doi, PublisherId, Timestamp, +}; +use crate::policy::{CreatePolicy, PolicyContext}; +use crate::schema::{file, file_upload}; +use crate::storage::{ + canonical_frontcover_key, canonical_publication_key, canonical_resource_key, + presign_put_for_upload, temp_key, S3Client, StorageConfig, +}; +use chrono::{Duration, Utc}; +use diesel::prelude::*; +use diesel::OptionalExtension; +use std::collections::HashSet; +use thoth_errors::{ThothError, ThothResult}; +use uuid::Uuid; + +type FileUploadScope = ( + Work, + Option<Publication>, + Option<AdditionalResource>, + Option<WorkFeaturedVideo>, +); + +fn upload_expires_at(minutes: i64) -> ThothResult<Timestamp> { + let expires_at = Utc::now() + .checked_add_signed(Duration::minutes(minutes)) + .ok_or_else(|| { + ThothError::InternalError("Failed to calculate expiration time".to_string()) + })?; + Timestamp::parse_from_rfc3339(&expires_at.to_rfc3339()) +} + +fn publisher_id_from_scope( + db: &PgPool, + work_id: Option<Uuid>, + publication_id: Option<Uuid>, + additional_resource_id: Option<Uuid>, + work_featured_video_id: Option<Uuid>, + missing_scope_error: ThothError, +) -> ThothResult<Uuid> { + match ( + work_id, + publication_id, + additional_resource_id, + work_featured_video_id, + ) { + (Some(work_id), None, None, None) => Work::from_id(db, &work_id)?.publisher_id(db), + (None, Some(publication_id), None, None) => { + Publication::from_id(db, &publication_id)?.publisher_id(db) + } + (None, None, Some(additional_resource_id), None) => { + AdditionalResource::from_id(db, &additional_resource_id)?.publisher_id(db) + } + (None, None, None, Some(work_featured_video_id)) => { + WorkFeaturedVideo::from_id(db, &work_featured_video_id)?.publisher_id(db) + } + _ => Err(missing_scope_error), + } +} + +impl Crud for File { + type NewEntity = NewFile; + type PatchEntity = NewFile; + type OrderByEntity = (); + type FilterParameter1 = (); + type FilterParameter2 = (); + type FilterParameter3 = (); + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.file_id + } + + fn all( + _db: &PgPool, + _limit: i32, + _offset: i32, + _filter: Option<String>, + _order: Self::OrderByEntity, + _publishers: Vec<Uuid>, + _parent_id_1: Option<Uuid>, + _parent_id_2: Option<Uuid>, + _filter_param_1: Vec<Self::FilterParameter1>, + _filter_param_2: Vec<Self::FilterParameter2>, + _filter_param_3: Option<Self::FilterParameter3>, + _filter_param_4: Option<Self::FilterParameter4>, + ) -> ThothResult<Vec<File>> { + unimplemented!() + } + + fn count( + _db: &PgPool, + _filter: Option<String>, + _publishers: Vec<Uuid>, + _filter_param_1: Vec<Self::FilterParameter1>, + _filter_param_2: Vec<Self::FilterParameter2>, + _filter_param_3: Option<Self::FilterParameter3>, + _filter_param_4: Option<Self::FilterParameter4>, + ) -> ThothResult<i32> { + unimplemented!() + } + + fn from_id(db: &PgPool, entity_id: &Uuid) -> ThothResult<Self> { + let mut connection = db.get()?; + file::table + .find(entity_id) + .get_result::<File>(&mut connection) + .map_err(ThothError::from) + } + + fn create(db: &PgPool, data: &NewFile) -> ThothResult<Self> { + let mut connection = db.get()?; + diesel::insert_into(file::table) + .values(data) + .get_result::<File>(&mut connection) + .map_err(ThothError::from) + } + + fn update<C: PolicyContext>(&self, _ctx: &C, _data: &NewFile) -> ThothResult<Self> { + unimplemented!() + } + + fn delete(self, db: &PgPool) -> ThothResult<Self> { + let mut connection = db.get()?; + diesel::delete(file::table.find(self.file_id)) + .execute(&mut connection) + .map(|_| self) + .map_err(ThothError::from) + } +} + +impl Crud for FileUpload { + type NewEntity = NewFileUpload; + type PatchEntity = NewFileUpload; + type OrderByEntity = (); + type FilterParameter1 = (); + type FilterParameter2 = (); + type FilterParameter3 = (); + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.file_upload_id + } + + fn all( + _db: &PgPool, + _limit: i32, + _offset: i32, + _filter: Option<String>, + _order: Self::OrderByEntity, + _publishers: Vec<Uuid>, + _parent_id_1: Option<Uuid>, + _parent_id_2: Option<Uuid>, + _filter_param_1: Vec<Self::FilterParameter1>, + _filter_param_2: Vec<Self::FilterParameter2>, + _filter_param_3: Option<Self::FilterParameter3>, + _filter_param_4: Option<Self::FilterParameter4>, + ) -> ThothResult<Vec<FileUpload>> { + unimplemented!() + } + + fn count( + _db: &PgPool, + _filter: Option<String>, + _publishers: Vec<Uuid>, + _filter_param_1: Vec<Self::FilterParameter1>, + _filter_param_2: Vec<Self::FilterParameter2>, + _filter_param_3: Option<Self::FilterParameter3>, + _filter_param_4: Option<Self::FilterParameter4>, + ) -> ThothResult<i32> { + unimplemented!() + } + + fn from_id(db: &PgPool, entity_id: &Uuid) -> ThothResult<Self> { + let mut connection = db.get()?; + file_upload::table + .find(entity_id) + .get_result::<FileUpload>(&mut connection) + .map_err(ThothError::from) + } + + fn create(db: &PgPool, data: &NewFileUpload) -> ThothResult<Self> { + let mut connection = db.get()?; + diesel::insert_into(file_upload::table) + .values(data) + .get_result::<FileUpload>(&mut connection) + .map_err(ThothError::from) + } + + fn update<C: PolicyContext>(&self, _ctx: &C, _data: &NewFileUpload) -> ThothResult<Self> { + unimplemented!() + } + + fn delete(self, db: &PgPool) -> ThothResult<Self> { + let mut connection = db.get()?; + diesel::delete(file_upload::table.find(self.file_upload_id)) + .execute(&mut connection) + .map(|_| self) + .map_err(ThothError::from) + } +} + +impl PublisherId for File { + fn publisher_id(&self, db: &PgPool) -> ThothResult<Uuid> { + publisher_id_from_scope( + db, + self.work_id, + self.publication_id, + self.additional_resource_id, + self.work_featured_video_id, + ThothError::FileMissingWorkOrPublicationId, + ) + } +} + +impl PublisherId for NewFile { + fn publisher_id(&self, db: &PgPool) -> ThothResult<Uuid> { + publisher_id_from_scope( + db, + self.work_id, + self.publication_id, + self.additional_resource_id, + self.work_featured_video_id, + ThothError::FileMissingWorkOrPublicationId, + ) + } +} + +impl PublisherId for FileUpload { + fn publisher_id(&self, db: &PgPool) -> ThothResult<Uuid> { + publisher_id_from_scope( + db, + self.work_id, + self.publication_id, + self.additional_resource_id, + self.work_featured_video_id, + ThothError::FileUploadMissingWorkOrPublicationId, + ) + } +} + +impl PublisherId for NewFileUpload { + fn publisher_id(&self, db: &PgPool) -> ThothResult<Uuid> { + publisher_id_from_scope( + db, + self.work_id, + self.publication_id, + self.additional_resource_id, + self.work_featured_video_id, + ThothError::FileUploadMissingWorkOrPublicationId, + ) + } +} + +impl File { + fn deduplicate_cleanup_candidates( + candidates: Vec<FileCleanupCandidate>, + ) -> Vec<FileCleanupCandidate> { + let mut seen = HashSet::new(); + let mut deduplicated = Vec::new(); + + for candidate in candidates { + if seen.insert(candidate.object_key.clone()) { + deduplicated.push(candidate); + } + } + + deduplicated.sort_by(|a, b| a.object_key.cmp(&b.object_key)); + deduplicated + } + + fn to_cleanup_candidates(files: Vec<File>) -> Vec<FileCleanupCandidate> { + files + .into_iter() + .map(|file| FileCleanupCandidate { + file_type: file.file_type, + object_key: file.object_key, + }) + .collect() + } + + fn to_pending_upload_cleanup_candidates(uploads: Vec<FileUpload>) -> Vec<FileCleanupCandidate> { + uploads + .into_iter() + .map(|upload| FileCleanupCandidate { + file_type: upload.file_type, + object_key: temp_key(&upload.file_upload_id), + }) + .collect() + } + + pub fn cleanup_candidates_for_publication( + db: &PgPool, + publication_id: &Uuid, + ) -> ThothResult<Vec<FileCleanupCandidate>> { + use crate::schema::{file::dsl as file_dsl, file_upload::dsl as file_upload_dsl}; + + let mut connection = db.get()?; + let files = file_dsl::file + .filter(file_dsl::publication_id.eq(Some(*publication_id))) + .load::<File>(&mut connection) + .map_err(ThothError::from)?; + let uploads = file_upload_dsl::file_upload + .filter(file_upload_dsl::publication_id.eq(Some(*publication_id))) + .load::<FileUpload>(&mut connection) + .map_err(ThothError::from)?; + + let mut candidates = Self::to_cleanup_candidates(files); + candidates.extend(Self::to_pending_upload_cleanup_candidates(uploads)); + + Ok(Self::deduplicate_cleanup_candidates(candidates)) + } + + pub fn cleanup_candidates_for_additional_resource( + db: &PgPool, + additional_resource_id: &Uuid, + ) -> ThothResult<Vec<FileCleanupCandidate>> { + use crate::schema::{file::dsl as file_dsl, file_upload::dsl as file_upload_dsl}; + + let mut connection = db.get()?; + let files = file_dsl::file + .filter(file_dsl::additional_resource_id.eq(Some(*additional_resource_id))) + .load::<File>(&mut connection) + .map_err(ThothError::from)?; + let uploads = file_upload_dsl::file_upload + .filter(file_upload_dsl::additional_resource_id.eq(Some(*additional_resource_id))) + .load::<FileUpload>(&mut connection) + .map_err(ThothError::from)?; + + let mut candidates = Self::to_cleanup_candidates(files); + candidates.extend(Self::to_pending_upload_cleanup_candidates(uploads)); + + Ok(Self::deduplicate_cleanup_candidates(candidates)) + } + + pub fn cleanup_candidates_for_work_featured_video( + db: &PgPool, + work_featured_video_id: &Uuid, + ) -> ThothResult<Vec<FileCleanupCandidate>> { + use crate::schema::{file::dsl as file_dsl, file_upload::dsl as file_upload_dsl}; + + let mut connection = db.get()?; + let files = file_dsl::file + .filter(file_dsl::work_featured_video_id.eq(Some(*work_featured_video_id))) + .load::<File>(&mut connection) + .map_err(ThothError::from)?; + let uploads = file_upload_dsl::file_upload + .filter(file_upload_dsl::work_featured_video_id.eq(Some(*work_featured_video_id))) + .load::<FileUpload>(&mut connection) + .map_err(ThothError::from)?; + + let mut candidates = Self::to_cleanup_candidates(files); + candidates.extend(Self::to_pending_upload_cleanup_candidates(uploads)); + + Ok(Self::deduplicate_cleanup_candidates(candidates)) + } + + pub fn cleanup_candidates_for_work( + db: &PgPool, + work_id: &Uuid, + ) -> ThothResult<Vec<FileCleanupCandidate>> { + use crate::schema::additional_resource::dsl as additional_resource_dsl; + use crate::schema::publication::dsl as publication_dsl; + use crate::schema::work_featured_video::dsl as work_featured_video_dsl; + + let mut connection = db.get()?; + + let publication_ids = publication_dsl::publication + .filter(publication_dsl::work_id.eq(*work_id)) + .select(publication_dsl::publication_id) + .load::<Uuid>(&mut connection) + .map_err(ThothError::from)?; + + let additional_resource_ids = additional_resource_dsl::additional_resource + .filter(additional_resource_dsl::work_id.eq(*work_id)) + .select(additional_resource_dsl::additional_resource_id) + .load::<Uuid>(&mut connection) + .map_err(ThothError::from)?; + + let work_featured_video_ids = work_featured_video_dsl::work_featured_video + .filter(work_featured_video_dsl::work_id.eq(*work_id)) + .select(work_featured_video_dsl::work_featured_video_id) + .load::<Uuid>(&mut connection) + .map_err(ThothError::from)?; + + let mut candidates = Vec::new(); + + let direct_work_files = crate::schema::file::dsl::file + .filter(crate::schema::file::dsl::work_id.eq(Some(*work_id))) + .load::<File>(&mut connection) + .map_err(ThothError::from)?; + candidates.extend(Self::to_cleanup_candidates(direct_work_files)); + let direct_work_uploads = crate::schema::file_upload::dsl::file_upload + .filter(crate::schema::file_upload::dsl::work_id.eq(Some(*work_id))) + .load::<FileUpload>(&mut connection) + .map_err(ThothError::from)?; + candidates.extend(Self::to_pending_upload_cleanup_candidates( + direct_work_uploads, + )); + + if !publication_ids.is_empty() { + let publication_files = crate::schema::file::dsl::file + .filter(crate::schema::file::dsl::publication_id.eq_any(&publication_ids)) + .load::<File>(&mut connection) + .map_err(ThothError::from)?; + candidates.extend(Self::to_cleanup_candidates(publication_files)); + let publication_uploads = crate::schema::file_upload::dsl::file_upload + .filter(crate::schema::file_upload::dsl::publication_id.eq_any(&publication_ids)) + .load::<FileUpload>(&mut connection) + .map_err(ThothError::from)?; + candidates.extend(Self::to_pending_upload_cleanup_candidates( + publication_uploads, + )); + } + + if !additional_resource_ids.is_empty() { + let additional_resource_files = crate::schema::file::dsl::file + .filter( + crate::schema::file::dsl::additional_resource_id + .eq_any(&additional_resource_ids), + ) + .load::<File>(&mut connection) + .map_err(ThothError::from)?; + candidates.extend(Self::to_cleanup_candidates(additional_resource_files)); + let additional_resource_uploads = crate::schema::file_upload::dsl::file_upload + .filter( + crate::schema::file_upload::dsl::additional_resource_id + .eq_any(&additional_resource_ids), + ) + .load::<FileUpload>(&mut connection) + .map_err(ThothError::from)?; + candidates.extend(Self::to_pending_upload_cleanup_candidates( + additional_resource_uploads, + )); + } + + if !work_featured_video_ids.is_empty() { + let work_featured_video_files = crate::schema::file::dsl::file + .filter( + crate::schema::file::dsl::work_featured_video_id + .eq_any(&work_featured_video_ids), + ) + .load::<File>(&mut connection) + .map_err(ThothError::from)?; + candidates.extend(Self::to_cleanup_candidates(work_featured_video_files)); + let work_featured_video_uploads = crate::schema::file_upload::dsl::file_upload + .filter( + crate::schema::file_upload::dsl::work_featured_video_id + .eq_any(&work_featured_video_ids), + ) + .load::<FileUpload>(&mut connection) + .map_err(ThothError::from)?; + candidates.extend(Self::to_pending_upload_cleanup_candidates( + work_featured_video_uploads, + )); + } + + Ok(Self::deduplicate_cleanup_candidates(candidates)) + } + + pub fn from_object_key(db: &PgPool, object_key: &str) -> ThothResult<Self> { + use crate::schema::file::dsl; + + let mut connection = db.get()?; + dsl::file + .filter(dsl::object_key.eq(object_key)) + .first::<File>(&mut connection) + .map_err(ThothError::from) + } + + pub fn from_work_id(db: &PgPool, work_id: &Uuid) -> ThothResult<Option<Self>> { + use crate::schema::file::dsl; + + let mut connection = db.get()?; + dsl::file + .filter(dsl::work_id.eq(work_id)) + .filter(dsl::file_type.eq(FileType::Frontcover)) + .first::<File>(&mut connection) + .optional() + .map_err(ThothError::from) + } + + pub fn from_publication_id(db: &PgPool, publication_id: &Uuid) -> ThothResult<Option<Self>> { + use crate::schema::file::dsl; + + let mut connection = db.get()?; + dsl::file + .filter(dsl::publication_id.eq(publication_id)) + .filter(dsl::file_type.eq(FileType::Publication)) + .first::<File>(&mut connection) + .optional() + .map_err(ThothError::from) + } + + pub fn from_additional_resource_id( + db: &PgPool, + additional_resource_id: &Uuid, + ) -> ThothResult<Option<Self>> { + use crate::schema::file::dsl; + + let mut connection = db.get()?; + dsl::file + .filter(dsl::additional_resource_id.eq(additional_resource_id)) + .filter(dsl::file_type.eq(FileType::AdditionalResource)) + .first::<File>(&mut connection) + .optional() + .map_err(ThothError::from) + } + + pub fn from_work_featured_video_id( + db: &PgPool, + work_featured_video_id: &Uuid, + ) -> ThothResult<Option<Self>> { + use crate::schema::file::dsl; + + let mut connection = db.get()?; + dsl::file + .filter(dsl::work_featured_video_id.eq(work_featured_video_id)) + .filter(dsl::file_type.eq(FileType::WorkFeaturedVideo)) + .first::<File>(&mut connection) + .optional() + .map_err(ThothError::from) + } +} + +impl NewFileUpload { + pub(crate) async fn create_upload_response( + &self, + db: &PgPool, + s3_client: &S3Client, + storage_config: &StorageConfig, + expires_in_minutes: u64, + ) -> ThothResult<FileUploadResponse> { + let file_upload = FileUpload::create(db, self)?; + let temp_object_key = temp_key(&file_upload.file_upload_id); + let upload_url = presign_put_for_upload( + s3_client, + &storage_config.s3_bucket, + &temp_object_key, + &self.declared_mime_type, + &self.declared_sha256, + expires_in_minutes, + ) + .await?; + + let upload_headers = + upload_request_headers(&self.declared_mime_type, &self.declared_sha256)?; + + Ok(FileUploadResponse { + file_upload_id: file_upload.file_upload_id, + upload_url, + upload_headers, + expires_at: upload_expires_at(expires_in_minutes as i64)?, + }) + } +} + +impl FileUpload { + pub(crate) fn load_scope<C: PolicyContext>(&self, ctx: &C) -> ThothResult<FileUploadScope> { + match self.file_type { + FileType::Publication => { + let publication_id = self + .publication_id + .ok_or(ThothError::PublicationFileUploadMissingPublicationId)?; + let publication: Publication = ctx.load_current(&publication_id)?; + let work: Work = ctx.load_current(&publication.work_id)?; + Ok((work, Some(publication), None, None)) + } + FileType::Frontcover => { + let work_id = self + .work_id + .ok_or(ThothError::FrontcoverFileUploadMissingWorkId)?; + let work: Work = ctx.load_current(&work_id)?; + Ok((work, None, None, None)) + } + FileType::AdditionalResource => { + let additional_resource_id = self + .additional_resource_id + .ok_or(ThothError::AdditionalResourceFileUploadMissingAdditionalResourceId)?; + let additional_resource: AdditionalResource = + ctx.load_current(&additional_resource_id)?; + let work: Work = ctx.load_current(&additional_resource.work_id)?; + Ok((work, None, Some(additional_resource), None)) + } + FileType::WorkFeaturedVideo => { + let work_featured_video_id = self + .work_featured_video_id + .ok_or(ThothError::WorkFeaturedVideoFileUploadMissingWorkFeaturedVideoId)?; + let work_featured_video: WorkFeaturedVideo = + ctx.load_current(&work_featured_video_id)?; + let work: Work = ctx.load_current(&work_featured_video.work_id)?; + Ok((work, None, None, Some(work_featured_video))) + } + } + } + + pub(crate) fn canonical_key(&self, doi: &Doi) -> ThothResult<String> { + let doi_prefix = doi.prefix(); + let doi_suffix = doi.suffix(); + + match self.file_type { + FileType::Publication => Ok(canonical_publication_key( + doi_prefix, + doi_suffix, + &self.declared_extension, + )), + FileType::Frontcover => Ok(canonical_frontcover_key( + doi_prefix, + doi_suffix, + &self.declared_extension, + )), + FileType::AdditionalResource => { + let additional_resource_id = self + .additional_resource_id + .ok_or(ThothError::AdditionalResourceFileUploadMissingAdditionalResourceId); + Ok(canonical_resource_key( + doi_prefix, + doi_suffix, + &additional_resource_id?, + &self.declared_extension, + )) + } + FileType::WorkFeaturedVideo => { + let work_featured_video_id = self + .work_featured_video_id + .ok_or(ThothError::WorkFeaturedVideoFileUploadMissingWorkFeaturedVideoId); + Ok(canonical_resource_key( + doi_prefix, + doi_suffix, + &work_featured_video_id?, + &self.declared_extension, + )) + } + } + } + + pub(crate) fn existing_file(&self, db: &PgPool) -> ThothResult<Option<File>> { + match self.file_type { + FileType::Publication => { + let publication_id = self + .publication_id + .ok_or(ThothError::PublicationFileUploadMissingPublicationId)?; + File::from_publication_id(db, &publication_id) + } + FileType::Frontcover => { + let work_id = self + .work_id + .ok_or(ThothError::FrontcoverFileUploadMissingWorkId)?; + File::from_work_id(db, &work_id) + } + FileType::AdditionalResource => { + let additional_resource_id = self + .additional_resource_id + .ok_or(ThothError::AdditionalResourceFileUploadMissingAdditionalResourceId)?; + File::from_additional_resource_id(db, &additional_resource_id) + } + FileType::WorkFeaturedVideo => { + let work_featured_video_id = self + .work_featured_video_id + .ok_or(ThothError::WorkFeaturedVideoFileUploadMissingWorkFeaturedVideoId)?; + File::from_work_featured_video_id(db, &work_featured_video_id) + } + } + } + + pub(crate) fn persist_file_record<C: PolicyContext>( + &self, + ctx: &C, + canonical_key: &str, + cdn_url: &str, + mime_type: &str, + bytes: i64, + ) -> ThothResult<(File, Option<String>)> { + use crate::schema::file::dsl as file_dsl; + + let existing_file = self.existing_file(ctx.db())?; + let old_object_key = existing_file.as_ref().map(|file| file.object_key.clone()); + + let file = if let Some(existing) = existing_file { + let mut connection = ctx.db().get()?; + diesel::update(file_dsl::file.find(existing.file_id)) + .set(( + file_dsl::object_key.eq(canonical_key), + file_dsl::cdn_url.eq(cdn_url), + file_dsl::mime_type.eq(mime_type), + file_dsl::bytes.eq(bytes), + file_dsl::sha256.eq(&self.declared_sha256), + )) + .get_result::<File>(&mut connection) + .map_err(ThothError::from)? + } else { + let new_file = NewFile { + file_type: self.file_type, + work_id: self.work_id, + publication_id: self.publication_id, + additional_resource_id: self.additional_resource_id, + work_featured_video_id: self.work_featured_video_id, + object_key: canonical_key.to_string(), + cdn_url: cdn_url.to_string(), + mime_type: mime_type.to_string(), + bytes, + sha256: self.declared_sha256.clone(), + }; + FilePolicy::can_create(ctx, &new_file, ())?; + File::create(ctx.db(), &new_file)? + }; + + Ok((file, old_object_key)) + } + + pub(crate) fn sync_related_metadata<C: PolicyContext>( + &self, + ctx: &C, + work: &Work, + cdn_url: &str, + featured_video_dimensions: Option<(i32, i32)>, + ) -> ThothResult<()> { + match self.file_type { + FileType::Frontcover => { + let mut patch: PatchWork = work.clone().into(); + patch.cover_url = Some(cdn_url.to_string()); + work.update(ctx, &patch)?; + } + FileType::Publication => { + let publication_id = self + .publication_id + .ok_or(ThothError::PublicationFileUploadMissingPublicationId)?; + Self::upsert_thoth_location( + ctx, + publication_id, + work.landing_page.clone(), + cdn_url, + )?; + } + FileType::AdditionalResource => { + let additional_resource_id = self + .additional_resource_id + .ok_or(ThothError::AdditionalResourceFileUploadMissingAdditionalResourceId)?; + let additional_resource: AdditionalResource = + ctx.load_current(&additional_resource_id)?; + let patch = PatchAdditionalResource { + additional_resource_id: additional_resource.additional_resource_id, + work_id: additional_resource.work_id, + title: additional_resource.title.clone(), + description: additional_resource.description.clone(), + attribution: additional_resource.attribution.clone(), + resource_type: additional_resource.resource_type, + doi: additional_resource.doi.clone(), + handle: additional_resource.handle.clone(), + url: Some(cdn_url.to_string()), + date: additional_resource.date, + resource_ordinal: additional_resource.resource_ordinal, + }; + additional_resource.update(ctx, &patch)?; + } + FileType::WorkFeaturedVideo => { + let work_featured_video_id = self + .work_featured_video_id + .ok_or(ThothError::WorkFeaturedVideoFileUploadMissingWorkFeaturedVideoId)?; + let work_featured_video: WorkFeaturedVideo = + ctx.load_current(&work_featured_video_id)?; + let (width, height) = featured_video_dimensions + .unwrap_or((work_featured_video.width, work_featured_video.height)); + let patch = PatchWorkFeaturedVideo { + work_featured_video_id: work_featured_video.work_featured_video_id, + work_id: work_featured_video.work_id, + title: work_featured_video.title.clone(), + url: Some(cdn_url.to_string()), + width, + height, + }; + work_featured_video.update(ctx, &patch)?; + } + } + + Ok(()) + } + + fn upsert_thoth_location<C: PolicyContext>( + ctx: &C, + publication_id: Uuid, + landing_page: Option<String>, + full_text_url: &str, + ) -> ThothResult<()> { + use crate::schema::location::dsl; + + let mut connection = ctx.db().get()?; + + let thoth_location = dsl::location + .filter(dsl::publication_id.eq(publication_id)) + .filter(dsl::location_platform.eq(LocationPlatform::Thoth)) + .first::<Location>(&mut connection) + .optional() + .map_err(ThothError::from)?; + + if let Some(location) = thoth_location { + let mut patch = PatchLocation::from(location.clone()); + patch.full_text_url = Some(full_text_url.to_string()); + patch.landing_page = landing_page; + patch.canonical = true; + if patch.canonical { + patch.canonical_record_complete(ctx.db())?; + } + location.update(ctx, &patch)?; + return Ok(()); + } + + let existing_canonical = dsl::location + .filter(dsl::publication_id.eq(publication_id)) + .filter(dsl::canonical.eq(true)) + .first::<Location>(&mut connection) + .optional() + .map_err(ThothError::from)?; + + if existing_canonical.is_some() { + let new_location = NewLocation { + publication_id, + landing_page, + full_text_url: Some(full_text_url.to_string()), + location_platform: LocationPlatform::Thoth, + canonical: false, + }; + let created_location = Location::create(ctx.db(), &new_location)?; + let mut patch = PatchLocation::from(created_location.clone()); + patch.canonical = true; + if patch.canonical { + patch.canonical_record_complete(ctx.db())?; + } + created_location.update(ctx, &patch)?; + } else { + let new_location = NewLocation { + publication_id, + landing_page, + full_text_url: Some(full_text_url.to_string()), + location_platform: LocationPlatform::Thoth, + canonical: true, + }; + new_location.canonical_record_complete(ctx.db())?; + Location::create(ctx.db(), &new_location)?; + } + + Ok(()) + } +} diff --git a/thoth-api/src/model/file/mod.rs b/thoth-api/src/model/file/mod.rs new file mode 100644 index 00000000..5e83912d --- /dev/null +++ b/thoth-api/src/model/file/mod.rs @@ -0,0 +1,335 @@ +use serde::Deserialize; +use serde::Serialize; +use strum::Display; +use strum::EnumString; +use uuid::Uuid; + +use crate::model::Timestamp; +#[cfg(feature = "backend")] +use crate::schema::file; +#[cfg(feature = "backend")] +use crate::schema::file_upload; +#[cfg(feature = "backend")] +use thoth_errors::{ThothError, ThothResult}; + +#[cfg_attr( + feature = "backend", + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), + graphql(description = "Type of file being uploaded"), + ExistingTypePath = "crate::schema::sql_types::FileType" +)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, EnumString, Display)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "lowercase")] +pub enum FileType { + #[cfg_attr( + feature = "backend", + db_rename = "publication", + graphql(description = "Publication file (PDF, EPUB, XML, etc.)") + )] + Publication, + #[cfg_attr( + feature = "backend", + db_rename = "frontcover", + graphql(description = "Front cover image") + )] + Frontcover, + #[cfg_attr( + feature = "backend", + db_rename = "additional_resource", + graphql(description = "Additional resource file (audio, video, image, spreadsheet, etc.)") + )] + #[strum(serialize = "additional_resource")] + AdditionalResource, + #[cfg_attr( + feature = "backend", + db_rename = "work_featured_video", + graphql(description = "Featured video file hosted on CDN") + )] + #[strum(serialize = "work_featured_video")] + WorkFeaturedVideo, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct File { + pub file_id: Uuid, + pub file_type: FileType, + pub work_id: Option<Uuid>, + pub publication_id: Option<Uuid>, + pub additional_resource_id: Option<Uuid>, + pub work_featured_video_id: Option<Uuid>, + pub object_key: String, + pub cdn_url: String, + pub mime_type: String, + pub bytes: i64, + pub sha256: String, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[cfg(feature = "backend")] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FileCleanupCandidate { + pub file_type: FileType, + pub object_key: String, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FileUpload { + pub file_upload_id: Uuid, + pub file_type: FileType, + pub work_id: Option<Uuid>, + pub publication_id: Option<Uuid>, + pub additional_resource_id: Option<Uuid>, + pub work_featured_video_id: Option<Uuid>, + pub declared_mime_type: String, + pub declared_extension: String, + pub declared_sha256: String, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::Insertable), + graphql(description = "Input for starting a publication file upload"), + diesel(table_name = file_upload) +)] +pub struct NewFileUpload { + pub file_type: FileType, + pub work_id: Option<Uuid>, + pub publication_id: Option<Uuid>, + pub additional_resource_id: Option<Uuid>, + pub work_featured_video_id: Option<Uuid>, + pub declared_mime_type: String, + pub declared_extension: String, + pub declared_sha256: String, +} + +#[cfg_attr( + feature = "backend", + derive(diesel::Insertable), + diesel(table_name = file) +)] +pub struct NewFile { + pub file_type: FileType, + pub work_id: Option<Uuid>, + pub publication_id: Option<Uuid>, + pub additional_resource_id: Option<Uuid>, + pub work_featured_video_id: Option<Uuid>, + pub object_key: String, + pub cdn_url: String, + pub mime_type: String, + pub bytes: i64, + pub sha256: String, +} + +#[cfg(feature = "backend")] +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Input for starting a publication file upload (PDF, EPUB, XML, etc.).")] +pub struct NewPublicationFileUpload { + #[graphql(description = "Thoth ID of the publication linked to this file.")] + pub publication_id: Uuid, + #[graphql( + description = "MIME type declared by the client (used for validation and in the presigned URL)." + )] + pub declared_mime_type: String, + #[graphql( + description = "File extension to use in the final canonical key, e.g. 'pdf', 'epub', 'xml'." + )] + pub declared_extension: String, + #[graphql(description = "SHA-256 checksum of the file, hex-encoded.")] + pub declared_sha256: String, +} + +#[cfg(feature = "backend")] +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Input for starting a front cover upload for a work.")] +pub struct NewFrontcoverFileUpload { + #[graphql(description = "Thoth ID of the work this front cover belongs to.")] + pub work_id: Uuid, + #[graphql(description = "MIME type declared by the client (e.g. 'image/jpeg').")] + pub declared_mime_type: String, + #[graphql( + description = "File extension to use in the final canonical key, e.g. 'jpg', 'png', 'webp'." + )] + pub declared_extension: String, + #[graphql(description = "SHA-256 checksum of the file, hex-encoded.")] + pub declared_sha256: String, +} + +#[cfg(feature = "backend")] +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Input for starting an upload for an additional resource asset.")] +pub struct NewAdditionalResourceFileUpload { + #[graphql(description = "Thoth ID of the additional resource linked to this file.")] + pub additional_resource_id: Uuid, + #[graphql( + description = "MIME type declared by the client (used for validation and in the presigned URL)." + )] + pub declared_mime_type: String, + #[graphql( + description = "File extension to use in the final canonical key, e.g. 'jpg', 'png', 'mp4', 'xlsx'." + )] + pub declared_extension: String, + #[graphql(description = "SHA-256 checksum of the file, hex-encoded.")] + pub declared_sha256: String, +} + +#[cfg(feature = "backend")] +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Input for starting an upload for a work featured video.")] +pub struct NewWorkFeaturedVideoFileUpload { + #[graphql(description = "Thoth ID of the work featured video linked to this file.")] + pub work_featured_video_id: Uuid, + #[graphql( + description = "MIME type declared by the client (used for validation and in the presigned URL)." + )] + pub declared_mime_type: String, + #[graphql( + description = "File extension to use in the final canonical key, e.g. 'mp4', 'webm', 'mov'." + )] + pub declared_extension: String, + #[graphql(description = "SHA-256 checksum of the file, hex-encoded.")] + pub declared_sha256: String, +} + +#[cfg(feature = "backend")] +#[derive(juniper::GraphQLInputObject)] +#[graphql( + description = "Input for completing a file upload and promoting it to its final DOI-based location." +)] +pub struct CompleteFileUpload { + #[graphql(description = "ID of the upload session to complete.")] + pub file_upload_id: Uuid, +} + +#[cfg(feature = "backend")] +#[derive(juniper::GraphQLObject)] +#[graphql( + description = "Response from initiating a file upload, containing the upload URL and expiration time." +)] +pub struct FileUploadResponse { + #[graphql(description = "ID of the upload session.")] + pub file_upload_id: Uuid, + #[graphql(description = "Presigned S3 PUT URL for uploading the file.")] + pub upload_url: String, + #[graphql(description = "Headers that must be sent with the HTTP PUT request to uploadUrl.")] + pub upload_headers: Vec<UploadRequestHeader>, + #[graphql(description = "Time when the upload URL expires.")] + pub expires_at: Timestamp, +} + +#[cfg(feature = "backend")] +#[derive(juniper::GraphQLObject)] +#[graphql(description = "Single required HTTP header for presigned file upload.")] +pub struct UploadRequestHeader { + #[graphql(description = "HTTP header name.")] + pub name: String, + #[graphql(description = "HTTP header value.")] + pub value: String, +} + +#[cfg(feature = "backend")] +pub fn upload_request_headers( + declared_mime_type: &str, + declared_sha256: &str, +) -> ThothResult<Vec<UploadRequestHeader>> { + use base64::{engine::general_purpose, Engine as _}; + + let sha256_bytes = hex::decode(declared_sha256) + .map_err(|e| ThothError::InternalError(format!("Invalid SHA-256 hex: {}", e)))?; + let sha256_base64 = general_purpose::STANDARD.encode(sha256_bytes); + + Ok(vec![ + UploadRequestHeader { + name: "Content-Type".to_string(), + value: declared_mime_type.to_string(), + }, + UploadRequestHeader { + name: "x-amz-checksum-sha256".to_string(), + value: sha256_base64, + }, + UploadRequestHeader { + name: "x-amz-sdk-checksum-algorithm".to_string(), + value: "SHA256".to_string(), + }, + ]) +} + +#[cfg(feature = "backend")] +impl From<NewPublicationFileUpload> for NewFileUpload { + fn from(data: NewPublicationFileUpload) -> Self { + NewFileUpload { + file_type: FileType::Publication, + work_id: None, + publication_id: Some(data.publication_id), + additional_resource_id: None, + work_featured_video_id: None, + declared_mime_type: data.declared_mime_type, + declared_extension: data.declared_extension.to_lowercase(), + declared_sha256: data.declared_sha256, + } + } +} + +#[cfg(feature = "backend")] +impl From<NewFrontcoverFileUpload> for NewFileUpload { + fn from(data: NewFrontcoverFileUpload) -> Self { + NewFileUpload { + file_type: FileType::Frontcover, + work_id: Some(data.work_id), + publication_id: None, + additional_resource_id: None, + work_featured_video_id: None, + declared_mime_type: data.declared_mime_type, + declared_extension: data.declared_extension.to_lowercase(), + declared_sha256: data.declared_sha256, + } + } +} + +#[cfg(feature = "backend")] +impl From<NewAdditionalResourceFileUpload> for NewFileUpload { + fn from(data: NewAdditionalResourceFileUpload) -> Self { + NewFileUpload { + file_type: FileType::AdditionalResource, + work_id: None, + publication_id: None, + additional_resource_id: Some(data.additional_resource_id), + work_featured_video_id: None, + declared_mime_type: data.declared_mime_type, + declared_extension: data.declared_extension.to_lowercase(), + declared_sha256: data.declared_sha256, + } + } +} + +#[cfg(feature = "backend")] +impl From<NewWorkFeaturedVideoFileUpload> for NewFileUpload { + fn from(data: NewWorkFeaturedVideoFileUpload) -> Self { + NewFileUpload { + file_type: FileType::WorkFeaturedVideo, + work_id: None, + publication_id: None, + additional_resource_id: None, + work_featured_video_id: Some(data.work_featured_video_id), + declared_mime_type: data.declared_mime_type, + declared_extension: data.declared_extension.to_lowercase(), + declared_sha256: data.declared_sha256, + } + } +} + +#[cfg(feature = "backend")] +pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::FilePolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/file/policy.rs b/thoth-api/src/model/file/policy.rs new file mode 100644 index 00000000..374c8add --- /dev/null +++ b/thoth-api/src/model/file/policy.rs @@ -0,0 +1,366 @@ +use super::{File, FileType, FileUpload, NewFile, NewFileUpload}; +use crate::model::additional_resource::ResourceType; +use crate::model::publication::PublicationType; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext}; +use thoth_errors::{ThothError, ThothResult}; + +const KIB: i64 = 1024; +const MIB: i64 = 1024 * 1024; +const GIB: i64 = 1024 * 1024 * 1024; +const MIN_PUBLICATION_BYTES: i64 = 50 * KIB; +const MAX_PUBLICATION_BYTES: i64 = 5 * GIB; +const MIN_FRONTCOVER_BYTES: i64 = 50 * KIB; +const MAX_FRONTCOVER_BYTES: i64 = 50 * MIB; +const MIN_RESOURCE_BYTES: i64 = 1; +const MAX_RESOURCE_BYTES: i64 = 5 * GIB; + +/// Write policies for `File` and `FileUpload`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring CDN write permissions scoped to the linked publisher +pub struct FilePolicy; + +impl FilePolicy { + fn normalize_mime_type(mime_type: &str) -> String { + mime_type + .split(';') + .next() + .unwrap_or(mime_type) + .trim() + .to_ascii_lowercase() + } + + fn resource_extensions(resource_type: ResourceType) -> ThothResult<&'static [&'static str]> { + match resource_type { + ResourceType::Audio => Ok(&["mp3", "wav", "ogg", "m4a", "flac"]), + ResourceType::Video => Ok(&["mp4", "webm", "mov", "m4v"]), + ResourceType::Image => Ok(&["jpg", "jpeg", "png", "webp", "gif", "svg", "tif", "tiff"]), + ResourceType::Document => Ok(&["pdf", "doc", "docx", "txt", "rtf"]), + ResourceType::Dataset => Ok(&["csv", "tsv", "json", "zip", "parquet"]), + ResourceType::Spreadsheet => Ok(&["csv", "tsv", "xls", "xlsx", "ods"]), + _ => Err(ThothError::UnsupportedResourceTypeForFileUpload), + } + } + + fn resource_mime_types(resource_type: ResourceType) -> ThothResult<&'static [&'static str]> { + match resource_type { + ResourceType::Audio => Ok(&[ + "audio/mpeg", + "audio/mp3", + "audio/wav", + "audio/x-wav", + "audio/ogg", + "audio/flac", + "audio/mp4", + "application/octet-stream", + ]), + ResourceType::Video => Ok(&[ + "video/mp4", + "video/webm", + "video/quicktime", + "video/x-msvideo", + "application/octet-stream", + ]), + ResourceType::Image => Ok(&[ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + "image/svg+xml", + "image/tiff", + "application/octet-stream", + ]), + ResourceType::Document => Ok(&[ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "text/plain", + "application/rtf", + "application/octet-stream", + ]), + ResourceType::Dataset => Ok(&[ + "text/csv", + "text/tab-separated-values", + "application/json", + "application/zip", + "application/x-parquet", + "application/octet-stream", + ]), + ResourceType::Spreadsheet => Ok(&[ + "text/csv", + "text/tab-separated-values", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.oasis.opendocument.spreadsheet", + "application/octet-stream", + ]), + _ => Err(ThothError::UnsupportedResourceTypeForFileUpload), + } + } + + /// Validate extension for additional-resource and featured-video uploads. + pub(crate) fn validate_resource_file_extension( + extension: &str, + resource_type: ResourceType, + ) -> ThothResult<()> { + let allowed = Self::resource_extensions(resource_type)?; + if allowed.contains(&extension.to_lowercase().as_str()) { + Ok(()) + } else { + Err(ThothError::InvalidFileExtension) + } + } + + /// Validate MIME type for additional-resource and featured-video uploads. + pub(crate) fn validate_resource_file_mime_type( + resource_type: ResourceType, + mime_type: &str, + ) -> ThothResult<()> { + let mime_type = Self::normalize_mime_type(mime_type); + let allowed = Self::resource_mime_types(resource_type)?; + if allowed.contains(&mime_type.as_str()) { + Ok(()) + } else { + Err(ThothError::InvalidFileMimeType) + } + } + + /// Validate uploaded size limits for additional-resource and featured-video uploads. + pub(crate) fn validate_resource_file_size(bytes: i64) -> ThothResult<()> { + if bytes < MIN_RESOURCE_BYTES { + return Err(ThothError::FileTooSmall); + } + if bytes > MAX_RESOURCE_BYTES { + return Err(ThothError::FileTooLarge); + } + Ok(()) + } + + /// Validate file extension matches the file type and publication type (if applicable). + pub(crate) fn validate_file_extension( + extension: &str, + file_type: &FileType, + publication_type: Option<PublicationType>, + ) -> ThothResult<()> { + match file_type { + FileType::Frontcover => { + let valid_extensions = ["jpg", "jpeg", "png", "webp"]; + if !valid_extensions.contains(&extension.to_lowercase().as_str()) { + return Err(ThothError::InvalidFileExtension); + } + } + FileType::Publication => { + if let Some(pub_type) = publication_type { + let valid_extensions: Vec<&str> = match pub_type { + // PDF + PublicationType::Pdf => vec!["pdf"], + // EPUB + PublicationType::Epub => vec!["epub"], + // HTML (including HTM and ZIP archives containing HTML) + PublicationType::Html => vec!["html", "htm", "zip"], + // XML (including ZIP archives containing XML) + PublicationType::Xml => vec!["xml", "zip"], + // DOCX + PublicationType::Docx => vec!["docx"], + // MOBI + PublicationType::Mobi => vec!["mobi"], + // AZW3 + PublicationType::Azw3 => vec!["azw3"], + // FictionBook + PublicationType::FictionBook => vec!["fb2", "fb2.zip", "fbz", "zip"], + // MP3 audiobook + PublicationType::Mp3 => vec!["mp3"], + // WAV audiobook + PublicationType::Wav => vec!["wav"], + _ => return Err(ThothError::UnsupportedPublicationTypeForFileUpload), + }; + if !valid_extensions.contains(&extension.to_lowercase().as_str()) { + return Err(ThothError::InvalidFileExtension); + } + } else { + return Err(ThothError::PublicationTypeRequiredForFileValidation); + } + } + FileType::AdditionalResource | FileType::WorkFeaturedVideo => { + return Err(ThothError::UnsupportedResourceTypeForFileUpload); + } + } + Ok(()) + } + + /// Validate MIME type against file type/publication type allow-lists. + pub(crate) fn validate_file_mime_type( + extension: &str, + file_type: &FileType, + publication_type: Option<PublicationType>, + mime_type: &str, + ) -> ThothResult<()> { + let mime_type = Self::normalize_mime_type(mime_type); + match file_type { + FileType::Frontcover => { + let expected = match extension.to_ascii_lowercase().as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "webp" => "image/webp", + _ => return Err(ThothError::InvalidFileExtension), + }; + + if mime_type == expected { + Ok(()) + } else { + Err(ThothError::InvalidFileMimeType) + } + } + FileType::Publication => { + let publication_type = + publication_type.ok_or(ThothError::PublicationTypeRequiredForFileValidation)?; + + let accepted_mime_types: &[&str] = match publication_type { + PublicationType::Pdf => &["application/pdf", "application/octet-stream"], + PublicationType::Epub => &[ + "application/epub+zip", + "application/zip", + "application/octet-stream", + ], + PublicationType::Html => { + &["text/html", "application/zip", "application/octet-stream"] + } + PublicationType::Xml => &[ + "application/xml", + "text/xml", + "application/zip", + "application/octet-stream", + ], + PublicationType::Docx => &[ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/octet-stream", + ], + PublicationType::Mobi => { + &["application/x-mobipocket-ebook", "application/octet-stream"] + } + PublicationType::Azw3 => { + &["application/vnd.amazon.ebook", "application/octet-stream"] + } + PublicationType::FictionBook => &[ + "application/fictionbook2+zip", + "application/zip", + "application/octet-stream", + ], + PublicationType::Mp3 => { + &["audio/mp3", "audio/mpeg", "application/octet-stream"] + } + PublicationType::Wav => { + &["audio/wav", "audio/x-wav", "application/octet-stream"] + } + _ => return Err(ThothError::UnsupportedPublicationTypeForFileUpload), + }; + + if accepted_mime_types.contains(&mime_type.as_str()) { + Ok(()) + } else { + Err(ThothError::InvalidFileMimeType) + } + } + FileType::AdditionalResource | FileType::WorkFeaturedVideo => { + Err(ThothError::UnsupportedResourceTypeForFileUpload) + } + } + } + + /// Validate uploaded object size limits. + pub(crate) fn validate_file_size(bytes: i64, file_type: &FileType) -> ThothResult<()> { + let (min_bytes, max_bytes) = match file_type { + FileType::Publication => (MIN_PUBLICATION_BYTES, MAX_PUBLICATION_BYTES), + FileType::Frontcover => (MIN_FRONTCOVER_BYTES, MAX_FRONTCOVER_BYTES), + FileType::AdditionalResource | FileType::WorkFeaturedVideo => { + (MIN_RESOURCE_BYTES, MAX_RESOURCE_BYTES) + } + }; + + if bytes < min_bytes { + return Err(ThothError::FileTooSmall); + } + + if bytes > max_bytes { + return Err(ThothError::FileTooLarge); + } + + Ok(()) + } + + /// Authorisation and validation gate for completing an upload. + pub(crate) fn can_complete_upload<C: PolicyContext>( + ctx: &C, + upload: &FileUpload, + publication_type: Option<PublicationType>, + resource_type: Option<ResourceType>, + bytes: i64, + mime_type: &str, + ) -> ThothResult<()> { + Self::can_delete(ctx, upload)?; + match upload.file_type { + FileType::Frontcover | FileType::Publication => { + Self::validate_file_extension( + &upload.declared_extension, + &upload.file_type, + publication_type, + )?; + Self::validate_file_mime_type( + &upload.declared_extension, + &upload.file_type, + publication_type, + mime_type, + )?; + Self::validate_file_size(bytes, &upload.file_type)?; + } + FileType::AdditionalResource | FileType::WorkFeaturedVideo => { + let resource_type = + resource_type.ok_or(ThothError::UnsupportedResourceTypeForFileUpload)?; + Self::validate_resource_file_extension(&upload.declared_extension, resource_type)?; + Self::validate_resource_file_mime_type(resource_type, mime_type)?; + Self::validate_resource_file_size(bytes)?; + } + } + Ok(()) + } +} + +impl CreatePolicy<NewFile> for FilePolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewFile, _params: ()) -> ThothResult<()> { + ctx.require_cdn_write_for(data)?; + Ok(()) + } +} + +impl DeletePolicy<File> for FilePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, file: &File) -> ThothResult<()> { + ctx.require_cdn_write_for(file)?; + Ok(()) + } +} + +impl CreatePolicy<NewFileUpload, Option<PublicationType>> for FilePolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewFileUpload, + publication_type: Option<PublicationType>, + ) -> ThothResult<()> { + ctx.require_cdn_write_for(data)?; + Self::validate_file_extension(&data.declared_extension, &data.file_type, publication_type)?; + Self::validate_file_mime_type( + &data.declared_extension, + &data.file_type, + publication_type, + &data.declared_mime_type, + )?; + Ok(()) + } +} + +impl DeletePolicy<FileUpload> for FilePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, upload: &FileUpload) -> ThothResult<()> { + ctx.require_cdn_write_for(upload)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/file/tests.rs b/thoth-api/src/model/file/tests.rs new file mode 100644 index 00000000..04844c8d --- /dev/null +++ b/thoth-api/src/model/file/tests.rs @@ -0,0 +1,1525 @@ +use super::*; +use uuid::Uuid; + +const TEST_SHA256_HEX: &str = "444b138b41e3c48ca505b1740091b0c93ce9a71c7c9d24956e6cf8716f1aad7e"; + +#[cfg(feature = "backend")] +fn make_new_frontcover_file(work_id: Uuid, object_key: impl Into<String>) -> NewFile { + let object_key = object_key.into(); + NewFile { + file_type: FileType::Frontcover, + work_id: Some(work_id), + publication_id: None, + additional_resource_id: None, + work_featured_video_id: None, + object_key: object_key.clone(), + cdn_url: format!("https://cdn.example.org/{object_key}"), + mime_type: "image/jpeg".to_string(), + bytes: 1024, + sha256: TEST_SHA256_HEX.to_string(), + } +} + +#[cfg(feature = "backend")] +fn make_new_publication_file(publication_id: Uuid, object_key: impl Into<String>) -> NewFile { + let object_key = object_key.into(); + NewFile { + file_type: FileType::Publication, + work_id: None, + publication_id: Some(publication_id), + additional_resource_id: None, + work_featured_video_id: None, + object_key: object_key.clone(), + cdn_url: format!("https://cdn.example.org/{object_key}"), + mime_type: "application/pdf".to_string(), + bytes: 2048, + sha256: TEST_SHA256_HEX.to_string(), + } +} + +#[cfg(feature = "backend")] +fn make_new_frontcover_upload(work_id: Uuid, extension: impl Into<String>) -> NewFileUpload { + NewFileUpload { + file_type: FileType::Frontcover, + work_id: Some(work_id), + publication_id: None, + additional_resource_id: None, + work_featured_video_id: None, + declared_mime_type: "image/jpeg".to_string(), + declared_extension: extension.into(), + declared_sha256: TEST_SHA256_HEX.to_string(), + } +} + +#[cfg(feature = "backend")] +fn make_new_publication_upload( + publication_id: Uuid, + extension: impl Into<String>, +) -> NewFileUpload { + NewFileUpload { + file_type: FileType::Publication, + work_id: None, + publication_id: Some(publication_id), + additional_resource_id: None, + work_featured_video_id: None, + declared_mime_type: "application/pdf".to_string(), + declared_extension: extension.into(), + declared_sha256: TEST_SHA256_HEX.to_string(), + } +} + +#[cfg(feature = "backend")] +fn make_new_additional_resource_upload( + additional_resource_id: Uuid, + extension: impl Into<String>, +) -> NewFileUpload { + NewFileUpload { + file_type: FileType::AdditionalResource, + work_id: None, + publication_id: None, + additional_resource_id: Some(additional_resource_id), + work_featured_video_id: None, + declared_mime_type: "application/json".to_string(), + declared_extension: extension.into(), + declared_sha256: TEST_SHA256_HEX.to_string(), + } +} + +#[cfg(feature = "backend")] +fn make_new_work_featured_video_upload( + work_featured_video_id: Uuid, + extension: impl Into<String>, +) -> NewFileUpload { + NewFileUpload { + file_type: FileType::WorkFeaturedVideo, + work_id: None, + publication_id: None, + additional_resource_id: None, + work_featured_video_id: Some(work_featured_video_id), + declared_mime_type: "video/mp4".to_string(), + declared_extension: extension.into(), + declared_sha256: TEST_SHA256_HEX.to_string(), + } +} + +#[cfg(feature = "backend")] +fn create_pdf_publication( + pool: &crate::db::PgPool, + work_id: Uuid, +) -> crate::model::publication::Publication { + use crate::model::publication::{NewPublication, Publication, PublicationType}; + use crate::model::Crud; + + let new_publication = NewPublication { + publication_type: PublicationType::Pdf, + work_id, + isbn: None, + width_mm: None, + width_in: None, + height_mm: None, + height_in: None, + depth_mm: None, + depth_in: None, + weight_g: None, + weight_oz: None, + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + }; + + Publication::create(pool, &new_publication).expect("Failed to create PDF publication") +} + +#[cfg(feature = "backend")] +fn create_work_featured_video( + pool: &crate::db::PgPool, + work_id: Uuid, +) -> crate::model::work_featured_video::WorkFeaturedVideo { + use crate::model::work_featured_video::{NewWorkFeaturedVideo, WorkFeaturedVideo}; + use crate::model::Crud; + + let new_video = NewWorkFeaturedVideo { + work_id, + title: Some("Hosted video".to_string()), + url: None, + width: 560, + height: 315, + }; + + WorkFeaturedVideo::create(pool, &new_video).expect("Failed to create featured video") +} + +#[cfg(feature = "backend")] +fn create_additional_resource( + pool: &crate::db::PgPool, + work_id: Uuid, +) -> crate::model::additional_resource::AdditionalResource { + use crate::model::additional_resource::{ + AdditionalResource, NewAdditionalResource, ResourceType, + }; + use crate::model::Crud; + + let new_resource = NewAdditionalResource { + work_id, + title: "Dataset".to_string(), + description: None, + attribution: None, + resource_type: ResourceType::Dataset, + doi: None, + handle: None, + url: None, + date: None, + resource_ordinal: 1, + }; + + AdditionalResource::create(pool, &new_resource).expect("Failed to create additional resource") +} + +#[cfg(feature = "backend")] +fn make_new_additional_resource_file( + additional_resource_id: Uuid, + object_key: impl Into<String>, +) -> NewFile { + let object_key = object_key.into(); + NewFile { + file_type: FileType::AdditionalResource, + work_id: None, + publication_id: None, + additional_resource_id: Some(additional_resource_id), + work_featured_video_id: None, + object_key: object_key.clone(), + cdn_url: format!("https://cdn.example.org/{object_key}"), + mime_type: "application/json".to_string(), + bytes: 4096, + sha256: TEST_SHA256_HEX.to_string(), + } +} + +#[cfg(feature = "backend")] +fn make_new_work_featured_video_file( + work_featured_video_id: Uuid, + object_key: impl Into<String>, +) -> NewFile { + let object_key = object_key.into(); + NewFile { + file_type: FileType::WorkFeaturedVideo, + work_id: None, + publication_id: None, + additional_resource_id: None, + work_featured_video_id: Some(work_featured_video_id), + object_key: object_key.clone(), + cdn_url: format!("https://cdn.example.org/{object_key}"), + mime_type: "video/mp4".to_string(), + bytes: 8192, + sha256: TEST_SHA256_HEX.to_string(), + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn filetype_display_formats_expected_strings() { + assert_eq!(format!("{}", FileType::Publication), "publication"); + assert_eq!(format!("{}", FileType::Frontcover), "frontcover"); + assert_eq!( + format!("{}", FileType::AdditionalResource), + "additional_resource" + ); + assert_eq!( + format!("{}", FileType::WorkFeaturedVideo), + "work_featured_video" + ); + } + + #[test] + fn filetype_fromstr_parses_expected_values() { + use std::str::FromStr; + + assert_eq!( + FileType::from_str("publication").unwrap(), + FileType::Publication + ); + assert_eq!( + FileType::from_str("frontcover").unwrap(), + FileType::Frontcover + ); + assert_eq!( + FileType::from_str("additional_resource").unwrap(), + FileType::AdditionalResource + ); + assert_eq!( + FileType::from_str("work_featured_video").unwrap(), + FileType::WorkFeaturedVideo + ); + assert!(FileType::from_str("Publication").is_err()); + assert!(FileType::from_str("cover").is_err()); + } +} + +#[cfg(feature = "backend")] +mod conversions { + use super::*; + use crate::model::tests::db::setup_test_db; + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn filetype_graphql_roundtrip() { + assert_graphql_enum_roundtrip(FileType::Publication); + assert_graphql_enum_roundtrip(FileType::Frontcover); + assert_graphql_enum_roundtrip(FileType::AdditionalResource); + assert_graphql_enum_roundtrip(FileType::WorkFeaturedVideo); + } + + #[test] + fn filetype_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<FileType, crate::schema::sql_types::FileType>( + pool.as_ref(), + "'publication'::file_type", + FileType::Publication, + ); + assert_db_enum_roundtrip::<FileType, crate::schema::sql_types::FileType>( + pool.as_ref(), + "'frontcover'::file_type", + FileType::Frontcover, + ); + assert_db_enum_roundtrip::<FileType, crate::schema::sql_types::FileType>( + pool.as_ref(), + "'additional_resource'::file_type", + FileType::AdditionalResource, + ); + assert_db_enum_roundtrip::<FileType, crate::schema::sql_types::FileType>( + pool.as_ref(), + "'work_featured_video'::file_type", + FileType::WorkFeaturedVideo, + ); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, Timestamp}; + + #[test] + fn pk_returns_file_id() { + let file_id = Uuid::new_v4(); + let file = File { + file_id, + file_type: FileType::Frontcover, + work_id: None, + publication_id: None, + additional_resource_id: None, + work_featured_video_id: None, + object_key: "test/key".to_string(), + cdn_url: "https://cdn.example.com/test.jpg".to_string(), + mime_type: "image/jpeg".to_string(), + bytes: 1024, + sha256: TEST_SHA256_HEX.to_string(), + created_at: Timestamp::default(), + updated_at: Timestamp::default(), + }; + + assert_eq!(file.pk(), file_id); + } + + #[test] + fn pk_returns_file_upload_id() { + let file_upload_id = Uuid::new_v4(); + let upload = FileUpload { + file_upload_id, + file_type: FileType::Frontcover, + work_id: None, + publication_id: None, + additional_resource_id: None, + work_featured_video_id: None, + declared_mime_type: "image/jpeg".to_string(), + declared_extension: "jpg".to_string(), + declared_sha256: TEST_SHA256_HEX.to_string(), + created_at: Timestamp::default(), + updated_at: Timestamp::default(), + }; + + assert_eq!(upload.pk(), file_upload_id); + } +} + +#[cfg(feature = "backend")] +mod validation { + use super::*; + use crate::model::additional_resource::ResourceType; + use crate::model::publication::PublicationType; + use thoth_errors::ThothError; + + #[test] + fn frontcover_allows_known_extensions() { + for ext in ["jpg", "jpeg", "png", "webp"] { + assert!(FilePolicy::validate_file_extension(ext, &FileType::Frontcover, None).is_ok()); + } + } + + #[test] + fn frontcover_extension_validation_is_case_insensitive() { + assert!(FilePolicy::validate_file_extension("JPG", &FileType::Frontcover, None).is_ok()); + assert!(FilePolicy::validate_file_extension("WeBp", &FileType::Frontcover, None).is_ok()); + } + + #[test] + fn frontcover_rejects_unknown_extensions() { + assert_eq!( + FilePolicy::validate_file_extension("tiff", &FileType::Frontcover, None).unwrap_err(), + ThothError::InvalidFileExtension + ); + } + + #[test] + fn publication_pdf_allows_pdf() { + assert!(FilePolicy::validate_file_extension( + "pdf", + &FileType::Publication, + Some(PublicationType::Pdf) + ) + .is_ok()); + } + + #[test] + fn publication_pdf_rejects_other_extensions() { + assert_eq!( + FilePolicy::validate_file_extension( + "epub", + &FileType::Publication, + Some(PublicationType::Pdf) + ) + .unwrap_err(), + ThothError::InvalidFileExtension + ); + } + + #[test] + fn publication_requires_publication_type_for_validation() { + assert_eq!( + FilePolicy::validate_file_extension("pdf", &FileType::Publication, None).unwrap_err(), + ThothError::PublicationTypeRequiredForFileValidation + ); + } + + #[test] + fn publication_rejects_unsupported_publication_types() { + assert_eq!( + FilePolicy::validate_file_extension( + "pdf", + &FileType::Publication, + Some(PublicationType::Paperback) + ) + .unwrap_err(), + ThothError::UnsupportedPublicationTypeForFileUpload + ); + } + + #[test] + fn frontcover_requires_mime_to_match_extension() { + assert!(FilePolicy::validate_file_mime_type( + "jpg", + &FileType::Frontcover, + None, + "IMAGE/JPEG" + ) + .is_ok()); + + assert_eq!( + FilePolicy::validate_file_mime_type("jpg", &FileType::Frontcover, None, "image/png") + .unwrap_err(), + ThothError::InvalidFileMimeType + ); + } + + #[test] + fn publication_mime_allows_accepted_aliases() { + assert!(FilePolicy::validate_file_mime_type( + "pdf", + &FileType::Publication, + Some(PublicationType::Pdf), + "application/octet-stream" + ) + .is_ok()); + + assert!(FilePolicy::validate_file_mime_type( + "xml", + &FileType::Publication, + Some(PublicationType::Xml), + "text/xml" + ) + .is_ok()); + + assert!(FilePolicy::validate_file_mime_type( + "mp3", + &FileType::Publication, + Some(PublicationType::Mp3), + "audio/mp3" + ) + .is_ok()); + + assert!(FilePolicy::validate_file_mime_type( + "zip", + &FileType::Publication, + Some(PublicationType::Xml), + "application/zip" + ) + .is_ok()); + } + + #[test] + fn publication_mime_rejects_invalid_values() { + assert_eq!( + FilePolicy::validate_file_mime_type( + "pdf", + &FileType::Publication, + Some(PublicationType::Pdf), + "application/epub+zip" + ) + .unwrap_err(), + ThothError::InvalidFileMimeType + ); + } + + #[test] + fn publication_size_limits_are_enforced() { + let fifty_kib = 50 * 1024; + let five_gib = 5 * 1024 * 1024 * 1024; + assert!(FilePolicy::validate_file_size(fifty_kib, &FileType::Publication).is_ok()); + assert!(FilePolicy::validate_file_size(five_gib, &FileType::Publication).is_ok()); + + assert_eq!( + FilePolicy::validate_file_size(fifty_kib - 1, &FileType::Publication).unwrap_err(), + ThothError::FileTooSmall + ); + assert_eq!( + FilePolicy::validate_file_size(five_gib + 1, &FileType::Publication).unwrap_err(), + ThothError::FileTooLarge + ); + } + + #[test] + fn frontcover_size_limits_are_enforced() { + let fifty_kib = 50 * 1024; + let fifty_mib = 50 * 1024 * 1024; + assert!(FilePolicy::validate_file_size(fifty_kib, &FileType::Frontcover).is_ok()); + assert!(FilePolicy::validate_file_size(fifty_mib, &FileType::Frontcover).is_ok()); + + assert_eq!( + FilePolicy::validate_file_size(fifty_kib - 1, &FileType::Frontcover).unwrap_err(), + ThothError::FileTooSmall + ); + assert_eq!( + FilePolicy::validate_file_size(fifty_mib + 1, &FileType::Frontcover).unwrap_err(), + ThothError::FileTooLarge + ); + } + + #[test] + fn new_file_upload_from_publication_lowercases_extension() { + let data = NewPublicationFileUpload { + publication_id: Uuid::new_v4(), + declared_mime_type: "application/pdf".to_string(), + declared_extension: "PDF".to_string(), + declared_sha256: TEST_SHA256_HEX.to_string(), + }; + + let upload: NewFileUpload = data.into(); + assert_eq!(upload.file_type, FileType::Publication); + assert_eq!(upload.declared_extension, "pdf"); + } + + #[test] + fn new_file_upload_from_frontcover_lowercases_extension() { + let data = NewFrontcoverFileUpload { + work_id: Uuid::new_v4(), + declared_mime_type: "image/jpeg".to_string(), + declared_extension: "JPG".to_string(), + declared_sha256: TEST_SHA256_HEX.to_string(), + }; + + let upload: NewFileUpload = data.into(); + assert_eq!(upload.file_type, FileType::Frontcover); + assert_eq!(upload.declared_extension, "jpg"); + } + + #[test] + fn new_file_upload_from_additional_resource_lowercases_extension() { + let data = NewAdditionalResourceFileUpload { + additional_resource_id: Uuid::new_v4(), + declared_mime_type: "image/png".to_string(), + declared_extension: "PNG".to_string(), + declared_sha256: TEST_SHA256_HEX.to_string(), + }; + + let upload: NewFileUpload = data.into(); + assert_eq!(upload.file_type, FileType::AdditionalResource); + assert_eq!(upload.declared_extension, "png"); + } + + #[test] + fn resource_extension_and_mime_validation() { + assert!(FilePolicy::validate_resource_file_extension("mp4", ResourceType::Video).is_ok()); + assert!( + FilePolicy::validate_resource_file_mime_type(ResourceType::Video, "video/mp4").is_ok() + ); + assert_eq!( + FilePolicy::validate_resource_file_extension("exe", ResourceType::Video).unwrap_err(), + ThothError::InvalidFileExtension + ); + assert_eq!( + FilePolicy::validate_resource_file_extension("mp4", ResourceType::Blog).unwrap_err(), + ThothError::UnsupportedResourceTypeForFileUpload + ); + assert_eq!( + FilePolicy::validate_resource_file_mime_type(ResourceType::Video, "image/png") + .unwrap_err(), + ThothError::InvalidFileMimeType + ); + } + + #[test] + fn upload_request_headers_contains_required_checksum_headers() { + let headers = upload_request_headers("application/pdf", TEST_SHA256_HEX) + .expect("Expected upload headers"); + + assert_eq!(headers.len(), 3); + assert_eq!(headers[0].name, "Content-Type"); + assert_eq!(headers[0].value, "application/pdf"); + assert_eq!(headers[1].name, "x-amz-checksum-sha256"); + assert_eq!( + headers[1].value, + "REsTi0HjxIylBbF0AJGwyTzppxx8nSSVbmz4cW8arX4=" + ); + assert_eq!(headers[2].name, "x-amz-sdk-checksum-algorithm"); + assert_eq!(headers[2].value, "SHA256"); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + use crate::model::additional_resource::ResourceType; + use crate::model::publication::PublicationType; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role}; + use thoth_errors::ThothError; + + #[test] + fn crud_policy_allows_cdn_write_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_pdf_publication(pool.as_ref(), work.work_id); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("file-user", Role::CdnWrite, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let new_file = make_new_frontcover_file( + work.work_id, + format!("10.1234/{}/cover.jpg", Uuid::new_v4()), + ); + let new_upload = make_new_publication_upload(publication.publication_id, "pdf"); + + let file = File::create(pool.as_ref(), &new_file).expect("Failed to create file"); + let upload = + FileUpload::create(pool.as_ref(), &new_upload).expect("Failed to create file upload"); + + assert!(FilePolicy::can_create(&ctx, &new_file, ()).is_ok()); + assert!(FilePolicy::can_create(&ctx, &new_upload, Some(PublicationType::Pdf)).is_ok()); + assert!(FilePolicy::can_delete(&ctx, &file).is_ok()); + assert!(FilePolicy::can_delete(&ctx, &upload).is_ok()); + assert!(FilePolicy::can_complete_upload( + &ctx, + &upload, + Some(PublicationType::Pdf), + None, + 60 * 1024, + "application/pdf" + ) + .is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_cdn_write_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_pdf_publication(pool.as_ref(), work.work_id); + + let new_file = make_new_frontcover_file( + work.work_id, + format!("10.1234/{}/cover.jpg", Uuid::new_v4()), + ); + let new_upload = make_new_publication_upload(publication.publication_id, "pdf"); + + let file = File::create(pool.as_ref(), &new_file).expect("Failed to create file"); + let upload = + FileUpload::create(pool.as_ref(), &new_upload).expect("Failed to create file upload"); + + let user = test_user_with_role("file-user", Role::CdnWrite, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + assert!(FilePolicy::can_create(&ctx, &new_file, ()).is_err()); + assert!(FilePolicy::can_create(&ctx, &new_upload, Some(PublicationType::Pdf)).is_err()); + assert!(FilePolicy::can_delete(&ctx, &file).is_err()); + assert!(FilePolicy::can_delete(&ctx, &upload).is_err()); + assert!(FilePolicy::can_complete_upload( + &ctx, + &upload, + Some(PublicationType::Pdf), + None, + 60 * 1024, + "application/pdf" + ) + .is_err()); + } + + #[test] + fn can_complete_upload_validates_extension_and_publication_type() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_pdf_publication(pool.as_ref(), work.work_id); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("file-user", Role::CdnWrite, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let valid_upload = FileUpload::create( + pool.as_ref(), + &make_new_publication_upload(publication.publication_id, "pdf"), + ) + .expect("Failed to create valid upload"); + + assert!(FilePolicy::can_complete_upload( + &ctx, + &valid_upload, + Some(PublicationType::Pdf), + None, + 60 * 1024, + "application/pdf" + ) + .is_ok()); + + let other_work = create_work(pool.as_ref(), &imprint); + let other_publication = create_pdf_publication(pool.as_ref(), other_work.work_id); + + let invalid_upload = FileUpload::create( + pool.as_ref(), + &make_new_publication_upload(other_publication.publication_id, "epub"), + ) + .expect("Failed to create invalid upload"); + + assert_eq!( + FilePolicy::can_complete_upload( + &ctx, + &invalid_upload, + Some(PublicationType::Pdf), + None, + 60 * 1024, + "application/pdf" + ) + .unwrap_err(), + ThothError::InvalidFileExtension + ); + assert_eq!( + FilePolicy::can_complete_upload( + &ctx, + &valid_upload, + None, + None, + 60 * 1024, + "application/pdf" + ) + .unwrap_err(), + ThothError::PublicationTypeRequiredForFileValidation + ); + } + + #[test] + fn can_complete_upload_validates_resources_with_single_gate() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let featured_video = create_work_featured_video(pool.as_ref(), work.work_id); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("file-user", Role::CdnWrite, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let upload = FileUpload::create( + pool.as_ref(), + &NewFileUpload { + file_type: FileType::WorkFeaturedVideo, + work_id: None, + publication_id: None, + additional_resource_id: None, + work_featured_video_id: Some(featured_video.work_featured_video_id), + declared_mime_type: "video/mp4".to_string(), + declared_extension: "mp4".to_string(), + declared_sha256: TEST_SHA256_HEX.to_string(), + }, + ) + .expect("Failed to create featured-video upload"); + + assert!(FilePolicy::can_complete_upload( + &ctx, + &upload, + None, + Some(ResourceType::Video), + 1024, + "video/mp4" + ) + .is_ok()); + + assert_eq!( + FilePolicy::can_complete_upload(&ctx, &upload, None, None, 1024, "video/mp4") + .unwrap_err(), + ThothError::UnsupportedResourceTypeForFileUpload + ); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + use crate::model::publication::Publication; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context, + test_context_with_user, test_user_with_role, + }; + use crate::model::work::Work; + use crate::model::{Crud, Doi, PublisherId}; + use crate::policy::Role; + use crate::storage::temp_key; + use std::str::FromStr; + use thoth_errors::ThothError; + + #[test] + fn crud_roundtrip_file_create_fetch_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let new_file = make_new_frontcover_file( + work.work_id, + format!("10.1234/{}/cover.jpg", Uuid::new_v4()), + ); + + let file = File::create(pool.as_ref(), &new_file).expect("Failed to create file"); + let fetched = File::from_id(pool.as_ref(), &file.file_id).expect("Failed to fetch file"); + assert_eq!(fetched.file_id, file.file_id); + + let deleted = fetched + .delete(pool.as_ref()) + .expect("Failed to delete file"); + assert!(File::from_id(pool.as_ref(), &deleted.file_id).is_err()); + } + + #[test] + fn crud_roundtrip_file_upload_create_fetch_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let new_upload = make_new_frontcover_upload(work.work_id, "jpg"); + + let upload = + FileUpload::create(pool.as_ref(), &new_upload).expect("Failed to create file upload"); + let fetched = FileUpload::from_id(pool.as_ref(), &upload.file_upload_id) + .expect("Failed to fetch file upload"); + assert_eq!(fetched.file_upload_id, upload.file_upload_id); + + let deleted = fetched + .delete(pool.as_ref()) + .expect("Failed to delete file upload"); + assert!(FileUpload::from_id(pool.as_ref(), &deleted.file_upload_id).is_err()); + } + + #[test] + fn delete_publication_cascades_associated_file_and_upload_rows() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_pdf_publication(pool.as_ref(), work.work_id); + let publication_id = publication.publication_id; + + let file = File::create( + pool.as_ref(), + &make_new_publication_file( + publication.publication_id, + format!("10.1234/{}/publication.pdf", Uuid::new_v4()), + ), + ) + .expect("Failed to create publication file"); + let upload = FileUpload::create( + pool.as_ref(), + &make_new_publication_upload(publication.publication_id, "pdf"), + ) + .expect("Failed to create publication file upload"); + + publication + .delete(pool.as_ref()) + .expect("Failed to delete publication"); + + assert!(Publication::from_id(pool.as_ref(), &publication_id).is_err()); + assert!(File::from_id(pool.as_ref(), &file.file_id).is_err()); + assert!(FileUpload::from_id(pool.as_ref(), &upload.file_upload_id).is_err()); + } + + #[test] + fn delete_work_cascades_all_associated_file_and_upload_rows() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let work_id = work.work_id; + let publication = create_pdf_publication(pool.as_ref(), work.work_id); + let additional_resource = create_additional_resource(pool.as_ref(), work.work_id); + let featured_video = create_work_featured_video(pool.as_ref(), work.work_id); + + let cover_file = File::create( + pool.as_ref(), + &make_new_frontcover_file( + work.work_id, + format!("10.1234/{}/cover.jpg", Uuid::new_v4()), + ), + ) + .expect("Failed to create frontcover file"); + let publication_file = File::create( + pool.as_ref(), + &make_new_publication_file( + publication.publication_id, + format!("10.1234/{}/publication.pdf", Uuid::new_v4()), + ), + ) + .expect("Failed to create publication file"); + let resource_file = File::create( + pool.as_ref(), + &make_new_additional_resource_file( + additional_resource.additional_resource_id, + format!("10.1234/{}/resources/data.json", Uuid::new_v4()), + ), + ) + .expect("Failed to create additional-resource file"); + let featured_file = File::create( + pool.as_ref(), + &make_new_work_featured_video_file( + featured_video.work_featured_video_id, + format!("10.1234/{}/resources/featured.mp4", Uuid::new_v4()), + ), + ) + .expect("Failed to create featured-video file"); + + let cover_upload = FileUpload::create( + pool.as_ref(), + &make_new_frontcover_upload(work.work_id, "jpg"), + ) + .expect("Failed to create frontcover upload"); + let publication_upload = FileUpload::create( + pool.as_ref(), + &make_new_publication_upload(publication.publication_id, "pdf"), + ) + .expect("Failed to create publication upload"); + let resource_upload = FileUpload::create( + pool.as_ref(), + &make_new_additional_resource_upload( + additional_resource.additional_resource_id, + "json", + ), + ) + .expect("Failed to create additional-resource upload"); + let featured_upload = FileUpload::create( + pool.as_ref(), + &make_new_work_featured_video_upload(featured_video.work_featured_video_id, "mp4"), + ) + .expect("Failed to create featured-video upload"); + + work.delete(pool.as_ref()).expect("Failed to delete work"); + + assert!(Work::from_id(pool.as_ref(), &work_id).is_err()); + assert!(File::from_id(pool.as_ref(), &cover_file.file_id).is_err()); + assert!(File::from_id(pool.as_ref(), &publication_file.file_id).is_err()); + assert!(File::from_id(pool.as_ref(), &resource_file.file_id).is_err()); + assert!(File::from_id(pool.as_ref(), &featured_file.file_id).is_err()); + assert!(FileUpload::from_id(pool.as_ref(), &cover_upload.file_upload_id).is_err()); + assert!(FileUpload::from_id(pool.as_ref(), &publication_upload.file_upload_id).is_err()); + assert!(FileUpload::from_id(pool.as_ref(), &resource_upload.file_upload_id).is_err()); + assert!(FileUpload::from_id(pool.as_ref(), &featured_upload.file_upload_id).is_err()); + } + + #[test] + fn crud_lookup_helpers_return_expected_records() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_pdf_publication(pool.as_ref(), work.work_id); + + let frontcover_file = File::create( + pool.as_ref(), + &make_new_frontcover_file( + work.work_id, + format!("10.1234/{}/frontcover.jpg", Uuid::new_v4()), + ), + ) + .expect("Failed to create frontcover file"); + let publication_file = File::create( + pool.as_ref(), + &make_new_publication_file( + publication.publication_id, + format!("10.1234/{}/publication.pdf", Uuid::new_v4()), + ), + ) + .expect("Failed to create publication file"); + + let from_object = File::from_object_key(pool.as_ref(), &frontcover_file.object_key) + .expect("Failed to fetch by object key"); + assert_eq!(from_object.file_id, frontcover_file.file_id); + + let from_work = File::from_work_id(pool.as_ref(), &work.work_id) + .expect("Failed to fetch frontcover by work id") + .expect("Expected frontcover file"); + assert_eq!(from_work.file_id, frontcover_file.file_id); + + let from_publication = + File::from_publication_id(pool.as_ref(), &publication.publication_id) + .expect("Failed to fetch publication file by publication id") + .expect("Expected publication file"); + assert_eq!(from_publication.file_id, publication_file.file_id); + + let other_work = create_work(pool.as_ref(), &imprint); + let other_publication = create_pdf_publication(pool.as_ref(), other_work.work_id); + assert!(File::from_work_id(pool.as_ref(), &other_work.work_id) + .expect("Failed to query frontcover by work id") + .is_none()); + assert!( + File::from_publication_id(pool.as_ref(), &other_publication.publication_id) + .expect("Failed to query publication file by publication id") + .is_none() + ); + } + + #[test] + fn crud_publisher_id_resolves_for_all_file_variants() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_pdf_publication(pool.as_ref(), work.work_id); + + let frontcover_new_file = make_new_frontcover_file( + work.work_id, + format!("10.1234/{}/cover.jpg", Uuid::new_v4()), + ); + let publication_new_file = make_new_publication_file( + publication.publication_id, + format!("10.1234/{}/publication.pdf", Uuid::new_v4()), + ); + + assert_eq!( + frontcover_new_file.publisher_id(pool.as_ref()).unwrap(), + publisher.publisher_id + ); + assert_eq!( + publication_new_file.publisher_id(pool.as_ref()).unwrap(), + publisher.publisher_id + ); + + let frontcover_file = + File::create(pool.as_ref(), &frontcover_new_file).expect("Failed to create file"); + let publication_upload = FileUpload::create( + pool.as_ref(), + &make_new_publication_upload(publication.publication_id, "pdf"), + ) + .expect("Failed to create file upload"); + + assert_eq!( + frontcover_file.publisher_id(pool.as_ref()).unwrap(), + publisher.publisher_id + ); + assert_eq!( + publication_upload.publisher_id(pool.as_ref()).unwrap(), + publisher.publisher_id + ); + + let invalid_new_file = NewFile { + file_type: FileType::Frontcover, + work_id: None, + publication_id: None, + additional_resource_id: None, + work_featured_video_id: None, + object_key: "invalid.jpg".to_string(), + cdn_url: "https://cdn.example.org/invalid.jpg".to_string(), + mime_type: "image/jpeg".to_string(), + bytes: 1, + sha256: TEST_SHA256_HEX.to_string(), + }; + assert_eq!( + invalid_new_file.publisher_id(pool.as_ref()).unwrap_err(), + ThothError::FileMissingWorkOrPublicationId + ); + + let invalid_upload = NewFileUpload { + file_type: FileType::Publication, + work_id: None, + publication_id: None, + additional_resource_id: None, + work_featured_video_id: None, + declared_mime_type: "application/pdf".to_string(), + declared_extension: "pdf".to_string(), + declared_sha256: TEST_SHA256_HEX.to_string(), + }; + assert_eq!( + invalid_upload.publisher_id(pool.as_ref()).unwrap_err(), + ThothError::FileUploadMissingWorkOrPublicationId + ); + } + + #[test] + fn crud_file_upload_helpers_load_scope_and_canonical_key() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_pdf_publication(pool.as_ref(), work.work_id); + + let publication_upload = FileUpload::create( + pool.as_ref(), + &make_new_publication_upload(publication.publication_id, "pdf"), + ) + .expect("Failed to create publication upload"); + let frontcover_upload = FileUpload::create( + pool.as_ref(), + &make_new_frontcover_upload(work.work_id, "jpg"), + ) + .expect("Failed to create frontcover upload"); + + let ctx = test_context(pool.clone(), "file-user"); + + let (loaded_work, loaded_publication, loaded_resource, loaded_featured_video) = + publication_upload + .load_scope(&ctx) + .expect("Failed to load publication upload scope"); + assert_eq!(loaded_work.work_id, work.work_id); + assert_eq!( + loaded_publication + .expect("Expected publication to be loaded") + .publication_id, + publication.publication_id + ); + assert!(loaded_resource.is_none()); + assert!(loaded_featured_video.is_none()); + + let (loaded_work, loaded_publication, loaded_resource, loaded_featured_video) = + frontcover_upload + .load_scope(&ctx) + .expect("Failed to load frontcover upload scope"); + assert_eq!(loaded_work.work_id, work.work_id); + assert!(loaded_publication.is_none()); + assert!(loaded_resource.is_none()); + assert!(loaded_featured_video.is_none()); + + let doi = Doi::from_str("https://doi.org/10.1234/AbC/Def").expect("Failed to parse DOI"); + assert_eq!( + publication_upload.canonical_key(&doi).unwrap(), + "10.1234/abc/def.pdf" + ); + assert_eq!( + frontcover_upload.canonical_key(&doi).unwrap(), + "10.1234/abc/def_frontcover.jpg" + ); + } + + #[test] + fn crud_persist_file_record_creates_and_updates_existing_file() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("file-user", Role::CdnWrite, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let upload = FileUpload::create( + pool.as_ref(), + &make_new_frontcover_upload(work.work_id, "jpg"), + ) + .expect("Failed to create upload"); + + let first_key = "10.1234/abc/def_frontcover.jpg"; + let first_url = "https://cdn.example.org/10.1234/abc/def_frontcover.jpg"; + + let (created_file, old_key) = upload + .persist_file_record(&ctx, first_key, first_url, "image/jpeg", 1024) + .expect("Failed to create initial file record"); + assert!(old_key.is_none()); + assert_eq!(created_file.object_key, first_key); + assert_eq!(created_file.cdn_url, first_url); + assert_eq!(created_file.mime_type, "image/jpeg"); + assert_eq!(created_file.bytes, 1024); + + let second_key = "10.1234/abc/def_frontcover_v2.jpg"; + let second_url = "https://cdn.example.org/10.1234/abc/def_frontcover_v2.jpg"; + + let (updated_file, old_key) = upload + .persist_file_record(&ctx, second_key, second_url, "image/webp", 2048) + .expect("Failed to update existing file record"); + assert_eq!(old_key.as_deref(), Some(first_key)); + assert_eq!(updated_file.file_id, created_file.file_id); + assert_eq!(updated_file.object_key, second_key); + assert_eq!(updated_file.cdn_url, second_url); + assert_eq!(updated_file.mime_type, "image/webp"); + assert_eq!(updated_file.bytes, 2048); + + let persisted = File::from_work_id(pool.as_ref(), &work.work_id) + .expect("Failed to reload file by work id") + .expect("Expected persisted frontcover"); + assert_eq!(persisted.file_id, created_file.file_id); + assert_eq!(persisted.object_key, second_key); + } + + #[test] + fn crud_sync_related_metadata_updates_work_cover_url_for_frontcover() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("file-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let upload = FileUpload::create( + pool.as_ref(), + &make_new_frontcover_upload(work.work_id, "jpg"), + ) + .expect("Failed to create upload"); + + let cover_url = "https://cdn.example.org/10.1234/abc/def_frontcover.jpg"; + upload + .sync_related_metadata(&ctx, &work, cover_url, None) + .expect("Failed to sync frontcover metadata"); + + let refreshed_work = Work::from_id(pool.as_ref(), &work.work_id) + .expect("Failed to reload work after metadata sync"); + assert_eq!(refreshed_work.cover_url.as_deref(), Some(cover_url)); + } + + #[test] + fn crud_sync_related_metadata_updates_featured_video_url_and_dimensions() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let featured_video = create_work_featured_video(pool.as_ref(), work.work_id); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("file-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let upload = FileUpload::create( + pool.as_ref(), + &NewFileUpload { + file_type: FileType::WorkFeaturedVideo, + work_id: None, + publication_id: None, + additional_resource_id: None, + work_featured_video_id: Some(featured_video.work_featured_video_id), + declared_mime_type: "video/mp4".to_string(), + declared_extension: "mp4".to_string(), + declared_sha256: TEST_SHA256_HEX.to_string(), + }, + ) + .expect("Failed to create upload"); + + let video_url = "https://cdn.example.org/10.1234/abc/def/resources/video.mp4"; + upload + .sync_related_metadata(&ctx, &work, video_url, Some((1280, 720))) + .expect("Failed to sync featured-video metadata"); + + let refreshed = crate::model::work_featured_video::WorkFeaturedVideo::from_id( + pool.as_ref(), + &featured_video.work_featured_video_id, + ) + .expect("Failed to reload featured video after metadata sync"); + assert_eq!(refreshed.url.as_deref(), Some(video_url)); + assert_eq!(refreshed.width, 1280); + assert_eq!(refreshed.height, 720); + } + + #[test] + fn cleanup_candidates_for_publication_includes_file_and_pending_upload() { + let (_guard, pool) = setup_test_db(); + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_pdf_publication(pool.as_ref(), work.work_id); + + let object_key = format!("10.1234/{}/publication.pdf", Uuid::new_v4()); + File::create( + pool.as_ref(), + &make_new_publication_file(publication.publication_id, &object_key), + ) + .expect("Failed to create publication file"); + let upload = FileUpload::create( + pool.as_ref(), + &make_new_publication_upload(publication.publication_id, "pdf"), + ) + .expect("Failed to create publication upload"); + + let candidates = + File::cleanup_candidates_for_publication(pool.as_ref(), &publication.publication_id) + .expect("Failed to load publication cleanup candidates"); + assert_eq!(candidates.len(), 2); + assert!(candidates + .iter() + .any(|c| c.file_type == FileType::Publication && c.object_key == object_key)); + assert!(candidates + .iter() + .any(|c| c.file_type == FileType::Publication + && c.object_key == temp_key(&upload.file_upload_id))); + } + + #[test] + fn cleanup_candidates_for_additional_resource_limits_to_target_resource() { + let (_guard, pool) = setup_test_db(); + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + let target_resource = create_additional_resource(pool.as_ref(), work.work_id); + let other_resource = create_additional_resource(pool.as_ref(), other_work.work_id); + + let target_key = format!("10.1234/{}/resources/target.json", Uuid::new_v4()); + let other_key = format!("10.1234/{}/resources/other.json", Uuid::new_v4()); + + File::create( + pool.as_ref(), + &make_new_additional_resource_file(target_resource.additional_resource_id, &target_key), + ) + .expect("Failed to create target additional-resource file"); + File::create( + pool.as_ref(), + &make_new_additional_resource_file(other_resource.additional_resource_id, &other_key), + ) + .expect("Failed to create non-target additional-resource file"); + + let target_upload = FileUpload::create( + pool.as_ref(), + &make_new_additional_resource_upload(target_resource.additional_resource_id, "json"), + ) + .expect("Failed to create target additional-resource upload"); + let other_upload = FileUpload::create( + pool.as_ref(), + &make_new_additional_resource_upload(other_resource.additional_resource_id, "json"), + ) + .expect("Failed to create non-target additional-resource upload"); + + let candidates = File::cleanup_candidates_for_additional_resource( + pool.as_ref(), + &target_resource.additional_resource_id, + ) + .expect("Failed to load additional-resource cleanup candidates"); + + assert_eq!(candidates.len(), 2); + assert!(candidates.iter().any(|c| c.object_key == target_key)); + assert!(candidates + .iter() + .any(|c| c.object_key == temp_key(&target_upload.file_upload_id))); + assert!(!candidates.iter().any(|c| c.object_key == other_key)); + assert!(!candidates + .iter() + .any(|c| c.object_key == temp_key(&other_upload.file_upload_id))); + } + + #[test] + fn cleanup_candidates_for_work_featured_video_limits_to_target_video() { + let (_guard, pool) = setup_test_db(); + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + let target_video = create_work_featured_video(pool.as_ref(), work.work_id); + let other_video = create_work_featured_video(pool.as_ref(), other_work.work_id); + + let target_key = format!("10.1234/{}/resources/target.mp4", Uuid::new_v4()); + let other_key = format!("10.1234/{}/resources/other.mp4", Uuid::new_v4()); + + File::create( + pool.as_ref(), + &make_new_work_featured_video_file(target_video.work_featured_video_id, &target_key), + ) + .expect("Failed to create target featured-video file"); + File::create( + pool.as_ref(), + &make_new_work_featured_video_file(other_video.work_featured_video_id, &other_key), + ) + .expect("Failed to create non-target featured-video file"); + + let target_upload = FileUpload::create( + pool.as_ref(), + &make_new_work_featured_video_upload(target_video.work_featured_video_id, "mp4"), + ) + .expect("Failed to create target featured-video upload"); + let other_upload = FileUpload::create( + pool.as_ref(), + &make_new_work_featured_video_upload(other_video.work_featured_video_id, "mp4"), + ) + .expect("Failed to create non-target featured-video upload"); + + let candidates = File::cleanup_candidates_for_work_featured_video( + pool.as_ref(), + &target_video.work_featured_video_id, + ) + .expect("Failed to load featured-video cleanup candidates"); + + assert_eq!(candidates.len(), 2); + assert!(candidates.iter().any(|c| c.object_key == target_key)); + assert!(candidates + .iter() + .any(|c| c.object_key == temp_key(&target_upload.file_upload_id))); + assert!(!candidates.iter().any(|c| c.object_key == other_key)); + assert!(!candidates + .iter() + .any(|c| c.object_key == temp_key(&other_upload.file_upload_id))); + } + + #[test] + fn cleanup_candidates_for_work_collects_all_linked_files_and_pending_uploads() { + let (_guard, pool) = setup_test_db(); + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_pdf_publication(pool.as_ref(), work.work_id); + let additional_resource = create_additional_resource(pool.as_ref(), work.work_id); + let featured_video = create_work_featured_video(pool.as_ref(), work.work_id); + + let resource_key = format!("10.1234/{}/resources/resource.mp4", Uuid::new_v4()); + let featured_video_key = format!("10.1234/{}/resources/featured.mp4", Uuid::new_v4()); + let publication_key = format!("10.1234/{}/publication.pdf", Uuid::new_v4()); + let cover_key = format!("10.1234/{}/cover.jpg", Uuid::new_v4()); + + File::create( + pool.as_ref(), + &make_new_frontcover_file(work.work_id, &cover_key), + ) + .expect("Failed to create frontcover file"); + File::create( + pool.as_ref(), + &make_new_publication_file(publication.publication_id, &publication_key), + ) + .expect("Failed to create publication file"); + File::create( + pool.as_ref(), + &make_new_additional_resource_file( + additional_resource.additional_resource_id, + &resource_key, + ), + ) + .expect("Failed to create additional-resource file"); + File::create( + pool.as_ref(), + &make_new_work_featured_video_file( + featured_video.work_featured_video_id, + &featured_video_key, + ), + ) + .expect("Failed to create featured-video file"); + let cover_upload = FileUpload::create( + pool.as_ref(), + &make_new_frontcover_upload(work.work_id, "jpg"), + ) + .expect("Failed to create frontcover upload"); + let publication_upload = FileUpload::create( + pool.as_ref(), + &make_new_publication_upload(publication.publication_id, "pdf"), + ) + .expect("Failed to create publication upload"); + let resource_upload = FileUpload::create( + pool.as_ref(), + &make_new_additional_resource_upload( + additional_resource.additional_resource_id, + "json", + ), + ) + .expect("Failed to create additional-resource upload"); + let featured_video_upload = FileUpload::create( + pool.as_ref(), + &make_new_work_featured_video_upload(featured_video.work_featured_video_id, "mp4"), + ) + .expect("Failed to create featured-video upload"); + + let candidates = File::cleanup_candidates_for_work(pool.as_ref(), &work.work_id) + .expect("Failed to load"); + + assert_eq!(candidates.len(), 8); + assert!(candidates.iter().any(|c| c.object_key == cover_key)); + assert!(candidates.iter().any(|c| c.object_key == publication_key)); + assert!(candidates.iter().any(|c| c.object_key == resource_key)); + assert!(candidates + .iter() + .any(|c| c.object_key == featured_video_key)); + assert!(candidates + .iter() + .any(|c| c.object_key == temp_key(&cover_upload.file_upload_id))); + assert!(candidates + .iter() + .any(|c| c.object_key == temp_key(&publication_upload.file_upload_id))); + assert!(candidates + .iter() + .any(|c| c.object_key == temp_key(&resource_upload.file_upload_id))); + assert!(candidates + .iter() + .any(|c| c.object_key == temp_key(&featured_video_upload.file_upload_id))); + } +} diff --git a/thoth-api/src/model/funding/crud.rs b/thoth-api/src/model/funding/crud.rs index 0b14cfc9..14706e5f 100644 --- a/thoth-api/src/model/funding/crud.rs +++ b/thoth-api/src/model/funding/crud.rs @@ -1,9 +1,7 @@ use super::{Funding, FundingField, FundingHistory, NewFunding, NewFundingHistory, PatchFunding}; -use crate::graphql::model::FundingOrderBy; -use crate::graphql::utils::Direction; +use crate::graphql::types::inputs::FundingOrderBy; use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{funding, funding_history}; -use crate::{crud_methods, db_insert}; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; use thoth_errors::ThothResult; use uuid::Uuid; @@ -15,6 +13,7 @@ impl Crud for Funding { type FilterParameter1 = (); type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.funding_id @@ -32,6 +31,7 @@ impl Crud for Funding { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Funding>> { use crate::schema::funding::dsl::*; let mut connection = db.get()?; @@ -41,46 +41,33 @@ impl Crud for Funding { .into_boxed(); query = match order.field { - FundingField::FundingId => match order.direction { - Direction::Asc => query.order(funding_id.asc()), - Direction::Desc => query.order(funding_id.desc()), - }, - FundingField::WorkId => match order.direction { - Direction::Asc => query.order(work_id.asc()), - Direction::Desc => query.order(work_id.desc()), - }, - FundingField::InstitutionId => match order.direction { - Direction::Asc => query.order(institution_id.asc()), - Direction::Desc => query.order(institution_id.desc()), - }, - FundingField::Program => match order.direction { - Direction::Asc => query.order(program.asc()), - Direction::Desc => query.order(program.desc()), - }, - FundingField::ProjectName => match order.direction { - Direction::Asc => query.order(project_name.asc()), - Direction::Desc => query.order(project_name.desc()), - }, - FundingField::ProjectShortname => match order.direction { - Direction::Asc => query.order(project_shortname.asc()), - Direction::Desc => query.order(project_shortname.desc()), - }, - FundingField::GrantNumber => match order.direction { - Direction::Asc => query.order(grant_number.asc()), - Direction::Desc => query.order(grant_number.desc()), - }, - FundingField::Jurisdiction => match order.direction { - Direction::Asc => query.order(jurisdiction.asc()), - Direction::Desc => query.order(jurisdiction.desc()), - }, - FundingField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - FundingField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + FundingField::FundingId => { + apply_directional_order!(query, order.direction, order, funding_id) + } + FundingField::WorkId => { + apply_directional_order!(query, order.direction, order, work_id) + } + FundingField::InstitutionId => { + apply_directional_order!(query, order.direction, order, institution_id) + } + FundingField::Program => { + apply_directional_order!(query, order.direction, order, program) + } + FundingField::ProjectName => { + apply_directional_order!(query, order.direction, order, project_name) + } + FundingField::ProjectShortname => { + apply_directional_order!(query, order.direction, order, project_shortname) + } + FundingField::GrantNumber => { + apply_directional_order!(query, order.direction, order, grant_number) + } + FundingField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + FundingField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if !publishers.is_empty() { query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); @@ -105,6 +92,7 @@ impl Crud for Funding { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::funding::dsl::*; let mut connection = db.get()?; @@ -120,20 +108,20 @@ impl Crud for Funding { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } - crud_methods!(funding::table, funding::dsl::funding); } +publisher_id_impls!(Funding, NewFunding, PatchFunding, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Funding { type NewHistoryEntity = NewFundingHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { funding_id: self.funding_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -144,27 +132,3 @@ impl DbInsert for NewFundingHistory { db_insert!(funding_history::table); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_funding_pk() { - let funding: Funding = Default::default(); - assert_eq!(funding.pk(), funding.funding_id); - } - - #[test] - fn test_new_funding_history_from_funding() { - let funding: Funding = Default::default(); - let account_id: Uuid = Default::default(); - let new_funding_history = funding.new_history_entry(&account_id); - assert_eq!(new_funding_history.funding_id, funding.funding_id); - assert_eq!(new_funding_history.account_id, account_id); - assert_eq!( - new_funding_history.data, - serde_json::Value::String(serde_json::to_string(&funding).unwrap()) - ); - } -} diff --git a/thoth-api/src/model/funding/mod.rs b/thoth-api/src/model/funding/mod.rs index d976ecdf..426f839b 100644 --- a/thoth-api/src/model/funding/mod.rs +++ b/thoth-api/src/model/funding/mod.rs @@ -1,8 +1,6 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::model::institution::Institution; -use crate::model::work::WorkWithRelations; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::funding; @@ -22,12 +20,11 @@ pub enum FundingField { ProjectName, ProjectShortname, GrantNumber, - Jurisdiction, CreatedAt, UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Funding { @@ -38,34 +35,13 @@ pub struct Funding { pub project_name: Option<String>, pub project_shortname: Option<String>, pub grant_number: Option<String>, - pub jurisdiction: Option<String>, pub created_at: Timestamp, pub updated_at: Timestamp, } -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct FundingWithInstitution { - pub funding_id: Uuid, - pub work_id: Uuid, - pub institution_id: Uuid, - pub program: Option<String>, - pub project_name: Option<String>, - pub project_shortname: Option<String>, - pub grant_number: Option<String>, - pub jurisdiction: Option<String>, - pub institution: Institution, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct FundingWithWork { - pub work: WorkWithRelations, -} - #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new grant awarded for the publication of a work by an institution"), diesel(table_name = funding) )] @@ -76,12 +52,11 @@ pub struct NewFunding { pub project_name: Option<String>, pub project_shortname: Option<String>, pub grant_number: Option<String>, - pub jurisdiction: Option<String>, } #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing grant awarded for the publication of a work by an institution"), diesel(table_name = funding, treat_none_as_null = true) )] @@ -93,28 +68,33 @@ pub struct PatchFunding { pub project_name: Option<String>, pub project_shortname: Option<String>, pub grant_number: Option<String>, - pub jurisdiction: Option<String>, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct FundingHistory { pub funding_history_id: Uuid, pub funding_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = funding_history) )] pub struct NewFundingHistory { pub funding_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::FundingPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/funding/policy.rs b/thoth-api/src/model/funding/policy.rs new file mode 100644 index 00000000..9f4f38c1 --- /dev/null +++ b/thoth-api/src/model/funding/policy.rs @@ -0,0 +1,38 @@ +use crate::model::funding::{Funding, NewFunding, PatchFunding}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Funding`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct FundingPolicy; + +impl CreatePolicy<NewFunding> for FundingPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewFunding, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Funding, PatchFunding> for FundingPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Funding, + patch: &PatchFunding, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy<Funding> for FundingPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Funding) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/funding/tests.rs b/thoth-api/src/model/funding/tests.rs new file mode 100644 index 00000000..5ddc454e --- /dev/null +++ b/thoth-api/src/model/funding/tests.rs @@ -0,0 +1,564 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_funding( + pool: &crate::db::PgPool, + work_id: Uuid, + institution_id: Uuid, + program: Option<String>, +) -> Funding { + let new_funding = NewFunding { + work_id, + institution_id, + program, + project_name: Some("Project Name".to_string()), + project_shortname: Some("PRJ".to_string()), + grant_number: Some("GRANT-1".to_string()), + }; + + Funding::create(pool, &new_funding).expect("Failed to create funding") +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let funding: Funding = Default::default(); + assert_eq!(funding.pk(), funding.funding_id); + } + + #[test] + fn history_entry_serializes_model() { + let funding: Funding = Default::default(); + let user_id = "123456".to_string(); + let new_funding_history = funding.new_history_entry(&user_id); + assert_eq!(new_funding_history.funding_id, funding.funding_id); + assert_eq!(new_funding_history.user_id, user_id); + assert_eq!( + new_funding_history.data, + serde_json::Value::String(serde_json::to_string(&funding).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::funding::policy::FundingPolicy; + use crate::model::tests::db::{ + create_imprint, create_institution, create_publisher, create_work, setup_test_db, + test_context_with_user, test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("funding-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let institution = create_institution(pool.as_ref()); + let new_funding = NewFunding { + work_id: work.work_id, + institution_id: institution.institution_id, + program: Some("Program".to_string()), + project_name: Some("Project Name".to_string()), + project_shortname: Some("PRJ".to_string()), + grant_number: Some("GRANT-1".to_string()), + }; + + let funding = Funding::create(pool.as_ref(), &new_funding).expect("Failed to create"); + let patch = PatchFunding { + funding_id: funding.funding_id, + work_id: funding.work_id, + institution_id: funding.institution_id, + program: Some("Updated Program".to_string()), + project_name: funding.project_name.clone(), + project_shortname: funding.project_shortname.clone(), + grant_number: funding.grant_number.clone(), + }; + + assert!(FundingPolicy::can_create(&ctx, &new_funding, ()).is_ok()); + assert!(FundingPolicy::can_update(&ctx, &funding, &patch, ()).is_ok()); + assert!(FundingPolicy::can_delete(&ctx, &funding).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let institution = create_institution(pool.as_ref()); + let funding = make_funding( + pool.as_ref(), + work.work_id, + institution.institution_id, + Some("Program".to_string()), + ); + let patch = PatchFunding { + funding_id: funding.funding_id, + work_id: funding.work_id, + institution_id: funding.institution_id, + program: Some("Updated Program".to_string()), + project_name: funding.project_name.clone(), + project_shortname: funding.project_shortname.clone(), + grant_number: funding.grant_number.clone(), + }; + + let user = test_user_with_role("funding-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_funding = NewFunding { + work_id: work.work_id, + institution_id: institution.institution_id, + program: Some("Program".to_string()), + project_name: Some("Project Name".to_string()), + project_shortname: Some("PRJ".to_string()), + grant_number: Some("GRANT-1".to_string()), + }; + + assert!(FundingPolicy::can_create(&ctx, &new_funding, ()).is_err()); + assert!(FundingPolicy::can_update(&ctx, &funding, &patch, ()).is_err()); + assert!(FundingPolicy::can_delete(&ctx, &funding).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::graphql::types::inputs::{Direction, FundingOrderBy}; + use crate::model::tests::db::{ + create_imprint, create_institution, create_publisher, create_work, setup_test_db, + test_context, + }; + use crate::model::Crud; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let institution = create_institution(pool.as_ref()); + + let new_funding = NewFunding { + work_id: work.work_id, + institution_id: institution.institution_id, + program: Some(format!("Program {}", Uuid::new_v4())), + project_name: Some("Project Name".to_string()), + project_shortname: Some("PRJ".to_string()), + grant_number: Some("GRANT-1".to_string()), + }; + + let funding = Funding::create(pool.as_ref(), &new_funding).expect("Failed to create"); + let fetched = + Funding::from_id(pool.as_ref(), &funding.funding_id).expect("Failed to fetch"); + assert_eq!(funding.funding_id, fetched.funding_id); + + let patch = PatchFunding { + funding_id: funding.funding_id, + work_id: funding.work_id, + institution_id: funding.institution_id, + program: Some("Updated Program".to_string()), + project_name: funding.project_name.clone(), + project_shortname: funding.project_shortname.clone(), + grant_number: Some("GRANT-2".to_string()), + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = funding.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.program, patch.program); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Funding::from_id(pool.as_ref(), &deleted.funding_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let institution = create_institution(pool.as_ref()); + + make_funding( + pool.as_ref(), + work.work_id, + institution.institution_id, + Some(format!("Program {}", Uuid::new_v4())), + ); + make_funding( + pool.as_ref(), + work.work_id, + institution.institution_id, + Some(format!("Program {}", Uuid::new_v4())), + ); + + let order = FundingOrderBy { + field: FundingField::FundingId, + direction: Direction::Asc, + }; + + let first = Funding::all( + pool.as_ref(), + 1, + 0, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch fundings"); + let second = Funding::all( + pool.as_ref(), + 1, + 1, + None, + FundingOrderBy { + field: FundingField::FundingId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch fundings"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].funding_id, second[0].funding_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let institution = create_institution(pool.as_ref()); + + make_funding( + pool.as_ref(), + work.work_id, + institution.institution_id, + Some(format!("Program {}", Uuid::new_v4())), + ); + make_funding( + pool.as_ref(), + work.work_id, + institution.institution_id, + Some(format!("Program {}", Uuid::new_v4())), + ); + + let count = Funding::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count fundings"); + assert_eq!(count, 2); + } + + #[test] + fn crud_filter_parent_work_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + let institution = create_institution(pool.as_ref()); + + let matches = make_funding( + pool.as_ref(), + work.work_id, + institution.institution_id, + Some("Program Match".to_string()), + ); + make_funding( + pool.as_ref(), + other_work.work_id, + institution.institution_id, + Some("Program Other".to_string()), + ); + + let filtered = Funding::all( + pool.as_ref(), + 10, + 0, + None, + FundingOrderBy { + field: FundingField::FundingId, + direction: Direction::Asc, + }, + vec![], + Some(work.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter fundings by work"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].funding_id, matches.funding_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let institution = create_institution(pool.as_ref()); + + let first = make_funding( + pool.as_ref(), + work.work_id, + institution.institution_id, + Some(format!("Program {}", Uuid::new_v4())), + ); + let second = make_funding( + pool.as_ref(), + work.work_id, + institution.institution_id, + Some(format!("Program {}", Uuid::new_v4())), + ); + let mut ids = [first.funding_id, second.funding_id]; + ids.sort(); + + let asc = Funding::all( + pool.as_ref(), + 2, + 0, + None, + FundingOrderBy { + field: FundingField::FundingId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order fundings (asc)"); + + let desc = Funding::all( + pool.as_ref(), + 2, + 0, + None, + FundingOrderBy { + field: FundingField::FundingId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order fundings (desc)"); + + assert_eq!(asc[0].funding_id, ids[0]); + assert_eq!(desc[0].funding_id, ids[1]); + } + + #[test] + fn crud_filter_parent_institution_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let institution = create_institution(pool.as_ref()); + let other_institution = create_institution(pool.as_ref()); + + let matches = make_funding( + pool.as_ref(), + work.work_id, + institution.institution_id, + Some("Program Match".to_string()), + ); + make_funding( + pool.as_ref(), + work.work_id, + other_institution.institution_id, + Some("Program Other".to_string()), + ); + + let filtered = Funding::all( + pool.as_ref(), + 10, + 0, + None, + FundingOrderBy { + field: FundingField::FundingId, + direction: Direction::Asc, + }, + vec![], + None, + Some(institution.institution_id), + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter fundings by institution"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].funding_id, matches.funding_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let institution = create_institution(pool.as_ref()); + let matches = make_funding( + pool.as_ref(), + work.work_id, + institution.institution_id, + Some("Program Match".to_string()), + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let other_work = create_work(pool.as_ref(), &other_imprint); + let other_institution = create_institution(pool.as_ref()); + make_funding( + pool.as_ref(), + other_work.work_id, + other_institution.institution_id, + Some("Program Other".to_string()), + ); + + let filtered = Funding::all( + pool.as_ref(), + 10, + 0, + None, + FundingOrderBy { + field: FundingField::FundingId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter fundings by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].funding_id, matches.funding_id); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let institution = create_institution(pool.as_ref()); + + Funding::create( + pool.as_ref(), + &NewFunding { + work_id: work.work_id, + institution_id: institution.institution_id, + program: Some("Program A".to_string()), + project_name: Some("Project A".to_string()), + project_shortname: Some("PA".to_string()), + grant_number: Some("GRANT-A".to_string()), + }, + ) + .expect("Failed to create funding"); + Funding::create( + pool.as_ref(), + &NewFunding { + work_id: work.work_id, + institution_id: institution.institution_id, + program: Some("Program B".to_string()), + project_name: Some("Project B".to_string()), + project_shortname: Some("PB".to_string()), + grant_number: Some("GRANT-B".to_string()), + }, + ) + .expect("Failed to create funding"); + + let fields: Vec<fn() -> FundingField> = vec![ + || FundingField::FundingId, + || FundingField::WorkId, + || FundingField::InstitutionId, + || FundingField::Program, + || FundingField::ProjectName, + || FundingField::ProjectShortname, + || FundingField::GrantNumber, + || FundingField::CreatedAt, + || FundingField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Funding::all( + pool.as_ref(), + 10, + 0, + None, + FundingOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order fundings"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/imprint/crud.rs b/thoth-api/src/model/imprint/crud.rs index 49816b10..3366649c 100644 --- a/thoth-api/src/model/imprint/crud.rs +++ b/thoth-api/src/model/imprint/crud.rs @@ -2,10 +2,8 @@ use super::{ Imprint, ImprintField, ImprintHistory, ImprintOrderBy, NewImprint, NewImprintHistory, PatchImprint, }; -use crate::graphql::utils::Direction; use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{imprint, imprint_history}; -use crate::{crud_methods, db_insert}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, }; @@ -19,6 +17,7 @@ impl Crud for Imprint { type FilterParameter1 = (); type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.imprint_id @@ -36,36 +35,40 @@ impl Crud for Imprint { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Imprint>> { use crate::schema::imprint::dsl::*; let mut connection = db.get()?; let mut query = imprint.into_boxed(); query = match order.field { - ImprintField::ImprintId => match order.direction { - Direction::Asc => query.order(imprint_id.asc()), - Direction::Desc => query.order(imprint_id.desc()), - }, - ImprintField::ImprintName => match order.direction { - Direction::Asc => query.order(imprint_name.asc()), - Direction::Desc => query.order(imprint_name.desc()), - }, - ImprintField::ImprintUrl => match order.direction { - Direction::Asc => query.order(imprint_url.asc()), - Direction::Desc => query.order(imprint_url.desc()), - }, - ImprintField::CrossmarkDoi => match order.direction { - Direction::Asc => query.order(crossmark_doi.asc()), - Direction::Desc => query.order(crossmark_doi.desc()), - }, - ImprintField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - ImprintField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + ImprintField::ImprintId => { + apply_directional_order!(query, order.direction, order, imprint_id) + } + ImprintField::ImprintName => { + apply_directional_order!(query, order.direction, order, imprint_name) + } + ImprintField::ImprintUrl => { + apply_directional_order!(query, order.direction, order, imprint_url) + } + ImprintField::CrossmarkDoi => { + apply_directional_order!(query, order.direction, order, crossmark_doi) + } + ImprintField::DefaultCurrency => { + apply_directional_order!(query, order.direction, order, default_currency) + } + ImprintField::DefaultPlace => { + apply_directional_order!(query, order.direction, order, default_place) + } + ImprintField::DefaultLocale => { + apply_directional_order!(query, order.direction, order, default_locale) + } + ImprintField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + ImprintField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if !publishers.is_empty() { query = query.filter(publisher_id.eq_any(publishers)); @@ -94,6 +97,7 @@ impl Crud for Imprint { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::imprint::dsl::*; let mut connection = db.get()?; @@ -120,20 +124,20 @@ impl Crud for Imprint { .map_err(Into::into) } - fn publisher_id(&self, _db: &crate::db::PgPool) -> ThothResult<Uuid> { - Ok(self.publisher_id) - } - crud_methods!(imprint::table, imprint::dsl::imprint); } +publisher_id_impls!(Imprint, NewImprint, PatchImprint, |s, _db| { + Ok(s.publisher_id) +}); + impl HistoryEntry for Imprint { type NewHistoryEntity = NewImprintHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { imprint_id: self.imprint_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -144,27 +148,3 @@ impl DbInsert for NewImprintHistory { db_insert!(imprint_history::table); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_imprint_pk() { - let imprint: Imprint = Default::default(); - assert_eq!(imprint.pk(), imprint.imprint_id); - } - - #[test] - fn test_new_imprint_history_from_imprint() { - let imprint: Imprint = Default::default(); - let account_id: Uuid = Default::default(); - let new_imprint_history = imprint.new_history_entry(&account_id); - assert_eq!(new_imprint_history.imprint_id, imprint.imprint_id); - assert_eq!(new_imprint_history.account_id, account_id); - assert_eq!( - new_imprint_history.data, - serde_json::Value::String(serde_json::to_string(&imprint).unwrap()) - ); - } -} diff --git a/thoth-api/src/model/imprint/mod.rs b/thoth-api/src/model/imprint/mod.rs index 7333925d..9b11e875 100644 --- a/thoth-api/src/model/imprint/mod.rs +++ b/thoth-api/src/model/imprint/mod.rs @@ -1,12 +1,11 @@ -use crate::model::Doi; +use crate::model::{locale::LocaleCode, price::CurrencyCode, Doi}; use serde::Deserialize; use serde::Serialize; use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; -use crate::model::publisher::Publisher; +use crate::graphql::types::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::imprint; @@ -30,11 +29,14 @@ pub enum ImprintField { ImprintUrl, #[strum(serialize = "CrossmarkDOI")] CrossmarkDoi, + DefaultCurrency, + DefaultPlace, + DefaultLocale, CreatedAt, UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Imprint { @@ -43,24 +45,19 @@ pub struct Imprint { pub imprint_name: String, pub imprint_url: Option<String>, pub crossmark_doi: Option<Doi>, + pub s3_bucket: Option<String>, + pub cdn_domain: Option<String>, + pub cloudfront_dist_id: Option<String>, + pub default_currency: Option<CurrencyCode>, + pub default_place: Option<String>, + pub default_locale: Option<LocaleCode>, pub created_at: Timestamp, pub updated_at: Timestamp, } -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ImprintWithPublisher { - pub imprint_id: Uuid, - pub imprint_name: String, - pub imprint_url: Option<String>, - pub crossmark_doi: Option<Doi>, - pub updated_at: Timestamp, - pub publisher: Publisher, -} - #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new brand under which a publisher issues works"), diesel(table_name = imprint) )] @@ -69,11 +66,17 @@ pub struct NewImprint { pub imprint_name: String, pub imprint_url: Option<String>, pub crossmark_doi: Option<Doi>, + pub s3_bucket: Option<String>, + pub cdn_domain: Option<String>, + pub cloudfront_dist_id: Option<String>, + pub default_currency: Option<CurrencyCode>, + pub default_place: Option<String>, + pub default_locale: Option<LocaleCode>, } #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing brand under which a publisher issues works"), diesel(table_name = imprint, treat_none_as_null = true) )] @@ -83,25 +86,31 @@ pub struct PatchImprint { pub imprint_name: String, pub imprint_url: Option<String>, pub crossmark_doi: Option<Doi>, + pub s3_bucket: Option<String>, + pub cdn_domain: Option<String>, + pub cloudfront_dist_id: Option<String>, + pub default_currency: Option<CurrencyCode>, + pub default_place: Option<String>, + pub default_locale: Option<LocaleCode>, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct ImprintHistory { pub imprint_history_id: Uuid, pub imprint_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = imprint_history) )] pub struct NewImprintHistory { pub imprint_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -116,53 +125,11 @@ pub struct ImprintOrderBy { pub direction: Direction, } -#[test] -fn test_imprintfield_default() { - let impfield: ImprintField = Default::default(); - assert_eq!(impfield, ImprintField::ImprintName); -} - -#[test] -fn test_imprintfield_display() { - assert_eq!(format!("{}", ImprintField::ImprintId), "ID"); - assert_eq!(format!("{}", ImprintField::ImprintName), "Imprint"); - assert_eq!(format!("{}", ImprintField::ImprintUrl), "ImprintURL"); - assert_eq!(format!("{}", ImprintField::CrossmarkDoi), "CrossmarkDOI"); - assert_eq!(format!("{}", ImprintField::CreatedAt), "CreatedAt"); - assert_eq!(format!("{}", ImprintField::UpdatedAt), "UpdatedAt"); -} - -#[test] -fn test_imprintfield_fromstr() { - use std::str::FromStr; - assert_eq!( - ImprintField::from_str("ID").unwrap(), - ImprintField::ImprintId - ); - assert_eq!( - ImprintField::from_str("Imprint").unwrap(), - ImprintField::ImprintName - ); - assert_eq!( - ImprintField::from_str("ImprintURL").unwrap(), - ImprintField::ImprintUrl - ); - assert_eq!( - ImprintField::from_str("CrossmarkDOI").unwrap(), - ImprintField::CrossmarkDoi - ); - assert_eq!( - ImprintField::from_str("CreatedAt").unwrap(), - ImprintField::CreatedAt - ); - assert_eq!( - ImprintField::from_str("UpdatedAt").unwrap(), - ImprintField::UpdatedAt - ); - assert!(ImprintField::from_str("ImprintID").is_err()); - assert!(ImprintField::from_str("Publisher").is_err()); - assert!(ImprintField::from_str("Website").is_err()); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::ImprintPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/imprint/policy.rs b/thoth-api/src/model/imprint/policy.rs new file mode 100644 index 00000000..93a9c040 --- /dev/null +++ b/thoth-api/src/model/imprint/policy.rs @@ -0,0 +1,53 @@ +use crate::model::imprint::{Imprint, NewImprint, PatchImprint}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Imprint`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct ImprintPolicy; + +impl CreatePolicy<NewImprint> for ImprintPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewImprint, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + + if data.s3_bucket.is_some() + || data.cdn_domain.is_some() + || data.cloudfront_dist_id.is_some() + { + ctx.require_superuser()?; + } + + Ok(()) + } +} + +impl UpdatePolicy<Imprint, PatchImprint> for ImprintPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Imprint, + patch: &PatchImprint, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_admin_for(current)?; + ctx.require_publisher_admin_for(patch)?; + + if patch.s3_bucket != current.s3_bucket + || patch.cdn_domain != current.cdn_domain + || patch.cloudfront_dist_id != current.cloudfront_dist_id + { + ctx.require_superuser()?; + } + + Ok(()) + } +} + +impl DeletePolicy<Imprint> for ImprintPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Imprint) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/imprint/tests.rs b/thoth-api/src/model/imprint/tests.rs new file mode 100644 index 00000000..64eadb23 --- /dev/null +++ b/thoth-api/src/model/imprint/tests.rs @@ -0,0 +1,631 @@ +use super::*; +use crate::model::Doi; + +mod defaults { + use super::*; + + #[test] + fn imprintfield_default_is_imprint_name() { + let impfield: ImprintField = Default::default(); + assert_eq!(impfield, ImprintField::ImprintName); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn imprintfield_display_formats_expected_strings() { + assert_eq!(format!("{}", ImprintField::ImprintId), "ID"); + assert_eq!(format!("{}", ImprintField::ImprintName), "Imprint"); + assert_eq!(format!("{}", ImprintField::ImprintUrl), "ImprintURL"); + assert_eq!(format!("{}", ImprintField::CrossmarkDoi), "CrossmarkDOI"); + assert_eq!( + format!("{}", ImprintField::DefaultCurrency), + "DefaultCurrency" + ); + assert_eq!(format!("{}", ImprintField::DefaultPlace), "DefaultPlace"); + assert_eq!(format!("{}", ImprintField::DefaultLocale), "DefaultLocale"); + assert_eq!(format!("{}", ImprintField::CreatedAt), "CreatedAt"); + assert_eq!(format!("{}", ImprintField::UpdatedAt), "UpdatedAt"); + } + + #[test] + fn imprintfield_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + ImprintField::from_str("ID").unwrap(), + ImprintField::ImprintId + ); + assert_eq!( + ImprintField::from_str("Imprint").unwrap(), + ImprintField::ImprintName + ); + assert_eq!( + ImprintField::from_str("ImprintURL").unwrap(), + ImprintField::ImprintUrl + ); + assert_eq!( + ImprintField::from_str("CrossmarkDOI").unwrap(), + ImprintField::CrossmarkDoi + ); + assert_eq!( + ImprintField::from_str("DefaultCurrency").unwrap(), + ImprintField::DefaultCurrency + ); + assert_eq!( + ImprintField::from_str("DefaultPlace").unwrap(), + ImprintField::DefaultPlace + ); + assert_eq!( + ImprintField::from_str("DefaultLocale").unwrap(), + ImprintField::DefaultLocale + ); + assert_eq!( + ImprintField::from_str("CreatedAt").unwrap(), + ImprintField::CreatedAt + ); + assert_eq!( + ImprintField::from_str("UpdatedAt").unwrap(), + ImprintField::UpdatedAt + ); + assert!(ImprintField::from_str("ImprintID").is_err()); + assert!(ImprintField::from_str("Publisher").is_err()); + assert!(ImprintField::from_str("Website").is_err()); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let imprint: Imprint = Default::default(); + assert_eq!(imprint.pk(), imprint.imprint_id); + } + + #[test] + fn history_entry_serializes_model() { + let imprint: Imprint = Default::default(); + let user_id = "123456".to_string(); + let new_imprint_history = imprint.new_history_entry(&user_id); + assert_eq!(new_imprint_history.imprint_id, imprint.imprint_id); + assert_eq!(new_imprint_history.user_id, user_id); + assert_eq!( + new_imprint_history.data, + serde_json::Value::String(serde_json::to_string(&imprint).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::imprint::policy::ImprintPolicy; + use crate::model::tests::db::{ + create_imprint, create_publisher, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_create_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("imprint-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let new_imprint = NewImprint { + publisher_id: publisher.publisher_id, + imprint_name: "Policy Imprint".to_string(), + imprint_url: None, + crossmark_doi: None, + s3_bucket: None, + cdn_domain: None, + cloudfront_dist_id: None, + default_currency: None, + default_place: None, + default_locale: None, + }; + + let imprint = Imprint::create(pool.as_ref(), &new_imprint).expect("Failed to create"); + + assert!(ImprintPolicy::can_create(&ctx, &new_imprint, ()).is_ok()); + assert!(ImprintPolicy::can_delete(&ctx, &imprint).is_ok()); + } + + #[test] + fn crud_policy_requires_publisher_admin_for_update() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let patch = PatchImprint { + imprint_id: imprint.imprint_id, + publisher_id: imprint.publisher_id, + imprint_name: "Updated Imprint".to_string(), + imprint_url: imprint.imprint_url.clone(), + crossmark_doi: imprint.crossmark_doi.clone(), + s3_bucket: imprint.s3_bucket.clone(), + cdn_domain: imprint.cdn_domain.clone(), + cloudfront_dist_id: imprint.cloudfront_dist_id.clone(), + default_currency: imprint.default_currency, + default_place: imprint.default_place.clone(), + default_locale: imprint.default_locale, + }; + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("imprint-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + assert!(ImprintPolicy::can_update(&ctx, &imprint, &patch, ()).is_err()); + + let admin = test_user_with_role("imprint-admin", Role::PublisherAdmin, &org_id); + let admin_ctx = test_context_with_user(pool.clone(), admin); + assert!(ImprintPolicy::can_update(&admin_ctx, &imprint, &patch, ()).is_ok()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + use uuid::Uuid; + + use crate::model::tests::db::{create_imprint, create_publisher, setup_test_db, test_context}; + use crate::model::Crud; + + fn make_imprint(pool: &crate::db::PgPool, publisher_id: Uuid, name: String) -> Imprint { + let new_imprint = NewImprint { + publisher_id, + imprint_name: name, + imprint_url: None, + crossmark_doi: None, + s3_bucket: None, + cdn_domain: None, + cloudfront_dist_id: None, + default_currency: None, + default_place: None, + default_locale: None, + }; + + Imprint::create(pool, &new_imprint).expect("Failed to create imprint") + } + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let fetched_imprint = + Imprint::from_id(pool.as_ref(), &imprint.imprint_id).expect("Failed to fetch imprint"); + assert_eq!(imprint.imprint_id, fetched_imprint.imprint_id); + + let patch = PatchImprint { + imprint_id: imprint.imprint_id, + publisher_id: imprint.publisher_id, + imprint_name: format!("Updated {}", Uuid::new_v4()), + imprint_url: Some("https://example.com".to_string()), + crossmark_doi: imprint.crossmark_doi.clone(), + s3_bucket: imprint.s3_bucket.clone(), + cdn_domain: imprint.cdn_domain.clone(), + cloudfront_dist_id: imprint.cloudfront_dist_id.clone(), + default_currency: imprint.default_currency, + default_place: imprint.default_place.clone(), + default_locale: imprint.default_locale, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = imprint + .update(&ctx, &patch) + .expect("Failed to update imprint"); + assert_eq!(updated.imprint_name, patch.imprint_name); + + let deleted = updated + .delete(pool.as_ref()) + .expect("Failed to delete imprint"); + assert!(Imprint::from_id(pool.as_ref(), &deleted.imprint_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + create_imprint(pool.as_ref(), &publisher); + create_imprint(pool.as_ref(), &publisher); + + let order = ImprintOrderBy { + field: ImprintField::ImprintId, + direction: Direction::Asc, + }; + + let first = Imprint::all( + pool.as_ref(), + 1, + 0, + None, + order.clone(), + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch imprints"); + let second = Imprint::all( + pool.as_ref(), + 1, + 1, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch imprints"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].imprint_id, second[0].imprint_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + create_imprint(pool.as_ref(), &publisher); + create_imprint(pool.as_ref(), &publisher); + + let count = Imprint::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count imprints"); + assert_eq!(count, 2); + } + + #[test] + fn crud_count_filters_by_publishers() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + make_imprint( + pool.as_ref(), + publisher.publisher_id, + "Match Imprint".to_string(), + ); + make_imprint( + pool.as_ref(), + other_publisher.publisher_id, + "Other Imprint".to_string(), + ); + + let count = Imprint::count( + pool.as_ref(), + None, + vec![publisher.publisher_id], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count imprints by publisher"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_filter_matches_imprint_name() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let marker = format!("Filter {}", Uuid::new_v4()); + let matches = make_imprint( + pool.as_ref(), + publisher.publisher_id, + format!("Imprint {marker}"), + ); + make_imprint( + pool.as_ref(), + publisher.publisher_id, + "Other Imprint".to_string(), + ); + + let order = ImprintOrderBy { + field: ImprintField::ImprintId, + direction: Direction::Asc, + }; + + let filtered = Imprint::all( + pool.as_ref(), + 10, + 0, + Some(marker), + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter imprints"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].imprint_id, matches.imprint_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let first = create_imprint(pool.as_ref(), &publisher); + let second = create_imprint(pool.as_ref(), &publisher); + let mut ids = [first.imprint_id, second.imprint_id]; + ids.sort(); + + let asc = Imprint::all( + pool.as_ref(), + 2, + 0, + None, + ImprintOrderBy { + field: ImprintField::ImprintId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order imprints (asc)"); + + let desc = Imprint::all( + pool.as_ref(), + 2, + 0, + None, + ImprintOrderBy { + field: ImprintField::ImprintId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order imprints (desc)"); + + assert_eq!(asc[0].imprint_id, ids[0]); + assert_eq!(desc[0].imprint_id, ids[1]); + } + + #[test] + fn crud_filter_parent_publisher_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + let matches = make_imprint( + pool.as_ref(), + publisher.publisher_id, + "Match Imprint".to_string(), + ); + make_imprint( + pool.as_ref(), + other_publisher.publisher_id, + "Other Imprint".to_string(), + ); + + let filtered = Imprint::all( + pool.as_ref(), + 10, + 0, + None, + ImprintOrderBy { + field: ImprintField::ImprintId, + direction: Direction::Asc, + }, + vec![], + Some(publisher.publisher_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter imprints by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].imprint_id, matches.imprint_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + let matches = make_imprint( + pool.as_ref(), + publisher.publisher_id, + "Match Imprint".to_string(), + ); + make_imprint( + pool.as_ref(), + other_publisher.publisher_id, + "Other Imprint".to_string(), + ); + + let filtered = Imprint::all( + pool.as_ref(), + 10, + 0, + None, + ImprintOrderBy { + field: ImprintField::ImprintId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter imprints by publishers"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].imprint_id, matches.imprint_id); + } + + #[test] + fn crud_count_with_filter_matches_imprint_url() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + Imprint::create( + pool.as_ref(), + &NewImprint { + publisher_id: publisher.publisher_id, + imprint_name: "Imprint A".to_string(), + imprint_url: Some("https://example.com/imprint-a".to_string()), + crossmark_doi: None, + s3_bucket: None, + cdn_domain: None, + cloudfront_dist_id: None, + default_currency: None, + default_place: None, + default_locale: None, + }, + ) + .expect("Failed to create imprint"); + Imprint::create( + pool.as_ref(), + &NewImprint { + publisher_id: publisher.publisher_id, + imprint_name: "Imprint B".to_string(), + imprint_url: Some("https://example.com/imprint-b".to_string()), + crossmark_doi: None, + s3_bucket: None, + cdn_domain: None, + cloudfront_dist_id: None, + default_currency: None, + default_place: None, + default_locale: None, + }, + ) + .expect("Failed to create imprint"); + + let count = Imprint::count( + pool.as_ref(), + Some("imprint-a".to_string()), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count filtered imprints"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + Imprint::create( + pool.as_ref(), + &NewImprint { + publisher_id: publisher.publisher_id, + imprint_name: "Imprint A".to_string(), + imprint_url: Some("https://example.com/a".to_string()), + crossmark_doi: Some(Doi("https://doi.org/10.1234/A".to_string())), + s3_bucket: None, + cdn_domain: None, + cloudfront_dist_id: None, + default_currency: None, + default_place: None, + default_locale: None, + }, + ) + .expect("Failed to create imprint"); + Imprint::create( + pool.as_ref(), + &NewImprint { + publisher_id: publisher.publisher_id, + imprint_name: "Imprint B".to_string(), + imprint_url: Some("https://example.com/b".to_string()), + crossmark_doi: Some(Doi("https://doi.org/10.1234/B".to_string())), + s3_bucket: None, + cdn_domain: None, + cloudfront_dist_id: None, + default_currency: None, + default_place: None, + default_locale: None, + }, + ) + .expect("Failed to create imprint"); + + let fields: Vec<fn() -> ImprintField> = vec![ + || ImprintField::ImprintId, + || ImprintField::ImprintName, + || ImprintField::ImprintUrl, + || ImprintField::CrossmarkDoi, + || ImprintField::CreatedAt, + || ImprintField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Imprint::all( + pool.as_ref(), + 10, + 0, + None, + ImprintOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order imprints"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/institution/crud.rs b/thoth-api/src/model/institution/crud.rs index 1b0a6a06..fcc7fa2c 100644 --- a/thoth-api/src/model/institution/crud.rs +++ b/thoth-api/src/model/institution/crud.rs @@ -2,10 +2,9 @@ use super::{ Institution, InstitutionField, InstitutionHistory, InstitutionOrderBy, NewInstitution, NewInstitutionHistory, PatchInstitution, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::db::PgPool; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherIds}; use crate::schema::{institution, institution_history}; -use crate::{crud_methods, db_insert}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, }; @@ -19,6 +18,7 @@ impl Crud for Institution { type FilterParameter1 = (); type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.institution_id @@ -36,40 +36,32 @@ impl Crud for Institution { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Institution>> { use crate::schema::institution::dsl::*; let mut connection = db.get()?; let mut query = institution.into_boxed(); query = match order.field { - InstitutionField::InstitutionId => match order.direction { - Direction::Asc => query.order(institution_id.asc()), - Direction::Desc => query.order(institution_id.desc()), - }, - InstitutionField::InstitutionName => match order.direction { - Direction::Asc => query.order(institution_name.asc()), - Direction::Desc => query.order(institution_name.desc()), - }, - InstitutionField::InstitutionDoi => match order.direction { - Direction::Asc => query.order(institution_doi.asc()), - Direction::Desc => query.order(institution_doi.desc()), - }, - InstitutionField::Ror => match order.direction { - Direction::Asc => query.order(ror.asc()), - Direction::Desc => query.order(ror.desc()), - }, - InstitutionField::CountryCode => match order.direction { - Direction::Asc => query.order(country_code.asc()), - Direction::Desc => query.order(country_code.desc()), - }, - InstitutionField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - InstitutionField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + InstitutionField::InstitutionId => { + apply_directional_order!(query, order.direction, order, institution_id) + } + InstitutionField::InstitutionName => { + apply_directional_order!(query, order.direction, order, institution_name) + } + InstitutionField::InstitutionDoi => { + apply_directional_order!(query, order.direction, order, institution_doi) + } + InstitutionField::Ror => apply_directional_order!(query, order.direction, order, ror), + InstitutionField::CountryCode => { + apply_directional_order!(query, order.direction, order, country_code) + } + InstitutionField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + InstitutionField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if let Some(filter) = filter { query = query.filter( @@ -93,6 +85,7 @@ impl Crud for Institution { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::institution::dsl::*; let mut connection = db.get()?; @@ -117,22 +110,47 @@ impl Crud for Institution { .map_err(Into::into) } - fn publisher_id(&self, _db: &crate::db::PgPool) -> ThothResult<Uuid> { - Err(ThothError::InternalError( - "Method publisher_id() is not supported for Institution objects".to_string(), - )) - } - crud_methods!(institution::table, institution::dsl::institution); } +impl PublisherIds for Institution { + fn publisher_ids(&self, db: &PgPool) -> ThothResult<Vec<Uuid>> { + let mut connection = db.get()?; + let publishers_via_affiliation = crate::schema::publisher::table + .inner_join( + crate::schema::imprint::table.inner_join( + crate::schema::work::table.inner_join( + crate::schema::contribution::table + .inner_join(crate::schema::affiliation::table), + ), + ), + ) + .select(crate::schema::publisher::publisher_id) + .filter(crate::schema::affiliation::institution_id.eq(self.institution_id)) + .distinct() + .load::<Uuid>(&mut connection) + .map_err(|_| ThothError::InternalError("Unable to load records".into()))?; + let publishers_via_funding = + crate::schema::publisher::table + .inner_join(crate::schema::imprint::table.inner_join( + crate::schema::work::table.inner_join(crate::schema::funding::table), + )) + .select(crate::schema::publisher::publisher_id) + .filter(crate::schema::funding::institution_id.eq(self.institution_id)) + .distinct() + .load::<Uuid>(&mut connection) + .map_err(|_| ThothError::InternalError("Unable to load records".into()))?; + Ok([publishers_via_affiliation, publishers_via_funding].concat()) + } +} + impl HistoryEntry for Institution { type NewHistoryEntity = NewInstitutionHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { institution_id: self.institution_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -143,65 +161,3 @@ impl DbInsert for NewInstitutionHistory { db_insert!(institution_history::table); } - -impl Institution { - pub fn linked_publisher_ids(&self, db: &crate::db::PgPool) -> ThothResult<Vec<Uuid>> { - institution_linked_publisher_ids(self.institution_id, db) - } -} - -fn institution_linked_publisher_ids( - institution_id: Uuid, - db: &crate::db::PgPool, -) -> ThothResult<Vec<Uuid>> { - let mut connection = db.get()?; - let publishers_via_affiliation = crate::schema::publisher::table - .inner_join(crate::schema::imprint::table.inner_join( - crate::schema::work::table.inner_join( - crate::schema::contribution::table.inner_join(crate::schema::affiliation::table), - ), - )) - .select(crate::schema::publisher::publisher_id) - .filter(crate::schema::affiliation::institution_id.eq(institution_id)) - .distinct() - .load::<Uuid>(&mut connection) - .map_err(|_| ThothError::InternalError("Unable to load records".into()))?; - let publishers_via_funding = crate::schema::publisher::table - .inner_join( - crate::schema::imprint::table - .inner_join(crate::schema::work::table.inner_join(crate::schema::funding::table)), - ) - .select(crate::schema::publisher::publisher_id) - .filter(crate::schema::funding::institution_id.eq(institution_id)) - .distinct() - .load::<Uuid>(&mut connection) - .map_err(|_| ThothError::InternalError("Unable to load records".into()))?; - Ok([publishers_via_affiliation, publishers_via_funding].concat()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_institution_pk() { - let institution: Institution = Default::default(); - assert_eq!(institution.pk(), institution.institution_id); - } - - #[test] - fn test_new_institution_history_from_institution() { - let institution: Institution = Default::default(); - let account_id: Uuid = Default::default(); - let new_institution_history = institution.new_history_entry(&account_id); - assert_eq!( - new_institution_history.institution_id, - institution.institution_id - ); - assert_eq!(new_institution_history.account_id, account_id); - assert_eq!( - new_institution_history.data, - serde_json::Value::String(serde_json::to_string(&institution).unwrap()) - ); - } -} diff --git a/thoth-api/src/model/institution/mod.rs b/thoth-api/src/model/institution/mod.rs index ad47910a..1fc0e0e4 100644 --- a/thoth-api/src/model/institution/mod.rs +++ b/thoth-api/src/model/institution/mod.rs @@ -5,7 +5,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::types::inputs::Direction; use crate::model::Doi; use crate::model::Ror; use crate::model::Timestamp; @@ -37,7 +37,7 @@ pub enum InstitutionField { UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Institution { @@ -52,7 +52,7 @@ pub struct Institution { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new organisation with which contributors may be affiliated or by which works may be funded"), diesel(table_name = institution) )] @@ -65,7 +65,7 @@ pub struct NewInstitution { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing organisation with which contributors may be affiliated or by which works may be funded"), diesel(table_name = institution, treat_none_as_null = true) )] @@ -79,7 +79,7 @@ pub struct PatchInstitution { #[cfg_attr( feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), graphql(description = "Three-letter ISO 3166-1 code representing a country"), ExistingTypePath = "crate::schema::sql_types::CountryCode" )] @@ -865,23 +865,23 @@ pub enum CountryCode { Zwe, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct InstitutionHistory { pub institution_history_id: Uuid, pub institution_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = institution_history) )] pub struct NewInstitutionHistory { pub institution_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -908,913 +908,11 @@ impl fmt::Display for Institution { } } -#[test] -fn test_institutionfield_default() { - let fundfield: InstitutionField = Default::default(); - assert_eq!(fundfield, InstitutionField::InstitutionName); -} - -#[test] -fn test_institutionfield_display() { - assert_eq!(format!("{}", InstitutionField::InstitutionId), "ID"); - assert_eq!( - format!("{}", InstitutionField::InstitutionName), - "Institution" - ); - assert_eq!(format!("{}", InstitutionField::InstitutionDoi), "DOI"); - assert_eq!(format!("{}", InstitutionField::Ror), "ROR ID"); - assert_eq!(format!("{}", InstitutionField::CountryCode), "Country"); - assert_eq!(format!("{}", InstitutionField::CreatedAt), "CreatedAt"); - assert_eq!(format!("{}", InstitutionField::UpdatedAt), "UpdatedAt"); -} - -#[test] -fn test_institutionfield_fromstr() { - use std::str::FromStr; - assert_eq!( - InstitutionField::from_str("ID").unwrap(), - InstitutionField::InstitutionId - ); - assert_eq!( - InstitutionField::from_str("Institution").unwrap(), - InstitutionField::InstitutionName - ); - assert_eq!( - InstitutionField::from_str("DOI").unwrap(), - InstitutionField::InstitutionDoi - ); - assert_eq!( - InstitutionField::from_str("ROR ID").unwrap(), - InstitutionField::Ror - ); - assert_eq!( - InstitutionField::from_str("Country").unwrap(), - InstitutionField::CountryCode - ); - assert_eq!( - InstitutionField::from_str("CreatedAt").unwrap(), - InstitutionField::CreatedAt - ); - assert_eq!( - InstitutionField::from_str("UpdatedAt").unwrap(), - InstitutionField::UpdatedAt - ); - assert!(InstitutionField::from_str("InstitutionID").is_err()); - assert!(InstitutionField::from_str("Website").is_err()); - assert!(InstitutionField::from_str("Fundings").is_err()); -} - -#[test] -fn test_countrycode_display() { - assert_eq!(format!("{}", CountryCode::Afg), "Afghanistan"); - assert_eq!(format!("{}", CountryCode::Ala), "Åland Islands"); - assert_eq!(format!("{}", CountryCode::Alb), "Albania"); - assert_eq!(format!("{}", CountryCode::Dza), "Algeria"); - assert_eq!(format!("{}", CountryCode::Asm), "American Samoa"); - assert_eq!(format!("{}", CountryCode::And), "Andorra"); - assert_eq!(format!("{}", CountryCode::Ago), "Angola"); - assert_eq!(format!("{}", CountryCode::Aia), "Anguilla"); - assert_eq!(format!("{}", CountryCode::Ata), "Antarctica"); - assert_eq!(format!("{}", CountryCode::Atg), "Antigua and Barbuda"); - assert_eq!(format!("{}", CountryCode::Arg), "Argentina"); - assert_eq!(format!("{}", CountryCode::Arm), "Armenia"); - assert_eq!(format!("{}", CountryCode::Abw), "Aruba"); - assert_eq!(format!("{}", CountryCode::Aus), "Australia"); - assert_eq!(format!("{}", CountryCode::Aut), "Austria"); - assert_eq!(format!("{}", CountryCode::Aze), "Azerbaijan"); - assert_eq!(format!("{}", CountryCode::Bhs), "Bahamas"); - assert_eq!(format!("{}", CountryCode::Bhr), "Bahrain"); - assert_eq!(format!("{}", CountryCode::Bgd), "Bangladesh"); - assert_eq!(format!("{}", CountryCode::Brb), "Barbados"); - assert_eq!(format!("{}", CountryCode::Blr), "Belarus"); - assert_eq!(format!("{}", CountryCode::Bel), "Belgium"); - assert_eq!(format!("{}", CountryCode::Blz), "Belize"); - assert_eq!(format!("{}", CountryCode::Ben), "Benin"); - assert_eq!(format!("{}", CountryCode::Bmu), "Bermuda"); - assert_eq!(format!("{}", CountryCode::Btn), "Bhutan"); - assert_eq!(format!("{}", CountryCode::Bol), "Bolivia"); - assert_eq!( - format!("{}", CountryCode::Bes), - "Bonaire, Sint Eustatius and Saba" - ); - assert_eq!(format!("{}", CountryCode::Bih), "Bosnia and Herzegovina"); - assert_eq!(format!("{}", CountryCode::Bwa), "Botswana"); - assert_eq!(format!("{}", CountryCode::Bvt), "Bouvet Island"); - assert_eq!(format!("{}", CountryCode::Bra), "Brazil"); - assert_eq!( - format!("{}", CountryCode::Iot), - "British Indian Ocean Territory" - ); - assert_eq!(format!("{}", CountryCode::Brn), "Brunei"); - assert_eq!(format!("{}", CountryCode::Bgr), "Bulgaria"); - assert_eq!(format!("{}", CountryCode::Bfa), "Burkina Faso"); - assert_eq!(format!("{}", CountryCode::Bdi), "Burundi"); - assert_eq!(format!("{}", CountryCode::Cpv), "Cabo Verde"); - assert_eq!(format!("{}", CountryCode::Khm), "Cambodia"); - assert_eq!(format!("{}", CountryCode::Cmr), "Cameroon"); - assert_eq!(format!("{}", CountryCode::Can), "Canada"); - assert_eq!(format!("{}", CountryCode::Cym), "Cayman Islands"); - assert_eq!(format!("{}", CountryCode::Caf), "Central African Republic"); - assert_eq!(format!("{}", CountryCode::Tcd), "Chad"); - assert_eq!(format!("{}", CountryCode::Chl), "Chile"); - assert_eq!(format!("{}", CountryCode::Chn), "China"); - assert_eq!(format!("{}", CountryCode::Cxr), "Christmas Island"); - assert_eq!(format!("{}", CountryCode::Cck), "Cocos (Keeling) Islands"); - assert_eq!(format!("{}", CountryCode::Col), "Colombia"); - assert_eq!(format!("{}", CountryCode::Com), "Comoros"); - assert_eq!(format!("{}", CountryCode::Cok), "Cook Islands"); - assert_eq!(format!("{}", CountryCode::Cri), "Costa Rica"); - assert_eq!(format!("{}", CountryCode::Civ), "Côte d'Ivoire"); - assert_eq!(format!("{}", CountryCode::Hrv), "Croatia"); - assert_eq!(format!("{}", CountryCode::Cub), "Cuba"); - assert_eq!(format!("{}", CountryCode::Cuw), "Curaçao"); - assert_eq!(format!("{}", CountryCode::Cyp), "Cyprus"); - assert_eq!(format!("{}", CountryCode::Cze), "Czechia"); - assert_eq!( - format!("{}", CountryCode::Cod), - "Democratic Republic of the Congo" - ); - assert_eq!(format!("{}", CountryCode::Dnk), "Denmark"); - assert_eq!(format!("{}", CountryCode::Dji), "Djibouti"); - assert_eq!(format!("{}", CountryCode::Dma), "Dominica"); - assert_eq!(format!("{}", CountryCode::Dom), "Dominican Republic"); - assert_eq!(format!("{}", CountryCode::Ecu), "Ecuador"); - assert_eq!(format!("{}", CountryCode::Egy), "Egypt"); - assert_eq!(format!("{}", CountryCode::Slv), "El Salvador"); - assert_eq!(format!("{}", CountryCode::Gnq), "Equatorial Guinea"); - assert_eq!(format!("{}", CountryCode::Eri), "Eritrea"); - assert_eq!(format!("{}", CountryCode::Est), "Estonia"); - assert_eq!(format!("{}", CountryCode::Swz), "Eswatini"); - assert_eq!(format!("{}", CountryCode::Eth), "Ethiopia"); - assert_eq!(format!("{}", CountryCode::Flk), "Falkland Islands"); - assert_eq!(format!("{}", CountryCode::Fro), "Faroe Islands"); - assert_eq!(format!("{}", CountryCode::Fji), "Fiji"); - assert_eq!(format!("{}", CountryCode::Fin), "Finland"); - assert_eq!(format!("{}", CountryCode::Fra), "France"); - assert_eq!(format!("{}", CountryCode::Guf), "French Guiana"); - assert_eq!(format!("{}", CountryCode::Pyf), "French Polynesia"); - assert_eq!( - format!("{}", CountryCode::Atf), - "French Southern Territories" - ); - assert_eq!(format!("{}", CountryCode::Gab), "Gabon"); - assert_eq!(format!("{}", CountryCode::Gmb), "Gambia"); - assert_eq!(format!("{}", CountryCode::Geo), "Georgia"); - assert_eq!(format!("{}", CountryCode::Deu), "Germany"); - assert_eq!(format!("{}", CountryCode::Gha), "Ghana"); - assert_eq!(format!("{}", CountryCode::Gib), "Gibraltar"); - assert_eq!(format!("{}", CountryCode::Grc), "Greece"); - assert_eq!(format!("{}", CountryCode::Grl), "Greenland"); - assert_eq!(format!("{}", CountryCode::Grd), "Grenada"); - assert_eq!(format!("{}", CountryCode::Glp), "Guadeloupe"); - assert_eq!(format!("{}", CountryCode::Gum), "Guam"); - assert_eq!(format!("{}", CountryCode::Gtm), "Guatemala"); - assert_eq!(format!("{}", CountryCode::Ggy), "Guernsey"); - assert_eq!(format!("{}", CountryCode::Gin), "Guinea"); - assert_eq!(format!("{}", CountryCode::Gnb), "Guinea-Bissau"); - assert_eq!(format!("{}", CountryCode::Guy), "Guyana"); - assert_eq!(format!("{}", CountryCode::Hti), "Haiti"); - assert_eq!( - format!("{}", CountryCode::Hmd), - "Heard Island and McDonald Islands" - ); - assert_eq!(format!("{}", CountryCode::Hnd), "Honduras"); - assert_eq!(format!("{}", CountryCode::Hkg), "Hong Kong"); - assert_eq!(format!("{}", CountryCode::Hun), "Hungary"); - assert_eq!(format!("{}", CountryCode::Isl), "Iceland"); - assert_eq!(format!("{}", CountryCode::Ind), "India"); - assert_eq!(format!("{}", CountryCode::Idn), "Indonesia"); - assert_eq!(format!("{}", CountryCode::Irn), "Iran"); - assert_eq!(format!("{}", CountryCode::Irq), "Iraq"); - assert_eq!(format!("{}", CountryCode::Irl), "Ireland"); - assert_eq!(format!("{}", CountryCode::Imn), "Isle of Man"); - assert_eq!(format!("{}", CountryCode::Isr), "Israel"); - assert_eq!(format!("{}", CountryCode::Ita), "Italy"); - assert_eq!(format!("{}", CountryCode::Jam), "Jamaica"); - assert_eq!(format!("{}", CountryCode::Jpn), "Japan"); - assert_eq!(format!("{}", CountryCode::Jey), "Jersey"); - assert_eq!(format!("{}", CountryCode::Jor), "Jordan"); - assert_eq!(format!("{}", CountryCode::Kaz), "Kazakhstan"); - assert_eq!(format!("{}", CountryCode::Ken), "Kenya"); - assert_eq!(format!("{}", CountryCode::Kir), "Kiribati"); - assert_eq!(format!("{}", CountryCode::Kwt), "Kuwait"); - assert_eq!(format!("{}", CountryCode::Kgz), "Kyrgyzstan"); - assert_eq!(format!("{}", CountryCode::Lao), "Laos"); - assert_eq!(format!("{}", CountryCode::Lva), "Latvia"); - assert_eq!(format!("{}", CountryCode::Lbn), "Lebanon"); - assert_eq!(format!("{}", CountryCode::Lso), "Lesotho"); - assert_eq!(format!("{}", CountryCode::Lbr), "Liberia"); - assert_eq!(format!("{}", CountryCode::Lby), "Libya"); - assert_eq!(format!("{}", CountryCode::Lie), "Liechtenstein"); - assert_eq!(format!("{}", CountryCode::Ltu), "Lithuania"); - assert_eq!(format!("{}", CountryCode::Lux), "Luxembourg"); - assert_eq!(format!("{}", CountryCode::Mac), "Macao"); - assert_eq!(format!("{}", CountryCode::Mdg), "Madagascar"); - assert_eq!(format!("{}", CountryCode::Mwi), "Malawi"); - assert_eq!(format!("{}", CountryCode::Mys), "Malaysia"); - assert_eq!(format!("{}", CountryCode::Mdv), "Maldives"); - assert_eq!(format!("{}", CountryCode::Mli), "Mali"); - assert_eq!(format!("{}", CountryCode::Mlt), "Malta"); - assert_eq!(format!("{}", CountryCode::Mhl), "Marshall Islands"); - assert_eq!(format!("{}", CountryCode::Mtq), "Martinique"); - assert_eq!(format!("{}", CountryCode::Mrt), "Mauritania"); - assert_eq!(format!("{}", CountryCode::Mus), "Mauritius"); - assert_eq!(format!("{}", CountryCode::Myt), "Mayotte"); - assert_eq!(format!("{}", CountryCode::Mex), "Mexico"); - assert_eq!(format!("{}", CountryCode::Fsm), "Micronesia"); - assert_eq!(format!("{}", CountryCode::Mda), "Moldova"); - assert_eq!(format!("{}", CountryCode::Mco), "Monaco"); - assert_eq!(format!("{}", CountryCode::Mng), "Mongolia"); - assert_eq!(format!("{}", CountryCode::Mne), "Montenegro"); - assert_eq!(format!("{}", CountryCode::Msr), "Montserrat"); - assert_eq!(format!("{}", CountryCode::Mar), "Morocco"); - assert_eq!(format!("{}", CountryCode::Moz), "Mozambique"); - assert_eq!(format!("{}", CountryCode::Mmr), "Myanmar"); - assert_eq!(format!("{}", CountryCode::Nam), "Namibia"); - assert_eq!(format!("{}", CountryCode::Nru), "Nauru"); - assert_eq!(format!("{}", CountryCode::Npl), "Nepal"); - assert_eq!(format!("{}", CountryCode::Nld), "Netherlands"); - assert_eq!(format!("{}", CountryCode::Ncl), "New Caledonia"); - assert_eq!(format!("{}", CountryCode::Nzl), "New Zealand"); - assert_eq!(format!("{}", CountryCode::Nic), "Nicaragua"); - assert_eq!(format!("{}", CountryCode::Ner), "Niger"); - assert_eq!(format!("{}", CountryCode::Nga), "Nigeria"); - assert_eq!(format!("{}", CountryCode::Niu), "Niue"); - assert_eq!(format!("{}", CountryCode::Nfk), "Norfolk Island"); - assert_eq!(format!("{}", CountryCode::Prk), "North Korea"); - assert_eq!(format!("{}", CountryCode::Mkd), "North Macedonia"); - assert_eq!(format!("{}", CountryCode::Mnp), "Northern Mariana Islands"); - assert_eq!(format!("{}", CountryCode::Nor), "Norway"); - assert_eq!(format!("{}", CountryCode::Omn), "Oman"); - assert_eq!(format!("{}", CountryCode::Pak), "Pakistan"); - assert_eq!(format!("{}", CountryCode::Plw), "Palau"); - assert_eq!(format!("{}", CountryCode::Pse), "Palestine"); - assert_eq!(format!("{}", CountryCode::Pan), "Panama"); - assert_eq!(format!("{}", CountryCode::Png), "Papua New Guinea"); - assert_eq!(format!("{}", CountryCode::Pry), "Paraguay"); - assert_eq!(format!("{}", CountryCode::Per), "Peru"); - assert_eq!(format!("{}", CountryCode::Phl), "Philippines"); - assert_eq!(format!("{}", CountryCode::Pcn), "Pitcairn"); - assert_eq!(format!("{}", CountryCode::Pol), "Poland"); - assert_eq!(format!("{}", CountryCode::Prt), "Portugal"); - assert_eq!(format!("{}", CountryCode::Pri), "Puerto Rico"); - assert_eq!(format!("{}", CountryCode::Qat), "Qatar"); - assert_eq!(format!("{}", CountryCode::Cog), "Republic of the Congo"); - assert_eq!(format!("{}", CountryCode::Reu), "Réunion"); - assert_eq!(format!("{}", CountryCode::Rou), "Romania"); - assert_eq!(format!("{}", CountryCode::Rus), "Russia"); - assert_eq!(format!("{}", CountryCode::Rwa), "Rwanda"); - assert_eq!(format!("{}", CountryCode::Blm), "Saint Barthélemy"); - assert_eq!( - format!("{}", CountryCode::Shn), - "Saint Helena, Ascension and Tristan da Cunha" - ); - assert_eq!(format!("{}", CountryCode::Kna), "Saint Kitts and Nevis"); - assert_eq!(format!("{}", CountryCode::Lca), "Saint Lucia"); - assert_eq!(format!("{}", CountryCode::Maf), "Saint Martin"); - assert_eq!(format!("{}", CountryCode::Spm), "Saint Pierre and Miquelon"); - assert_eq!( - format!("{}", CountryCode::Vct), - "Saint Vincent and the Grenadines" - ); - assert_eq!(format!("{}", CountryCode::Wsm), "Samoa"); - assert_eq!(format!("{}", CountryCode::Smr), "San Marino"); - assert_eq!(format!("{}", CountryCode::Stp), "Sao Tome and Principe"); - assert_eq!(format!("{}", CountryCode::Sau), "Saudi Arabia"); - assert_eq!(format!("{}", CountryCode::Sen), "Senegal"); - assert_eq!(format!("{}", CountryCode::Srb), "Serbia"); - assert_eq!(format!("{}", CountryCode::Syc), "Seychelles"); - assert_eq!(format!("{}", CountryCode::Sle), "Sierra Leone"); - assert_eq!(format!("{}", CountryCode::Sgp), "Singapore"); - assert_eq!(format!("{}", CountryCode::Sxm), "Sint Maarten"); - assert_eq!(format!("{}", CountryCode::Svk), "Slovakia"); - assert_eq!(format!("{}", CountryCode::Svn), "Slovenia"); - assert_eq!(format!("{}", CountryCode::Slb), "Solomon Islands"); - assert_eq!(format!("{}", CountryCode::Som), "Somalia"); - assert_eq!(format!("{}", CountryCode::Zaf), "South Africa"); - assert_eq!( - format!("{}", CountryCode::Sgs), - "South Georgia and the South Sandwich Islands" - ); - assert_eq!(format!("{}", CountryCode::Kor), "South Korea"); - assert_eq!(format!("{}", CountryCode::Ssd), "South Sudan"); - assert_eq!(format!("{}", CountryCode::Esp), "Spain"); - assert_eq!(format!("{}", CountryCode::Lka), "Sri Lanka"); - assert_eq!(format!("{}", CountryCode::Sdn), "Sudan"); - assert_eq!(format!("{}", CountryCode::Sur), "Suriname"); - assert_eq!(format!("{}", CountryCode::Sjm), "Svalbard and Jan Mayen"); - assert_eq!(format!("{}", CountryCode::Swe), "Sweden"); - assert_eq!(format!("{}", CountryCode::Che), "Switzerland"); - assert_eq!(format!("{}", CountryCode::Syr), "Syria"); - assert_eq!(format!("{}", CountryCode::Twn), "Taiwan"); - assert_eq!(format!("{}", CountryCode::Tjk), "Tajikistan"); - assert_eq!(format!("{}", CountryCode::Tza), "Tanzania"); - assert_eq!(format!("{}", CountryCode::Tha), "Thailand"); - assert_eq!(format!("{}", CountryCode::Tls), "Timor-Leste"); - assert_eq!(format!("{}", CountryCode::Tgo), "Togo"); - assert_eq!(format!("{}", CountryCode::Tkl), "Tokelau"); - assert_eq!(format!("{}", CountryCode::Ton), "Tonga"); - assert_eq!(format!("{}", CountryCode::Tto), "Trinidad and Tobago"); - assert_eq!(format!("{}", CountryCode::Tun), "Tunisia"); - assert_eq!(format!("{}", CountryCode::Tur), "Turkey"); - assert_eq!(format!("{}", CountryCode::Tkm), "Turkmenistan"); - assert_eq!(format!("{}", CountryCode::Tca), "Turks and Caicos Islands"); - assert_eq!(format!("{}", CountryCode::Tuv), "Tuvalu"); - assert_eq!(format!("{}", CountryCode::Uga), "Uganda"); - assert_eq!(format!("{}", CountryCode::Ukr), "Ukraine"); - assert_eq!(format!("{}", CountryCode::Are), "United Arab Emirates"); - assert_eq!(format!("{}", CountryCode::Gbr), "United Kingdom"); - assert_eq!( - format!("{}", CountryCode::Umi), - "United States Minor Outlying Islands" - ); - assert_eq!(format!("{}", CountryCode::Usa), "United States of America"); - assert_eq!(format!("{}", CountryCode::Ury), "Uruguay"); - assert_eq!(format!("{}", CountryCode::Uzb), "Uzbekistan"); - assert_eq!(format!("{}", CountryCode::Vut), "Vanuatu"); - assert_eq!(format!("{}", CountryCode::Vat), "Vatican City"); - assert_eq!(format!("{}", CountryCode::Ven), "Venezuela"); - assert_eq!(format!("{}", CountryCode::Vnm), "Viet Nam"); - assert_eq!(format!("{}", CountryCode::Vgb), "Virgin Islands (British)"); - assert_eq!(format!("{}", CountryCode::Vir), "Virgin Islands (U.S.)"); - assert_eq!(format!("{}", CountryCode::Wlf), "Wallis and Futuna"); - assert_eq!(format!("{}", CountryCode::Esh), "Western Sahara"); - assert_eq!(format!("{}", CountryCode::Yem), "Yemen"); - assert_eq!(format!("{}", CountryCode::Zmb), "Zambia"); - assert_eq!(format!("{}", CountryCode::Zwe), "Zimbabwe"); -} - -#[test] -fn test_countrycode_fromstr() { - use std::str::FromStr; - assert_eq!( - CountryCode::from_str("Afghanistan").unwrap(), - CountryCode::Afg - ); - assert_eq!( - CountryCode::from_str("Åland Islands").unwrap(), - CountryCode::Ala - ); - assert_eq!(CountryCode::from_str("Albania").unwrap(), CountryCode::Alb); - assert_eq!(CountryCode::from_str("Algeria").unwrap(), CountryCode::Dza); - assert_eq!( - CountryCode::from_str("American Samoa").unwrap(), - CountryCode::Asm - ); - assert_eq!(CountryCode::from_str("Andorra").unwrap(), CountryCode::And); - assert_eq!(CountryCode::from_str("Angola").unwrap(), CountryCode::Ago); - assert_eq!(CountryCode::from_str("Anguilla").unwrap(), CountryCode::Aia); - assert_eq!( - CountryCode::from_str("Antarctica").unwrap(), - CountryCode::Ata - ); - assert_eq!( - CountryCode::from_str("Antigua and Barbuda").unwrap(), - CountryCode::Atg - ); - assert_eq!( - CountryCode::from_str("Argentina").unwrap(), - CountryCode::Arg - ); - assert_eq!(CountryCode::from_str("Armenia").unwrap(), CountryCode::Arm); - assert_eq!(CountryCode::from_str("Aruba").unwrap(), CountryCode::Abw); - assert_eq!( - CountryCode::from_str("Australia").unwrap(), - CountryCode::Aus - ); - assert_eq!(CountryCode::from_str("Austria").unwrap(), CountryCode::Aut); - assert_eq!( - CountryCode::from_str("Azerbaijan").unwrap(), - CountryCode::Aze - ); - assert_eq!(CountryCode::from_str("Bahamas").unwrap(), CountryCode::Bhs); - assert_eq!(CountryCode::from_str("Bahrain").unwrap(), CountryCode::Bhr); - assert_eq!( - CountryCode::from_str("Bangladesh").unwrap(), - CountryCode::Bgd - ); - assert_eq!(CountryCode::from_str("Barbados").unwrap(), CountryCode::Brb); - assert_eq!(CountryCode::from_str("Belarus").unwrap(), CountryCode::Blr); - assert_eq!(CountryCode::from_str("Belgium").unwrap(), CountryCode::Bel); - assert_eq!(CountryCode::from_str("Belize").unwrap(), CountryCode::Blz); - assert_eq!(CountryCode::from_str("Benin").unwrap(), CountryCode::Ben); - assert_eq!(CountryCode::from_str("Bermuda").unwrap(), CountryCode::Bmu); - assert_eq!(CountryCode::from_str("Bhutan").unwrap(), CountryCode::Btn); - assert_eq!(CountryCode::from_str("Bolivia").unwrap(), CountryCode::Bol); - assert_eq!( - CountryCode::from_str("Bonaire, Sint Eustatius and Saba").unwrap(), - CountryCode::Bes - ); - assert_eq!( - CountryCode::from_str("Bosnia and Herzegovina").unwrap(), - CountryCode::Bih - ); - assert_eq!(CountryCode::from_str("Botswana").unwrap(), CountryCode::Bwa); - assert_eq!( - CountryCode::from_str("Bouvet Island").unwrap(), - CountryCode::Bvt - ); - assert_eq!(CountryCode::from_str("Brazil").unwrap(), CountryCode::Bra); - assert_eq!( - CountryCode::from_str("British Indian Ocean Territory").unwrap(), - CountryCode::Iot - ); - assert_eq!(CountryCode::from_str("Brunei").unwrap(), CountryCode::Brn); - assert_eq!(CountryCode::from_str("Bulgaria").unwrap(), CountryCode::Bgr); - assert_eq!( - CountryCode::from_str("Burkina Faso").unwrap(), - CountryCode::Bfa - ); - assert_eq!(CountryCode::from_str("Burundi").unwrap(), CountryCode::Bdi); - assert_eq!( - CountryCode::from_str("Cabo Verde").unwrap(), - CountryCode::Cpv - ); - assert_eq!(CountryCode::from_str("Cambodia").unwrap(), CountryCode::Khm); - assert_eq!(CountryCode::from_str("Cameroon").unwrap(), CountryCode::Cmr); - assert_eq!(CountryCode::from_str("Canada").unwrap(), CountryCode::Can); - assert_eq!( - CountryCode::from_str("Cayman Islands").unwrap(), - CountryCode::Cym - ); - assert_eq!( - CountryCode::from_str("Central African Republic").unwrap(), - CountryCode::Caf - ); - assert_eq!(CountryCode::from_str("Chad").unwrap(), CountryCode::Tcd); - assert_eq!(CountryCode::from_str("Chile").unwrap(), CountryCode::Chl); - assert_eq!(CountryCode::from_str("China").unwrap(), CountryCode::Chn); - assert_eq!( - CountryCode::from_str("Christmas Island").unwrap(), - CountryCode::Cxr - ); - assert_eq!( - CountryCode::from_str("Cocos (Keeling) Islands").unwrap(), - CountryCode::Cck - ); - assert_eq!(CountryCode::from_str("Colombia").unwrap(), CountryCode::Col); - assert_eq!(CountryCode::from_str("Comoros").unwrap(), CountryCode::Com); - assert_eq!( - CountryCode::from_str("Cook Islands").unwrap(), - CountryCode::Cok - ); - assert_eq!( - CountryCode::from_str("Costa Rica").unwrap(), - CountryCode::Cri - ); - assert_eq!( - CountryCode::from_str("Côte d'Ivoire").unwrap(), - CountryCode::Civ - ); - assert_eq!(CountryCode::from_str("Croatia").unwrap(), CountryCode::Hrv); - assert_eq!(CountryCode::from_str("Cuba").unwrap(), CountryCode::Cub); - assert_eq!(CountryCode::from_str("Curaçao").unwrap(), CountryCode::Cuw); - assert_eq!(CountryCode::from_str("Cyprus").unwrap(), CountryCode::Cyp); - assert_eq!(CountryCode::from_str("Czechia").unwrap(), CountryCode::Cze); - assert_eq!( - CountryCode::from_str("Democratic Republic of the Congo").unwrap(), - CountryCode::Cod - ); - assert_eq!(CountryCode::from_str("Denmark").unwrap(), CountryCode::Dnk); - assert_eq!(CountryCode::from_str("Djibouti").unwrap(), CountryCode::Dji); - assert_eq!(CountryCode::from_str("Dominica").unwrap(), CountryCode::Dma); - assert_eq!( - CountryCode::from_str("Dominican Republic").unwrap(), - CountryCode::Dom - ); - assert_eq!(CountryCode::from_str("Ecuador").unwrap(), CountryCode::Ecu); - assert_eq!(CountryCode::from_str("Egypt").unwrap(), CountryCode::Egy); - assert_eq!( - CountryCode::from_str("El Salvador").unwrap(), - CountryCode::Slv - ); - assert_eq!( - CountryCode::from_str("Equatorial Guinea").unwrap(), - CountryCode::Gnq - ); - assert_eq!(CountryCode::from_str("Eritrea").unwrap(), CountryCode::Eri); - assert_eq!(CountryCode::from_str("Estonia").unwrap(), CountryCode::Est); - assert_eq!(CountryCode::from_str("Eswatini").unwrap(), CountryCode::Swz); - assert_eq!(CountryCode::from_str("Ethiopia").unwrap(), CountryCode::Eth); - assert_eq!( - CountryCode::from_str("Falkland Islands").unwrap(), - CountryCode::Flk - ); - assert_eq!( - CountryCode::from_str("Faroe Islands").unwrap(), - CountryCode::Fro - ); - assert_eq!(CountryCode::from_str("Fiji").unwrap(), CountryCode::Fji); - assert_eq!(CountryCode::from_str("Finland").unwrap(), CountryCode::Fin); - assert_eq!(CountryCode::from_str("France").unwrap(), CountryCode::Fra); - assert_eq!( - CountryCode::from_str("French Guiana").unwrap(), - CountryCode::Guf - ); - assert_eq!( - CountryCode::from_str("French Polynesia").unwrap(), - CountryCode::Pyf - ); - assert_eq!( - CountryCode::from_str("French Southern Territories").unwrap(), - CountryCode::Atf - ); - assert_eq!(CountryCode::from_str("Gabon").unwrap(), CountryCode::Gab); - assert_eq!(CountryCode::from_str("Gambia").unwrap(), CountryCode::Gmb); - assert_eq!(CountryCode::from_str("Georgia").unwrap(), CountryCode::Geo); - assert_eq!(CountryCode::from_str("Germany").unwrap(), CountryCode::Deu); - assert_eq!(CountryCode::from_str("Ghana").unwrap(), CountryCode::Gha); - assert_eq!( - CountryCode::from_str("Gibraltar").unwrap(), - CountryCode::Gib - ); - assert_eq!(CountryCode::from_str("Greece").unwrap(), CountryCode::Grc); - assert_eq!( - CountryCode::from_str("Greenland").unwrap(), - CountryCode::Grl - ); - assert_eq!(CountryCode::from_str("Grenada").unwrap(), CountryCode::Grd); - assert_eq!( - CountryCode::from_str("Guadeloupe").unwrap(), - CountryCode::Glp - ); - assert_eq!(CountryCode::from_str("Guam").unwrap(), CountryCode::Gum); - assert_eq!( - CountryCode::from_str("Guatemala").unwrap(), - CountryCode::Gtm - ); - assert_eq!(CountryCode::from_str("Guernsey").unwrap(), CountryCode::Ggy); - assert_eq!(CountryCode::from_str("Guinea").unwrap(), CountryCode::Gin); - assert_eq!( - CountryCode::from_str("Guinea-Bissau").unwrap(), - CountryCode::Gnb - ); - assert_eq!(CountryCode::from_str("Guyana").unwrap(), CountryCode::Guy); - assert_eq!(CountryCode::from_str("Haiti").unwrap(), CountryCode::Hti); - assert_eq!( - CountryCode::from_str("Heard Island and McDonald Islands").unwrap(), - CountryCode::Hmd - ); - assert_eq!(CountryCode::from_str("Honduras").unwrap(), CountryCode::Hnd); - assert_eq!( - CountryCode::from_str("Hong Kong").unwrap(), - CountryCode::Hkg - ); - assert_eq!(CountryCode::from_str("Hungary").unwrap(), CountryCode::Hun); - assert_eq!(CountryCode::from_str("Iceland").unwrap(), CountryCode::Isl); - assert_eq!(CountryCode::from_str("India").unwrap(), CountryCode::Ind); - assert_eq!( - CountryCode::from_str("Indonesia").unwrap(), - CountryCode::Idn - ); - assert_eq!(CountryCode::from_str("Iran").unwrap(), CountryCode::Irn); - assert_eq!(CountryCode::from_str("Iraq").unwrap(), CountryCode::Irq); - assert_eq!(CountryCode::from_str("Ireland").unwrap(), CountryCode::Irl); - assert_eq!( - CountryCode::from_str("Isle of Man").unwrap(), - CountryCode::Imn - ); - assert_eq!(CountryCode::from_str("Israel").unwrap(), CountryCode::Isr); - assert_eq!(CountryCode::from_str("Italy").unwrap(), CountryCode::Ita); - assert_eq!(CountryCode::from_str("Jamaica").unwrap(), CountryCode::Jam); - assert_eq!(CountryCode::from_str("Japan").unwrap(), CountryCode::Jpn); - assert_eq!(CountryCode::from_str("Jersey").unwrap(), CountryCode::Jey); - assert_eq!(CountryCode::from_str("Jordan").unwrap(), CountryCode::Jor); - assert_eq!( - CountryCode::from_str("Kazakhstan").unwrap(), - CountryCode::Kaz - ); - assert_eq!(CountryCode::from_str("Kenya").unwrap(), CountryCode::Ken); - assert_eq!(CountryCode::from_str("Kiribati").unwrap(), CountryCode::Kir); - assert_eq!(CountryCode::from_str("Kuwait").unwrap(), CountryCode::Kwt); - assert_eq!( - CountryCode::from_str("Kyrgyzstan").unwrap(), - CountryCode::Kgz - ); - assert_eq!(CountryCode::from_str("Laos").unwrap(), CountryCode::Lao); - assert_eq!(CountryCode::from_str("Latvia").unwrap(), CountryCode::Lva); - assert_eq!(CountryCode::from_str("Lebanon").unwrap(), CountryCode::Lbn); - assert_eq!(CountryCode::from_str("Lesotho").unwrap(), CountryCode::Lso); - assert_eq!(CountryCode::from_str("Liberia").unwrap(), CountryCode::Lbr); - assert_eq!(CountryCode::from_str("Libya").unwrap(), CountryCode::Lby); - assert_eq!( - CountryCode::from_str("Liechtenstein").unwrap(), - CountryCode::Lie - ); - assert_eq!( - CountryCode::from_str("Lithuania").unwrap(), - CountryCode::Ltu - ); - assert_eq!( - CountryCode::from_str("Luxembourg").unwrap(), - CountryCode::Lux - ); - assert_eq!(CountryCode::from_str("Macao").unwrap(), CountryCode::Mac); - assert_eq!( - CountryCode::from_str("Madagascar").unwrap(), - CountryCode::Mdg - ); - assert_eq!(CountryCode::from_str("Malawi").unwrap(), CountryCode::Mwi); - assert_eq!(CountryCode::from_str("Malaysia").unwrap(), CountryCode::Mys); - assert_eq!(CountryCode::from_str("Maldives").unwrap(), CountryCode::Mdv); - assert_eq!(CountryCode::from_str("Mali").unwrap(), CountryCode::Mli); - assert_eq!(CountryCode::from_str("Malta").unwrap(), CountryCode::Mlt); - assert_eq!( - CountryCode::from_str("Marshall Islands").unwrap(), - CountryCode::Mhl - ); - assert_eq!( - CountryCode::from_str("Martinique").unwrap(), - CountryCode::Mtq - ); - assert_eq!( - CountryCode::from_str("Mauritania").unwrap(), - CountryCode::Mrt - ); - assert_eq!( - CountryCode::from_str("Mauritius").unwrap(), - CountryCode::Mus - ); - assert_eq!(CountryCode::from_str("Mayotte").unwrap(), CountryCode::Myt); - assert_eq!(CountryCode::from_str("Mexico").unwrap(), CountryCode::Mex); - assert_eq!( - CountryCode::from_str("Micronesia").unwrap(), - CountryCode::Fsm - ); - assert_eq!(CountryCode::from_str("Moldova").unwrap(), CountryCode::Mda); - assert_eq!(CountryCode::from_str("Monaco").unwrap(), CountryCode::Mco); - assert_eq!(CountryCode::from_str("Mongolia").unwrap(), CountryCode::Mng); - assert_eq!( - CountryCode::from_str("Montenegro").unwrap(), - CountryCode::Mne - ); - assert_eq!( - CountryCode::from_str("Montserrat").unwrap(), - CountryCode::Msr - ); - assert_eq!(CountryCode::from_str("Morocco").unwrap(), CountryCode::Mar); - assert_eq!( - CountryCode::from_str("Mozambique").unwrap(), - CountryCode::Moz - ); - assert_eq!(CountryCode::from_str("Myanmar").unwrap(), CountryCode::Mmr); - assert_eq!(CountryCode::from_str("Namibia").unwrap(), CountryCode::Nam); - assert_eq!(CountryCode::from_str("Nauru").unwrap(), CountryCode::Nru); - assert_eq!(CountryCode::from_str("Nepal").unwrap(), CountryCode::Npl); - assert_eq!( - CountryCode::from_str("Netherlands").unwrap(), - CountryCode::Nld - ); - assert_eq!( - CountryCode::from_str("New Caledonia").unwrap(), - CountryCode::Ncl - ); - assert_eq!( - CountryCode::from_str("New Zealand").unwrap(), - CountryCode::Nzl - ); - assert_eq!( - CountryCode::from_str("Nicaragua").unwrap(), - CountryCode::Nic - ); - assert_eq!(CountryCode::from_str("Niger").unwrap(), CountryCode::Ner); - assert_eq!(CountryCode::from_str("Nigeria").unwrap(), CountryCode::Nga); - assert_eq!(CountryCode::from_str("Niue").unwrap(), CountryCode::Niu); - assert_eq!( - CountryCode::from_str("Norfolk Island").unwrap(), - CountryCode::Nfk - ); - assert_eq!( - CountryCode::from_str("North Korea").unwrap(), - CountryCode::Prk - ); - assert_eq!( - CountryCode::from_str("North Macedonia").unwrap(), - CountryCode::Mkd - ); - assert_eq!( - CountryCode::from_str("Northern Mariana Islands").unwrap(), - CountryCode::Mnp - ); - assert_eq!(CountryCode::from_str("Norway").unwrap(), CountryCode::Nor); - assert_eq!(CountryCode::from_str("Oman").unwrap(), CountryCode::Omn); - assert_eq!(CountryCode::from_str("Pakistan").unwrap(), CountryCode::Pak); - assert_eq!(CountryCode::from_str("Palau").unwrap(), CountryCode::Plw); - assert_eq!( - CountryCode::from_str("Palestine").unwrap(), - CountryCode::Pse - ); - assert_eq!(CountryCode::from_str("Panama").unwrap(), CountryCode::Pan); - assert_eq!( - CountryCode::from_str("Papua New Guinea").unwrap(), - CountryCode::Png - ); - assert_eq!(CountryCode::from_str("Paraguay").unwrap(), CountryCode::Pry); - assert_eq!(CountryCode::from_str("Peru").unwrap(), CountryCode::Per); - assert_eq!( - CountryCode::from_str("Philippines").unwrap(), - CountryCode::Phl - ); - assert_eq!(CountryCode::from_str("Pitcairn").unwrap(), CountryCode::Pcn); - assert_eq!(CountryCode::from_str("Poland").unwrap(), CountryCode::Pol); - assert_eq!(CountryCode::from_str("Portugal").unwrap(), CountryCode::Prt); - assert_eq!( - CountryCode::from_str("Puerto Rico").unwrap(), - CountryCode::Pri - ); - assert_eq!(CountryCode::from_str("Qatar").unwrap(), CountryCode::Qat); - assert_eq!( - CountryCode::from_str("Republic of the Congo").unwrap(), - CountryCode::Cog - ); - assert_eq!(CountryCode::from_str("Réunion").unwrap(), CountryCode::Reu); - assert_eq!(CountryCode::from_str("Romania").unwrap(), CountryCode::Rou); - assert_eq!(CountryCode::from_str("Russia").unwrap(), CountryCode::Rus); - assert_eq!(CountryCode::from_str("Rwanda").unwrap(), CountryCode::Rwa); - assert_eq!( - CountryCode::from_str("Saint Barthélemy").unwrap(), - CountryCode::Blm - ); - assert_eq!( - CountryCode::from_str("Saint Helena, Ascension and Tristan da Cunha").unwrap(), - CountryCode::Shn - ); - assert_eq!( - CountryCode::from_str("Saint Kitts and Nevis").unwrap(), - CountryCode::Kna - ); - assert_eq!( - CountryCode::from_str("Saint Lucia").unwrap(), - CountryCode::Lca - ); - assert_eq!( - CountryCode::from_str("Saint Martin").unwrap(), - CountryCode::Maf - ); - assert_eq!( - CountryCode::from_str("Saint Pierre and Miquelon").unwrap(), - CountryCode::Spm - ); - assert_eq!( - CountryCode::from_str("Saint Vincent and the Grenadines").unwrap(), - CountryCode::Vct - ); - assert_eq!(CountryCode::from_str("Samoa").unwrap(), CountryCode::Wsm); - assert_eq!( - CountryCode::from_str("San Marino").unwrap(), - CountryCode::Smr - ); - assert_eq!( - CountryCode::from_str("Sao Tome and Principe").unwrap(), - CountryCode::Stp - ); - assert_eq!( - CountryCode::from_str("Saudi Arabia").unwrap(), - CountryCode::Sau - ); - assert_eq!(CountryCode::from_str("Senegal").unwrap(), CountryCode::Sen); - assert_eq!(CountryCode::from_str("Serbia").unwrap(), CountryCode::Srb); - assert_eq!( - CountryCode::from_str("Seychelles").unwrap(), - CountryCode::Syc - ); - assert_eq!( - CountryCode::from_str("Sierra Leone").unwrap(), - CountryCode::Sle - ); - assert_eq!( - CountryCode::from_str("Singapore").unwrap(), - CountryCode::Sgp - ); - assert_eq!( - CountryCode::from_str("Sint Maarten").unwrap(), - CountryCode::Sxm - ); - assert_eq!(CountryCode::from_str("Slovakia").unwrap(), CountryCode::Svk); - assert_eq!(CountryCode::from_str("Slovenia").unwrap(), CountryCode::Svn); - assert_eq!( - CountryCode::from_str("Solomon Islands").unwrap(), - CountryCode::Slb - ); - assert_eq!(CountryCode::from_str("Somalia").unwrap(), CountryCode::Som); - assert_eq!( - CountryCode::from_str("South Africa").unwrap(), - CountryCode::Zaf - ); - assert_eq!( - CountryCode::from_str("South Georgia and the South Sandwich Islands").unwrap(), - CountryCode::Sgs - ); - assert_eq!( - CountryCode::from_str("South Korea").unwrap(), - CountryCode::Kor - ); - assert_eq!( - CountryCode::from_str("South Sudan").unwrap(), - CountryCode::Ssd - ); - assert_eq!(CountryCode::from_str("Spain").unwrap(), CountryCode::Esp); - assert_eq!( - CountryCode::from_str("Sri Lanka").unwrap(), - CountryCode::Lka - ); - assert_eq!(CountryCode::from_str("Sudan").unwrap(), CountryCode::Sdn); - assert_eq!(CountryCode::from_str("Suriname").unwrap(), CountryCode::Sur); - assert_eq!( - CountryCode::from_str("Svalbard and Jan Mayen").unwrap(), - CountryCode::Sjm - ); - assert_eq!(CountryCode::from_str("Sweden").unwrap(), CountryCode::Swe); - assert_eq!( - CountryCode::from_str("Switzerland").unwrap(), - CountryCode::Che - ); - assert_eq!(CountryCode::from_str("Syria").unwrap(), CountryCode::Syr); - assert_eq!(CountryCode::from_str("Taiwan").unwrap(), CountryCode::Twn); - assert_eq!( - CountryCode::from_str("Tajikistan").unwrap(), - CountryCode::Tjk - ); - assert_eq!(CountryCode::from_str("Tanzania").unwrap(), CountryCode::Tza); - assert_eq!(CountryCode::from_str("Thailand").unwrap(), CountryCode::Tha); - assert_eq!( - CountryCode::from_str("Timor-Leste").unwrap(), - CountryCode::Tls - ); - assert_eq!(CountryCode::from_str("Togo").unwrap(), CountryCode::Tgo); - assert_eq!(CountryCode::from_str("Tokelau").unwrap(), CountryCode::Tkl); - assert_eq!(CountryCode::from_str("Tonga").unwrap(), CountryCode::Ton); - assert_eq!( - CountryCode::from_str("Trinidad and Tobago").unwrap(), - CountryCode::Tto - ); - assert_eq!(CountryCode::from_str("Tunisia").unwrap(), CountryCode::Tun); - assert_eq!(CountryCode::from_str("Turkey").unwrap(), CountryCode::Tur); - assert_eq!( - CountryCode::from_str("Turkmenistan").unwrap(), - CountryCode::Tkm - ); - assert_eq!( - CountryCode::from_str("Turks and Caicos Islands").unwrap(), - CountryCode::Tca - ); - assert_eq!(CountryCode::from_str("Tuvalu").unwrap(), CountryCode::Tuv); - assert_eq!(CountryCode::from_str("Uganda").unwrap(), CountryCode::Uga); - assert_eq!(CountryCode::from_str("Ukraine").unwrap(), CountryCode::Ukr); - assert_eq!( - CountryCode::from_str("United Arab Emirates").unwrap(), - CountryCode::Are - ); - assert_eq!( - CountryCode::from_str("United Kingdom").unwrap(), - CountryCode::Gbr - ); - assert_eq!( - CountryCode::from_str("United States Minor Outlying Islands").unwrap(), - CountryCode::Umi - ); - assert_eq!( - CountryCode::from_str("United States of America").unwrap(), - CountryCode::Usa - ); - assert_eq!(CountryCode::from_str("Uruguay").unwrap(), CountryCode::Ury); - assert_eq!( - CountryCode::from_str("Uzbekistan").unwrap(), - CountryCode::Uzb - ); - assert_eq!(CountryCode::from_str("Vanuatu").unwrap(), CountryCode::Vut); - assert_eq!( - CountryCode::from_str("Vatican City").unwrap(), - CountryCode::Vat - ); - assert_eq!( - CountryCode::from_str("Venezuela").unwrap(), - CountryCode::Ven - ); - assert_eq!(CountryCode::from_str("Viet Nam").unwrap(), CountryCode::Vnm); - assert_eq!( - CountryCode::from_str("Virgin Islands (British)").unwrap(), - CountryCode::Vgb - ); - assert_eq!( - CountryCode::from_str("Virgin Islands (U.S.)").unwrap(), - CountryCode::Vir - ); - assert_eq!( - CountryCode::from_str("Wallis and Futuna").unwrap(), - CountryCode::Wlf - ); - assert_eq!( - CountryCode::from_str("Western Sahara").unwrap(), - CountryCode::Esh - ); - assert_eq!(CountryCode::from_str("Yemen").unwrap(), CountryCode::Yem); - assert_eq!(CountryCode::from_str("Zambia").unwrap(), CountryCode::Zmb); - assert_eq!(CountryCode::from_str("Zimbabwe").unwrap(), CountryCode::Zwe); - assert!(CountryCode::from_str("Narnia").is_err()); - assert!(CountryCode::from_str("Mesopotamia").is_err()); - assert!(CountryCode::from_str("Czechoslovakia").is_err()); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::InstitutionPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/institution/policy.rs b/thoth-api/src/model/institution/policy.rs new file mode 100644 index 00000000..1de2cbee --- /dev/null +++ b/thoth-api/src/model/institution/policy.rs @@ -0,0 +1,40 @@ +use crate::model::institution::{Institution, NewInstitution, PatchInstitution}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Institution`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct InstitutionPolicy; + +impl CreatePolicy<NewInstitution> for InstitutionPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + _data: &NewInstitution, + _params: (), + ) -> ThothResult<()> { + ctx.require_authentication()?; + Ok(()) + } +} + +impl UpdatePolicy<Institution, PatchInstitution> for InstitutionPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + _current: &Institution, + _patch: &PatchInstitution, + _params: (), + ) -> ThothResult<()> { + ctx.require_authentication()?; + Ok(()) + } +} + +impl DeletePolicy<Institution> for InstitutionPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Institution) -> ThothResult<()> { + ctx.require_publishers_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/institution/tests.rs b/thoth-api/src/model/institution/tests.rs new file mode 100644 index 00000000..51550ab5 --- /dev/null +++ b/thoth-api/src/model/institution/tests.rs @@ -0,0 +1,1388 @@ +use super::*; +use crate::model::{Crud, Doi, Ror}; +use uuid::Uuid; + +fn make_institution(pool: &crate::db::PgPool, name: String) -> Institution { + let new_institution = NewInstitution { + institution_name: name, + institution_doi: None, + ror: None, + country_code: Some(CountryCode::Gbr), + }; + + Institution::create(pool, &new_institution).expect("Failed to create institution") +} + +mod defaults { + use super::*; + + #[test] + fn institutionfield_default_is_institution_name() { + let fundfield: InstitutionField = Default::default(); + assert_eq!(fundfield, InstitutionField::InstitutionName); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn institutionfield_display_formats_expected_strings() { + assert_eq!(format!("{}", InstitutionField::InstitutionId), "ID"); + assert_eq!( + format!("{}", InstitutionField::InstitutionName), + "Institution" + ); + assert_eq!(format!("{}", InstitutionField::InstitutionDoi), "DOI"); + assert_eq!(format!("{}", InstitutionField::Ror), "ROR ID"); + assert_eq!(format!("{}", InstitutionField::CountryCode), "Country"); + assert_eq!(format!("{}", InstitutionField::CreatedAt), "CreatedAt"); + assert_eq!(format!("{}", InstitutionField::UpdatedAt), "UpdatedAt"); + } + + #[test] + fn institutionfield_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + InstitutionField::from_str("ID").unwrap(), + InstitutionField::InstitutionId + ); + assert_eq!( + InstitutionField::from_str("Institution").unwrap(), + InstitutionField::InstitutionName + ); + assert_eq!( + InstitutionField::from_str("DOI").unwrap(), + InstitutionField::InstitutionDoi + ); + assert_eq!( + InstitutionField::from_str("ROR ID").unwrap(), + InstitutionField::Ror + ); + assert_eq!( + InstitutionField::from_str("Country").unwrap(), + InstitutionField::CountryCode + ); + assert_eq!( + InstitutionField::from_str("CreatedAt").unwrap(), + InstitutionField::CreatedAt + ); + assert_eq!( + InstitutionField::from_str("UpdatedAt").unwrap(), + InstitutionField::UpdatedAt + ); + assert!(InstitutionField::from_str("InstitutionID").is_err()); + assert!(InstitutionField::from_str("Website").is_err()); + assert!(InstitutionField::from_str("Fundings").is_err()); + } + + #[test] + fn countrycode_display_formats_expected_strings() { + assert_eq!(format!("{}", CountryCode::Afg), "Afghanistan"); + assert_eq!(format!("{}", CountryCode::Ala), "Åland Islands"); + assert_eq!(format!("{}", CountryCode::Alb), "Albania"); + assert_eq!(format!("{}", CountryCode::Dza), "Algeria"); + assert_eq!(format!("{}", CountryCode::Asm), "American Samoa"); + assert_eq!(format!("{}", CountryCode::And), "Andorra"); + assert_eq!(format!("{}", CountryCode::Ago), "Angola"); + assert_eq!(format!("{}", CountryCode::Aia), "Anguilla"); + assert_eq!(format!("{}", CountryCode::Ata), "Antarctica"); + assert_eq!(format!("{}", CountryCode::Atg), "Antigua and Barbuda"); + assert_eq!(format!("{}", CountryCode::Arg), "Argentina"); + assert_eq!(format!("{}", CountryCode::Arm), "Armenia"); + assert_eq!(format!("{}", CountryCode::Abw), "Aruba"); + assert_eq!(format!("{}", CountryCode::Aus), "Australia"); + assert_eq!(format!("{}", CountryCode::Aut), "Austria"); + assert_eq!(format!("{}", CountryCode::Aze), "Azerbaijan"); + assert_eq!(format!("{}", CountryCode::Bhs), "Bahamas"); + assert_eq!(format!("{}", CountryCode::Bhr), "Bahrain"); + assert_eq!(format!("{}", CountryCode::Bgd), "Bangladesh"); + assert_eq!(format!("{}", CountryCode::Brb), "Barbados"); + assert_eq!(format!("{}", CountryCode::Blr), "Belarus"); + assert_eq!(format!("{}", CountryCode::Bel), "Belgium"); + assert_eq!(format!("{}", CountryCode::Blz), "Belize"); + assert_eq!(format!("{}", CountryCode::Ben), "Benin"); + assert_eq!(format!("{}", CountryCode::Bmu), "Bermuda"); + assert_eq!(format!("{}", CountryCode::Btn), "Bhutan"); + assert_eq!(format!("{}", CountryCode::Bol), "Bolivia"); + assert_eq!( + format!("{}", CountryCode::Bes), + "Bonaire, Sint Eustatius and Saba" + ); + assert_eq!(format!("{}", CountryCode::Bih), "Bosnia and Herzegovina"); + assert_eq!(format!("{}", CountryCode::Bwa), "Botswana"); + assert_eq!(format!("{}", CountryCode::Bvt), "Bouvet Island"); + assert_eq!(format!("{}", CountryCode::Bra), "Brazil"); + assert_eq!( + format!("{}", CountryCode::Iot), + "British Indian Ocean Territory" + ); + assert_eq!(format!("{}", CountryCode::Brn), "Brunei"); + assert_eq!(format!("{}", CountryCode::Bgr), "Bulgaria"); + assert_eq!(format!("{}", CountryCode::Bfa), "Burkina Faso"); + assert_eq!(format!("{}", CountryCode::Bdi), "Burundi"); + assert_eq!(format!("{}", CountryCode::Cpv), "Cabo Verde"); + assert_eq!(format!("{}", CountryCode::Khm), "Cambodia"); + assert_eq!(format!("{}", CountryCode::Cmr), "Cameroon"); + assert_eq!(format!("{}", CountryCode::Can), "Canada"); + assert_eq!(format!("{}", CountryCode::Cym), "Cayman Islands"); + assert_eq!(format!("{}", CountryCode::Caf), "Central African Republic"); + assert_eq!(format!("{}", CountryCode::Tcd), "Chad"); + assert_eq!(format!("{}", CountryCode::Chl), "Chile"); + assert_eq!(format!("{}", CountryCode::Chn), "China"); + assert_eq!(format!("{}", CountryCode::Cxr), "Christmas Island"); + assert_eq!(format!("{}", CountryCode::Cck), "Cocos (Keeling) Islands"); + assert_eq!(format!("{}", CountryCode::Col), "Colombia"); + assert_eq!(format!("{}", CountryCode::Com), "Comoros"); + assert_eq!(format!("{}", CountryCode::Cok), "Cook Islands"); + assert_eq!(format!("{}", CountryCode::Cri), "Costa Rica"); + assert_eq!(format!("{}", CountryCode::Civ), "Côte d'Ivoire"); + assert_eq!(format!("{}", CountryCode::Hrv), "Croatia"); + assert_eq!(format!("{}", CountryCode::Cub), "Cuba"); + assert_eq!(format!("{}", CountryCode::Cuw), "Curaçao"); + assert_eq!(format!("{}", CountryCode::Cyp), "Cyprus"); + assert_eq!(format!("{}", CountryCode::Cze), "Czechia"); + assert_eq!( + format!("{}", CountryCode::Cod), + "Democratic Republic of the Congo" + ); + assert_eq!(format!("{}", CountryCode::Dnk), "Denmark"); + assert_eq!(format!("{}", CountryCode::Dji), "Djibouti"); + assert_eq!(format!("{}", CountryCode::Dma), "Dominica"); + assert_eq!(format!("{}", CountryCode::Dom), "Dominican Republic"); + assert_eq!(format!("{}", CountryCode::Ecu), "Ecuador"); + assert_eq!(format!("{}", CountryCode::Egy), "Egypt"); + assert_eq!(format!("{}", CountryCode::Slv), "El Salvador"); + assert_eq!(format!("{}", CountryCode::Gnq), "Equatorial Guinea"); + assert_eq!(format!("{}", CountryCode::Eri), "Eritrea"); + assert_eq!(format!("{}", CountryCode::Est), "Estonia"); + assert_eq!(format!("{}", CountryCode::Swz), "Eswatini"); + assert_eq!(format!("{}", CountryCode::Eth), "Ethiopia"); + assert_eq!(format!("{}", CountryCode::Flk), "Falkland Islands"); + assert_eq!(format!("{}", CountryCode::Fro), "Faroe Islands"); + assert_eq!(format!("{}", CountryCode::Fji), "Fiji"); + assert_eq!(format!("{}", CountryCode::Fin), "Finland"); + assert_eq!(format!("{}", CountryCode::Fra), "France"); + assert_eq!(format!("{}", CountryCode::Guf), "French Guiana"); + assert_eq!(format!("{}", CountryCode::Pyf), "French Polynesia"); + assert_eq!( + format!("{}", CountryCode::Atf), + "French Southern Territories" + ); + assert_eq!(format!("{}", CountryCode::Gab), "Gabon"); + assert_eq!(format!("{}", CountryCode::Gmb), "Gambia"); + assert_eq!(format!("{}", CountryCode::Geo), "Georgia"); + assert_eq!(format!("{}", CountryCode::Deu), "Germany"); + assert_eq!(format!("{}", CountryCode::Gha), "Ghana"); + assert_eq!(format!("{}", CountryCode::Gib), "Gibraltar"); + assert_eq!(format!("{}", CountryCode::Grc), "Greece"); + assert_eq!(format!("{}", CountryCode::Grl), "Greenland"); + assert_eq!(format!("{}", CountryCode::Grd), "Grenada"); + assert_eq!(format!("{}", CountryCode::Glp), "Guadeloupe"); + assert_eq!(format!("{}", CountryCode::Gum), "Guam"); + assert_eq!(format!("{}", CountryCode::Gtm), "Guatemala"); + assert_eq!(format!("{}", CountryCode::Ggy), "Guernsey"); + assert_eq!(format!("{}", CountryCode::Gin), "Guinea"); + assert_eq!(format!("{}", CountryCode::Gnb), "Guinea-Bissau"); + assert_eq!(format!("{}", CountryCode::Guy), "Guyana"); + assert_eq!(format!("{}", CountryCode::Hti), "Haiti"); + assert_eq!( + format!("{}", CountryCode::Hmd), + "Heard Island and McDonald Islands" + ); + assert_eq!(format!("{}", CountryCode::Hnd), "Honduras"); + assert_eq!(format!("{}", CountryCode::Hkg), "Hong Kong"); + assert_eq!(format!("{}", CountryCode::Hun), "Hungary"); + assert_eq!(format!("{}", CountryCode::Isl), "Iceland"); + assert_eq!(format!("{}", CountryCode::Ind), "India"); + assert_eq!(format!("{}", CountryCode::Idn), "Indonesia"); + assert_eq!(format!("{}", CountryCode::Irn), "Iran"); + assert_eq!(format!("{}", CountryCode::Irq), "Iraq"); + assert_eq!(format!("{}", CountryCode::Irl), "Ireland"); + assert_eq!(format!("{}", CountryCode::Imn), "Isle of Man"); + assert_eq!(format!("{}", CountryCode::Isr), "Israel"); + assert_eq!(format!("{}", CountryCode::Ita), "Italy"); + assert_eq!(format!("{}", CountryCode::Jam), "Jamaica"); + assert_eq!(format!("{}", CountryCode::Jpn), "Japan"); + assert_eq!(format!("{}", CountryCode::Jey), "Jersey"); + assert_eq!(format!("{}", CountryCode::Jor), "Jordan"); + assert_eq!(format!("{}", CountryCode::Kaz), "Kazakhstan"); + assert_eq!(format!("{}", CountryCode::Ken), "Kenya"); + assert_eq!(format!("{}", CountryCode::Kir), "Kiribati"); + assert_eq!(format!("{}", CountryCode::Kwt), "Kuwait"); + assert_eq!(format!("{}", CountryCode::Kgz), "Kyrgyzstan"); + assert_eq!(format!("{}", CountryCode::Lao), "Laos"); + assert_eq!(format!("{}", CountryCode::Lva), "Latvia"); + assert_eq!(format!("{}", CountryCode::Lbn), "Lebanon"); + assert_eq!(format!("{}", CountryCode::Lso), "Lesotho"); + assert_eq!(format!("{}", CountryCode::Lbr), "Liberia"); + assert_eq!(format!("{}", CountryCode::Lby), "Libya"); + assert_eq!(format!("{}", CountryCode::Lie), "Liechtenstein"); + assert_eq!(format!("{}", CountryCode::Ltu), "Lithuania"); + assert_eq!(format!("{}", CountryCode::Lux), "Luxembourg"); + assert_eq!(format!("{}", CountryCode::Mac), "Macao"); + assert_eq!(format!("{}", CountryCode::Mdg), "Madagascar"); + assert_eq!(format!("{}", CountryCode::Mwi), "Malawi"); + assert_eq!(format!("{}", CountryCode::Mys), "Malaysia"); + assert_eq!(format!("{}", CountryCode::Mdv), "Maldives"); + assert_eq!(format!("{}", CountryCode::Mli), "Mali"); + assert_eq!(format!("{}", CountryCode::Mlt), "Malta"); + assert_eq!(format!("{}", CountryCode::Mhl), "Marshall Islands"); + assert_eq!(format!("{}", CountryCode::Mtq), "Martinique"); + assert_eq!(format!("{}", CountryCode::Mrt), "Mauritania"); + assert_eq!(format!("{}", CountryCode::Mus), "Mauritius"); + assert_eq!(format!("{}", CountryCode::Myt), "Mayotte"); + assert_eq!(format!("{}", CountryCode::Mex), "Mexico"); + assert_eq!(format!("{}", CountryCode::Fsm), "Micronesia"); + assert_eq!(format!("{}", CountryCode::Mda), "Moldova"); + assert_eq!(format!("{}", CountryCode::Mco), "Monaco"); + assert_eq!(format!("{}", CountryCode::Mng), "Mongolia"); + assert_eq!(format!("{}", CountryCode::Mne), "Montenegro"); + assert_eq!(format!("{}", CountryCode::Msr), "Montserrat"); + assert_eq!(format!("{}", CountryCode::Mar), "Morocco"); + assert_eq!(format!("{}", CountryCode::Moz), "Mozambique"); + assert_eq!(format!("{}", CountryCode::Mmr), "Myanmar"); + assert_eq!(format!("{}", CountryCode::Nam), "Namibia"); + assert_eq!(format!("{}", CountryCode::Nru), "Nauru"); + assert_eq!(format!("{}", CountryCode::Npl), "Nepal"); + assert_eq!(format!("{}", CountryCode::Nld), "Netherlands"); + assert_eq!(format!("{}", CountryCode::Ncl), "New Caledonia"); + assert_eq!(format!("{}", CountryCode::Nzl), "New Zealand"); + assert_eq!(format!("{}", CountryCode::Nic), "Nicaragua"); + assert_eq!(format!("{}", CountryCode::Ner), "Niger"); + assert_eq!(format!("{}", CountryCode::Nga), "Nigeria"); + assert_eq!(format!("{}", CountryCode::Niu), "Niue"); + assert_eq!(format!("{}", CountryCode::Nfk), "Norfolk Island"); + assert_eq!(format!("{}", CountryCode::Prk), "North Korea"); + assert_eq!(format!("{}", CountryCode::Mkd), "North Macedonia"); + assert_eq!(format!("{}", CountryCode::Mnp), "Northern Mariana Islands"); + assert_eq!(format!("{}", CountryCode::Nor), "Norway"); + assert_eq!(format!("{}", CountryCode::Omn), "Oman"); + assert_eq!(format!("{}", CountryCode::Pak), "Pakistan"); + assert_eq!(format!("{}", CountryCode::Plw), "Palau"); + assert_eq!(format!("{}", CountryCode::Pse), "Palestine"); + assert_eq!(format!("{}", CountryCode::Pan), "Panama"); + assert_eq!(format!("{}", CountryCode::Png), "Papua New Guinea"); + assert_eq!(format!("{}", CountryCode::Pry), "Paraguay"); + assert_eq!(format!("{}", CountryCode::Per), "Peru"); + assert_eq!(format!("{}", CountryCode::Phl), "Philippines"); + assert_eq!(format!("{}", CountryCode::Pcn), "Pitcairn"); + assert_eq!(format!("{}", CountryCode::Pol), "Poland"); + assert_eq!(format!("{}", CountryCode::Prt), "Portugal"); + assert_eq!(format!("{}", CountryCode::Pri), "Puerto Rico"); + assert_eq!(format!("{}", CountryCode::Qat), "Qatar"); + assert_eq!(format!("{}", CountryCode::Cog), "Republic of the Congo"); + assert_eq!(format!("{}", CountryCode::Reu), "Réunion"); + assert_eq!(format!("{}", CountryCode::Rou), "Romania"); + assert_eq!(format!("{}", CountryCode::Rus), "Russia"); + assert_eq!(format!("{}", CountryCode::Rwa), "Rwanda"); + assert_eq!(format!("{}", CountryCode::Blm), "Saint Barthélemy"); + assert_eq!( + format!("{}", CountryCode::Shn), + "Saint Helena, Ascension and Tristan da Cunha" + ); + assert_eq!(format!("{}", CountryCode::Kna), "Saint Kitts and Nevis"); + assert_eq!(format!("{}", CountryCode::Lca), "Saint Lucia"); + assert_eq!(format!("{}", CountryCode::Maf), "Saint Martin"); + assert_eq!(format!("{}", CountryCode::Spm), "Saint Pierre and Miquelon"); + assert_eq!( + format!("{}", CountryCode::Vct), + "Saint Vincent and the Grenadines" + ); + assert_eq!(format!("{}", CountryCode::Wsm), "Samoa"); + assert_eq!(format!("{}", CountryCode::Smr), "San Marino"); + assert_eq!(format!("{}", CountryCode::Stp), "Sao Tome and Principe"); + assert_eq!(format!("{}", CountryCode::Sau), "Saudi Arabia"); + assert_eq!(format!("{}", CountryCode::Sen), "Senegal"); + assert_eq!(format!("{}", CountryCode::Srb), "Serbia"); + assert_eq!(format!("{}", CountryCode::Syc), "Seychelles"); + assert_eq!(format!("{}", CountryCode::Sle), "Sierra Leone"); + assert_eq!(format!("{}", CountryCode::Sgp), "Singapore"); + assert_eq!(format!("{}", CountryCode::Sxm), "Sint Maarten"); + assert_eq!(format!("{}", CountryCode::Svk), "Slovakia"); + assert_eq!(format!("{}", CountryCode::Svn), "Slovenia"); + assert_eq!(format!("{}", CountryCode::Slb), "Solomon Islands"); + assert_eq!(format!("{}", CountryCode::Som), "Somalia"); + assert_eq!(format!("{}", CountryCode::Zaf), "South Africa"); + assert_eq!( + format!("{}", CountryCode::Sgs), + "South Georgia and the South Sandwich Islands" + ); + assert_eq!(format!("{}", CountryCode::Kor), "South Korea"); + assert_eq!(format!("{}", CountryCode::Ssd), "South Sudan"); + assert_eq!(format!("{}", CountryCode::Esp), "Spain"); + assert_eq!(format!("{}", CountryCode::Lka), "Sri Lanka"); + assert_eq!(format!("{}", CountryCode::Sdn), "Sudan"); + assert_eq!(format!("{}", CountryCode::Sur), "Suriname"); + assert_eq!(format!("{}", CountryCode::Sjm), "Svalbard and Jan Mayen"); + assert_eq!(format!("{}", CountryCode::Swe), "Sweden"); + assert_eq!(format!("{}", CountryCode::Che), "Switzerland"); + assert_eq!(format!("{}", CountryCode::Syr), "Syria"); + assert_eq!(format!("{}", CountryCode::Twn), "Taiwan"); + assert_eq!(format!("{}", CountryCode::Tjk), "Tajikistan"); + assert_eq!(format!("{}", CountryCode::Tza), "Tanzania"); + assert_eq!(format!("{}", CountryCode::Tha), "Thailand"); + assert_eq!(format!("{}", CountryCode::Tls), "Timor-Leste"); + assert_eq!(format!("{}", CountryCode::Tgo), "Togo"); + assert_eq!(format!("{}", CountryCode::Tkl), "Tokelau"); + assert_eq!(format!("{}", CountryCode::Ton), "Tonga"); + assert_eq!(format!("{}", CountryCode::Tto), "Trinidad and Tobago"); + assert_eq!(format!("{}", CountryCode::Tun), "Tunisia"); + assert_eq!(format!("{}", CountryCode::Tur), "Turkey"); + assert_eq!(format!("{}", CountryCode::Tkm), "Turkmenistan"); + assert_eq!(format!("{}", CountryCode::Tca), "Turks and Caicos Islands"); + assert_eq!(format!("{}", CountryCode::Tuv), "Tuvalu"); + assert_eq!(format!("{}", CountryCode::Uga), "Uganda"); + assert_eq!(format!("{}", CountryCode::Ukr), "Ukraine"); + assert_eq!(format!("{}", CountryCode::Are), "United Arab Emirates"); + assert_eq!(format!("{}", CountryCode::Gbr), "United Kingdom"); + assert_eq!( + format!("{}", CountryCode::Umi), + "United States Minor Outlying Islands" + ); + assert_eq!(format!("{}", CountryCode::Usa), "United States of America"); + assert_eq!(format!("{}", CountryCode::Ury), "Uruguay"); + assert_eq!(format!("{}", CountryCode::Uzb), "Uzbekistan"); + assert_eq!(format!("{}", CountryCode::Vut), "Vanuatu"); + assert_eq!(format!("{}", CountryCode::Vat), "Vatican City"); + assert_eq!(format!("{}", CountryCode::Ven), "Venezuela"); + assert_eq!(format!("{}", CountryCode::Vnm), "Viet Nam"); + assert_eq!(format!("{}", CountryCode::Vgb), "Virgin Islands (British)"); + assert_eq!(format!("{}", CountryCode::Vir), "Virgin Islands (U.S.)"); + assert_eq!(format!("{}", CountryCode::Wlf), "Wallis and Futuna"); + assert_eq!(format!("{}", CountryCode::Esh), "Western Sahara"); + assert_eq!(format!("{}", CountryCode::Yem), "Yemen"); + assert_eq!(format!("{}", CountryCode::Zmb), "Zambia"); + assert_eq!(format!("{}", CountryCode::Zwe), "Zimbabwe"); + } + + #[test] + fn countrycode_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + CountryCode::from_str("Afghanistan").unwrap(), + CountryCode::Afg + ); + assert_eq!( + CountryCode::from_str("Åland Islands").unwrap(), + CountryCode::Ala + ); + assert_eq!(CountryCode::from_str("Albania").unwrap(), CountryCode::Alb); + assert_eq!(CountryCode::from_str("Algeria").unwrap(), CountryCode::Dza); + assert_eq!( + CountryCode::from_str("American Samoa").unwrap(), + CountryCode::Asm + ); + assert_eq!(CountryCode::from_str("Andorra").unwrap(), CountryCode::And); + assert_eq!(CountryCode::from_str("Angola").unwrap(), CountryCode::Ago); + assert_eq!(CountryCode::from_str("Anguilla").unwrap(), CountryCode::Aia); + assert_eq!( + CountryCode::from_str("Antarctica").unwrap(), + CountryCode::Ata + ); + assert_eq!( + CountryCode::from_str("Antigua and Barbuda").unwrap(), + CountryCode::Atg + ); + assert_eq!( + CountryCode::from_str("Argentina").unwrap(), + CountryCode::Arg + ); + assert_eq!(CountryCode::from_str("Armenia").unwrap(), CountryCode::Arm); + assert_eq!(CountryCode::from_str("Aruba").unwrap(), CountryCode::Abw); + assert_eq!( + CountryCode::from_str("Australia").unwrap(), + CountryCode::Aus + ); + assert_eq!(CountryCode::from_str("Austria").unwrap(), CountryCode::Aut); + assert_eq!( + CountryCode::from_str("Azerbaijan").unwrap(), + CountryCode::Aze + ); + assert_eq!(CountryCode::from_str("Bahamas").unwrap(), CountryCode::Bhs); + assert_eq!(CountryCode::from_str("Bahrain").unwrap(), CountryCode::Bhr); + assert_eq!( + CountryCode::from_str("Bangladesh").unwrap(), + CountryCode::Bgd + ); + assert_eq!(CountryCode::from_str("Barbados").unwrap(), CountryCode::Brb); + assert_eq!(CountryCode::from_str("Belarus").unwrap(), CountryCode::Blr); + assert_eq!(CountryCode::from_str("Belgium").unwrap(), CountryCode::Bel); + assert_eq!(CountryCode::from_str("Belize").unwrap(), CountryCode::Blz); + assert_eq!(CountryCode::from_str("Benin").unwrap(), CountryCode::Ben); + assert_eq!(CountryCode::from_str("Bermuda").unwrap(), CountryCode::Bmu); + assert_eq!(CountryCode::from_str("Bhutan").unwrap(), CountryCode::Btn); + assert_eq!(CountryCode::from_str("Bolivia").unwrap(), CountryCode::Bol); + assert_eq!( + CountryCode::from_str("Bonaire, Sint Eustatius and Saba").unwrap(), + CountryCode::Bes + ); + assert_eq!( + CountryCode::from_str("Bosnia and Herzegovina").unwrap(), + CountryCode::Bih + ); + assert_eq!(CountryCode::from_str("Botswana").unwrap(), CountryCode::Bwa); + assert_eq!( + CountryCode::from_str("Bouvet Island").unwrap(), + CountryCode::Bvt + ); + assert_eq!(CountryCode::from_str("Brazil").unwrap(), CountryCode::Bra); + assert_eq!( + CountryCode::from_str("British Indian Ocean Territory").unwrap(), + CountryCode::Iot + ); + assert_eq!(CountryCode::from_str("Brunei").unwrap(), CountryCode::Brn); + assert_eq!(CountryCode::from_str("Bulgaria").unwrap(), CountryCode::Bgr); + assert_eq!( + CountryCode::from_str("Burkina Faso").unwrap(), + CountryCode::Bfa + ); + assert_eq!(CountryCode::from_str("Burundi").unwrap(), CountryCode::Bdi); + assert_eq!( + CountryCode::from_str("Cabo Verde").unwrap(), + CountryCode::Cpv + ); + assert_eq!(CountryCode::from_str("Cambodia").unwrap(), CountryCode::Khm); + assert_eq!(CountryCode::from_str("Cameroon").unwrap(), CountryCode::Cmr); + assert_eq!(CountryCode::from_str("Canada").unwrap(), CountryCode::Can); + assert_eq!( + CountryCode::from_str("Cayman Islands").unwrap(), + CountryCode::Cym + ); + assert_eq!( + CountryCode::from_str("Central African Republic").unwrap(), + CountryCode::Caf + ); + assert_eq!(CountryCode::from_str("Chad").unwrap(), CountryCode::Tcd); + assert_eq!(CountryCode::from_str("Chile").unwrap(), CountryCode::Chl); + assert_eq!(CountryCode::from_str("China").unwrap(), CountryCode::Chn); + assert_eq!( + CountryCode::from_str("Christmas Island").unwrap(), + CountryCode::Cxr + ); + assert_eq!( + CountryCode::from_str("Cocos (Keeling) Islands").unwrap(), + CountryCode::Cck + ); + assert_eq!(CountryCode::from_str("Colombia").unwrap(), CountryCode::Col); + assert_eq!(CountryCode::from_str("Comoros").unwrap(), CountryCode::Com); + assert_eq!( + CountryCode::from_str("Cook Islands").unwrap(), + CountryCode::Cok + ); + assert_eq!( + CountryCode::from_str("Costa Rica").unwrap(), + CountryCode::Cri + ); + assert_eq!( + CountryCode::from_str("Côte d'Ivoire").unwrap(), + CountryCode::Civ + ); + assert_eq!(CountryCode::from_str("Croatia").unwrap(), CountryCode::Hrv); + assert_eq!(CountryCode::from_str("Cuba").unwrap(), CountryCode::Cub); + assert_eq!(CountryCode::from_str("Curaçao").unwrap(), CountryCode::Cuw); + assert_eq!(CountryCode::from_str("Cyprus").unwrap(), CountryCode::Cyp); + assert_eq!(CountryCode::from_str("Czechia").unwrap(), CountryCode::Cze); + assert_eq!( + CountryCode::from_str("Democratic Republic of the Congo").unwrap(), + CountryCode::Cod + ); + assert_eq!(CountryCode::from_str("Denmark").unwrap(), CountryCode::Dnk); + assert_eq!(CountryCode::from_str("Djibouti").unwrap(), CountryCode::Dji); + assert_eq!(CountryCode::from_str("Dominica").unwrap(), CountryCode::Dma); + assert_eq!( + CountryCode::from_str("Dominican Republic").unwrap(), + CountryCode::Dom + ); + assert_eq!(CountryCode::from_str("Ecuador").unwrap(), CountryCode::Ecu); + assert_eq!(CountryCode::from_str("Egypt").unwrap(), CountryCode::Egy); + assert_eq!( + CountryCode::from_str("El Salvador").unwrap(), + CountryCode::Slv + ); + assert_eq!( + CountryCode::from_str("Equatorial Guinea").unwrap(), + CountryCode::Gnq + ); + assert_eq!(CountryCode::from_str("Eritrea").unwrap(), CountryCode::Eri); + assert_eq!(CountryCode::from_str("Estonia").unwrap(), CountryCode::Est); + assert_eq!(CountryCode::from_str("Eswatini").unwrap(), CountryCode::Swz); + assert_eq!(CountryCode::from_str("Ethiopia").unwrap(), CountryCode::Eth); + assert_eq!( + CountryCode::from_str("Falkland Islands").unwrap(), + CountryCode::Flk + ); + assert_eq!( + CountryCode::from_str("Faroe Islands").unwrap(), + CountryCode::Fro + ); + assert_eq!(CountryCode::from_str("Fiji").unwrap(), CountryCode::Fji); + assert_eq!(CountryCode::from_str("Finland").unwrap(), CountryCode::Fin); + assert_eq!(CountryCode::from_str("France").unwrap(), CountryCode::Fra); + assert_eq!( + CountryCode::from_str("French Guiana").unwrap(), + CountryCode::Guf + ); + assert_eq!( + CountryCode::from_str("French Polynesia").unwrap(), + CountryCode::Pyf + ); + assert_eq!( + CountryCode::from_str("French Southern Territories").unwrap(), + CountryCode::Atf + ); + assert_eq!(CountryCode::from_str("Gabon").unwrap(), CountryCode::Gab); + assert_eq!(CountryCode::from_str("Gambia").unwrap(), CountryCode::Gmb); + assert_eq!(CountryCode::from_str("Georgia").unwrap(), CountryCode::Geo); + assert_eq!(CountryCode::from_str("Germany").unwrap(), CountryCode::Deu); + assert_eq!(CountryCode::from_str("Ghana").unwrap(), CountryCode::Gha); + assert_eq!( + CountryCode::from_str("Gibraltar").unwrap(), + CountryCode::Gib + ); + assert_eq!(CountryCode::from_str("Greece").unwrap(), CountryCode::Grc); + assert_eq!( + CountryCode::from_str("Greenland").unwrap(), + CountryCode::Grl + ); + assert_eq!(CountryCode::from_str("Grenada").unwrap(), CountryCode::Grd); + assert_eq!( + CountryCode::from_str("Guadeloupe").unwrap(), + CountryCode::Glp + ); + assert_eq!(CountryCode::from_str("Guam").unwrap(), CountryCode::Gum); + assert_eq!( + CountryCode::from_str("Guatemala").unwrap(), + CountryCode::Gtm + ); + assert_eq!(CountryCode::from_str("Guernsey").unwrap(), CountryCode::Ggy); + assert_eq!(CountryCode::from_str("Guinea").unwrap(), CountryCode::Gin); + assert_eq!( + CountryCode::from_str("Guinea-Bissau").unwrap(), + CountryCode::Gnb + ); + assert_eq!(CountryCode::from_str("Guyana").unwrap(), CountryCode::Guy); + assert_eq!(CountryCode::from_str("Haiti").unwrap(), CountryCode::Hti); + assert_eq!( + CountryCode::from_str("Heard Island and McDonald Islands").unwrap(), + CountryCode::Hmd + ); + assert_eq!(CountryCode::from_str("Honduras").unwrap(), CountryCode::Hnd); + assert_eq!( + CountryCode::from_str("Hong Kong").unwrap(), + CountryCode::Hkg + ); + assert_eq!(CountryCode::from_str("Hungary").unwrap(), CountryCode::Hun); + assert_eq!(CountryCode::from_str("Iceland").unwrap(), CountryCode::Isl); + assert_eq!(CountryCode::from_str("India").unwrap(), CountryCode::Ind); + assert_eq!( + CountryCode::from_str("Indonesia").unwrap(), + CountryCode::Idn + ); + assert_eq!(CountryCode::from_str("Iran").unwrap(), CountryCode::Irn); + assert_eq!(CountryCode::from_str("Iraq").unwrap(), CountryCode::Irq); + assert_eq!(CountryCode::from_str("Ireland").unwrap(), CountryCode::Irl); + assert_eq!( + CountryCode::from_str("Isle of Man").unwrap(), + CountryCode::Imn + ); + assert_eq!(CountryCode::from_str("Israel").unwrap(), CountryCode::Isr); + assert_eq!(CountryCode::from_str("Italy").unwrap(), CountryCode::Ita); + assert_eq!(CountryCode::from_str("Jamaica").unwrap(), CountryCode::Jam); + assert_eq!(CountryCode::from_str("Japan").unwrap(), CountryCode::Jpn); + assert_eq!(CountryCode::from_str("Jersey").unwrap(), CountryCode::Jey); + assert_eq!(CountryCode::from_str("Jordan").unwrap(), CountryCode::Jor); + assert_eq!( + CountryCode::from_str("Kazakhstan").unwrap(), + CountryCode::Kaz + ); + assert_eq!(CountryCode::from_str("Kenya").unwrap(), CountryCode::Ken); + assert_eq!(CountryCode::from_str("Kiribati").unwrap(), CountryCode::Kir); + assert_eq!(CountryCode::from_str("Kuwait").unwrap(), CountryCode::Kwt); + assert_eq!( + CountryCode::from_str("Kyrgyzstan").unwrap(), + CountryCode::Kgz + ); + assert_eq!(CountryCode::from_str("Laos").unwrap(), CountryCode::Lao); + assert_eq!(CountryCode::from_str("Latvia").unwrap(), CountryCode::Lva); + assert_eq!(CountryCode::from_str("Lebanon").unwrap(), CountryCode::Lbn); + assert_eq!(CountryCode::from_str("Lesotho").unwrap(), CountryCode::Lso); + assert_eq!(CountryCode::from_str("Liberia").unwrap(), CountryCode::Lbr); + assert_eq!(CountryCode::from_str("Libya").unwrap(), CountryCode::Lby); + assert_eq!( + CountryCode::from_str("Liechtenstein").unwrap(), + CountryCode::Lie + ); + assert_eq!( + CountryCode::from_str("Lithuania").unwrap(), + CountryCode::Ltu + ); + assert_eq!( + CountryCode::from_str("Luxembourg").unwrap(), + CountryCode::Lux + ); + assert_eq!(CountryCode::from_str("Macao").unwrap(), CountryCode::Mac); + assert_eq!( + CountryCode::from_str("Madagascar").unwrap(), + CountryCode::Mdg + ); + assert_eq!(CountryCode::from_str("Malawi").unwrap(), CountryCode::Mwi); + assert_eq!(CountryCode::from_str("Malaysia").unwrap(), CountryCode::Mys); + assert_eq!(CountryCode::from_str("Maldives").unwrap(), CountryCode::Mdv); + assert_eq!(CountryCode::from_str("Mali").unwrap(), CountryCode::Mli); + assert_eq!(CountryCode::from_str("Malta").unwrap(), CountryCode::Mlt); + assert_eq!( + CountryCode::from_str("Marshall Islands").unwrap(), + CountryCode::Mhl + ); + assert_eq!( + CountryCode::from_str("Martinique").unwrap(), + CountryCode::Mtq + ); + assert_eq!( + CountryCode::from_str("Mauritania").unwrap(), + CountryCode::Mrt + ); + assert_eq!( + CountryCode::from_str("Mauritius").unwrap(), + CountryCode::Mus + ); + assert_eq!(CountryCode::from_str("Mayotte").unwrap(), CountryCode::Myt); + assert_eq!(CountryCode::from_str("Mexico").unwrap(), CountryCode::Mex); + assert_eq!( + CountryCode::from_str("Micronesia").unwrap(), + CountryCode::Fsm + ); + assert_eq!(CountryCode::from_str("Moldova").unwrap(), CountryCode::Mda); + assert_eq!(CountryCode::from_str("Monaco").unwrap(), CountryCode::Mco); + assert_eq!(CountryCode::from_str("Mongolia").unwrap(), CountryCode::Mng); + assert_eq!( + CountryCode::from_str("Montenegro").unwrap(), + CountryCode::Mne + ); + assert_eq!( + CountryCode::from_str("Montserrat").unwrap(), + CountryCode::Msr + ); + assert_eq!(CountryCode::from_str("Morocco").unwrap(), CountryCode::Mar); + assert_eq!( + CountryCode::from_str("Mozambique").unwrap(), + CountryCode::Moz + ); + assert_eq!(CountryCode::from_str("Myanmar").unwrap(), CountryCode::Mmr); + assert_eq!(CountryCode::from_str("Namibia").unwrap(), CountryCode::Nam); + assert_eq!(CountryCode::from_str("Nauru").unwrap(), CountryCode::Nru); + assert_eq!(CountryCode::from_str("Nepal").unwrap(), CountryCode::Npl); + assert_eq!( + CountryCode::from_str("Netherlands").unwrap(), + CountryCode::Nld + ); + assert_eq!( + CountryCode::from_str("New Caledonia").unwrap(), + CountryCode::Ncl + ); + assert_eq!( + CountryCode::from_str("New Zealand").unwrap(), + CountryCode::Nzl + ); + assert_eq!( + CountryCode::from_str("Nicaragua").unwrap(), + CountryCode::Nic + ); + assert_eq!(CountryCode::from_str("Niger").unwrap(), CountryCode::Ner); + assert_eq!(CountryCode::from_str("Nigeria").unwrap(), CountryCode::Nga); + assert_eq!(CountryCode::from_str("Niue").unwrap(), CountryCode::Niu); + assert_eq!( + CountryCode::from_str("Norfolk Island").unwrap(), + CountryCode::Nfk + ); + assert_eq!( + CountryCode::from_str("North Korea").unwrap(), + CountryCode::Prk + ); + assert_eq!( + CountryCode::from_str("North Macedonia").unwrap(), + CountryCode::Mkd + ); + assert_eq!( + CountryCode::from_str("Northern Mariana Islands").unwrap(), + CountryCode::Mnp + ); + assert_eq!(CountryCode::from_str("Norway").unwrap(), CountryCode::Nor); + assert_eq!(CountryCode::from_str("Oman").unwrap(), CountryCode::Omn); + assert_eq!(CountryCode::from_str("Pakistan").unwrap(), CountryCode::Pak); + assert_eq!(CountryCode::from_str("Palau").unwrap(), CountryCode::Plw); + assert_eq!( + CountryCode::from_str("Palestine").unwrap(), + CountryCode::Pse + ); + assert_eq!(CountryCode::from_str("Panama").unwrap(), CountryCode::Pan); + assert_eq!( + CountryCode::from_str("Papua New Guinea").unwrap(), + CountryCode::Png + ); + assert_eq!(CountryCode::from_str("Paraguay").unwrap(), CountryCode::Pry); + assert_eq!(CountryCode::from_str("Peru").unwrap(), CountryCode::Per); + assert_eq!( + CountryCode::from_str("Philippines").unwrap(), + CountryCode::Phl + ); + assert_eq!(CountryCode::from_str("Pitcairn").unwrap(), CountryCode::Pcn); + assert_eq!(CountryCode::from_str("Poland").unwrap(), CountryCode::Pol); + assert_eq!(CountryCode::from_str("Portugal").unwrap(), CountryCode::Prt); + assert_eq!( + CountryCode::from_str("Puerto Rico").unwrap(), + CountryCode::Pri + ); + assert_eq!(CountryCode::from_str("Qatar").unwrap(), CountryCode::Qat); + assert_eq!( + CountryCode::from_str("Republic of the Congo").unwrap(), + CountryCode::Cog + ); + assert_eq!(CountryCode::from_str("Réunion").unwrap(), CountryCode::Reu); + assert_eq!(CountryCode::from_str("Romania").unwrap(), CountryCode::Rou); + assert_eq!(CountryCode::from_str("Russia").unwrap(), CountryCode::Rus); + assert_eq!(CountryCode::from_str("Rwanda").unwrap(), CountryCode::Rwa); + assert_eq!( + CountryCode::from_str("Saint Barthélemy").unwrap(), + CountryCode::Blm + ); + assert_eq!( + CountryCode::from_str("Saint Helena, Ascension and Tristan da Cunha").unwrap(), + CountryCode::Shn + ); + assert_eq!( + CountryCode::from_str("Saint Kitts and Nevis").unwrap(), + CountryCode::Kna + ); + assert_eq!( + CountryCode::from_str("Saint Lucia").unwrap(), + CountryCode::Lca + ); + assert_eq!( + CountryCode::from_str("Saint Martin").unwrap(), + CountryCode::Maf + ); + assert_eq!( + CountryCode::from_str("Saint Pierre and Miquelon").unwrap(), + CountryCode::Spm + ); + assert_eq!( + CountryCode::from_str("Saint Vincent and the Grenadines").unwrap(), + CountryCode::Vct + ); + assert_eq!(CountryCode::from_str("Samoa").unwrap(), CountryCode::Wsm); + assert_eq!( + CountryCode::from_str("San Marino").unwrap(), + CountryCode::Smr + ); + assert_eq!( + CountryCode::from_str("Sao Tome and Principe").unwrap(), + CountryCode::Stp + ); + assert_eq!( + CountryCode::from_str("Saudi Arabia").unwrap(), + CountryCode::Sau + ); + assert_eq!(CountryCode::from_str("Senegal").unwrap(), CountryCode::Sen); + assert_eq!(CountryCode::from_str("Serbia").unwrap(), CountryCode::Srb); + assert_eq!( + CountryCode::from_str("Seychelles").unwrap(), + CountryCode::Syc + ); + assert_eq!( + CountryCode::from_str("Sierra Leone").unwrap(), + CountryCode::Sle + ); + assert_eq!( + CountryCode::from_str("Singapore").unwrap(), + CountryCode::Sgp + ); + assert_eq!( + CountryCode::from_str("Sint Maarten").unwrap(), + CountryCode::Sxm + ); + assert_eq!(CountryCode::from_str("Slovakia").unwrap(), CountryCode::Svk); + assert_eq!(CountryCode::from_str("Slovenia").unwrap(), CountryCode::Svn); + assert_eq!( + CountryCode::from_str("Solomon Islands").unwrap(), + CountryCode::Slb + ); + assert_eq!(CountryCode::from_str("Somalia").unwrap(), CountryCode::Som); + assert_eq!( + CountryCode::from_str("South Africa").unwrap(), + CountryCode::Zaf + ); + assert_eq!( + CountryCode::from_str("South Georgia and the South Sandwich Islands").unwrap(), + CountryCode::Sgs + ); + assert_eq!( + CountryCode::from_str("South Korea").unwrap(), + CountryCode::Kor + ); + assert_eq!( + CountryCode::from_str("South Sudan").unwrap(), + CountryCode::Ssd + ); + assert_eq!(CountryCode::from_str("Spain").unwrap(), CountryCode::Esp); + assert_eq!( + CountryCode::from_str("Sri Lanka").unwrap(), + CountryCode::Lka + ); + assert_eq!(CountryCode::from_str("Sudan").unwrap(), CountryCode::Sdn); + assert_eq!(CountryCode::from_str("Suriname").unwrap(), CountryCode::Sur); + assert_eq!( + CountryCode::from_str("Svalbard and Jan Mayen").unwrap(), + CountryCode::Sjm + ); + assert_eq!(CountryCode::from_str("Sweden").unwrap(), CountryCode::Swe); + assert_eq!( + CountryCode::from_str("Switzerland").unwrap(), + CountryCode::Che + ); + assert_eq!(CountryCode::from_str("Syria").unwrap(), CountryCode::Syr); + assert_eq!(CountryCode::from_str("Taiwan").unwrap(), CountryCode::Twn); + assert_eq!( + CountryCode::from_str("Tajikistan").unwrap(), + CountryCode::Tjk + ); + assert_eq!(CountryCode::from_str("Tanzania").unwrap(), CountryCode::Tza); + assert_eq!(CountryCode::from_str("Thailand").unwrap(), CountryCode::Tha); + assert_eq!( + CountryCode::from_str("Timor-Leste").unwrap(), + CountryCode::Tls + ); + assert_eq!(CountryCode::from_str("Togo").unwrap(), CountryCode::Tgo); + assert_eq!(CountryCode::from_str("Tokelau").unwrap(), CountryCode::Tkl); + assert_eq!(CountryCode::from_str("Tonga").unwrap(), CountryCode::Ton); + assert_eq!( + CountryCode::from_str("Trinidad and Tobago").unwrap(), + CountryCode::Tto + ); + assert_eq!(CountryCode::from_str("Tunisia").unwrap(), CountryCode::Tun); + assert_eq!(CountryCode::from_str("Turkey").unwrap(), CountryCode::Tur); + assert_eq!( + CountryCode::from_str("Turkmenistan").unwrap(), + CountryCode::Tkm + ); + assert_eq!( + CountryCode::from_str("Turks and Caicos Islands").unwrap(), + CountryCode::Tca + ); + assert_eq!(CountryCode::from_str("Tuvalu").unwrap(), CountryCode::Tuv); + assert_eq!(CountryCode::from_str("Uganda").unwrap(), CountryCode::Uga); + assert_eq!(CountryCode::from_str("Ukraine").unwrap(), CountryCode::Ukr); + assert_eq!( + CountryCode::from_str("United Arab Emirates").unwrap(), + CountryCode::Are + ); + assert_eq!( + CountryCode::from_str("United Kingdom").unwrap(), + CountryCode::Gbr + ); + assert_eq!( + CountryCode::from_str("United States Minor Outlying Islands").unwrap(), + CountryCode::Umi + ); + assert_eq!( + CountryCode::from_str("United States of America").unwrap(), + CountryCode::Usa + ); + assert_eq!(CountryCode::from_str("Uruguay").unwrap(), CountryCode::Ury); + assert_eq!( + CountryCode::from_str("Uzbekistan").unwrap(), + CountryCode::Uzb + ); + assert_eq!(CountryCode::from_str("Vanuatu").unwrap(), CountryCode::Vut); + assert_eq!( + CountryCode::from_str("Vatican City").unwrap(), + CountryCode::Vat + ); + assert_eq!( + CountryCode::from_str("Venezuela").unwrap(), + CountryCode::Ven + ); + assert_eq!(CountryCode::from_str("Viet Nam").unwrap(), CountryCode::Vnm); + assert_eq!( + CountryCode::from_str("Virgin Islands (British)").unwrap(), + CountryCode::Vgb + ); + assert_eq!( + CountryCode::from_str("Virgin Islands (U.S.)").unwrap(), + CountryCode::Vir + ); + assert_eq!( + CountryCode::from_str("Wallis and Futuna").unwrap(), + CountryCode::Wlf + ); + assert_eq!( + CountryCode::from_str("Western Sahara").unwrap(), + CountryCode::Esh + ); + assert_eq!(CountryCode::from_str("Yemen").unwrap(), CountryCode::Yem); + assert_eq!(CountryCode::from_str("Zambia").unwrap(), CountryCode::Zmb); + assert_eq!(CountryCode::from_str("Zimbabwe").unwrap(), CountryCode::Zwe); + assert!(CountryCode::from_str("Narnia").is_err()); + assert!(CountryCode::from_str("Mesopotamia").is_err()); + assert!(CountryCode::from_str("Czechoslovakia").is_err()); + } + + #[test] + fn institution_display_formats_with_optional_ids() { + let with_ror = Institution { + institution_name: "Test Institution".to_string(), + ror: Some(Ror("https://ror.org/0abcdef12".to_string())), + ..Default::default() + }; + assert_eq!(format!("{with_ror}"), "Test Institution - 0abcdef12"); + + let with_doi = Institution { + institution_name: "Test Institution".to_string(), + institution_doi: Some(Doi("https://doi.org/10.1234/abcd".to_string())), + ..Default::default() + }; + assert_eq!(format!("{with_doi}"), "Test Institution - 10.1234/abcd"); + + let no_ids = Institution { + institution_name: "Test Institution".to_string(), + ..Default::default() + }; + assert_eq!(format!("{no_ids}"), "Test Institution"); + } +} + +#[cfg(feature = "backend")] +mod conversions { + use super::*; + use crate::model::tests::db::setup_test_db; + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn countrycode_graphql_roundtrip() { + assert_graphql_enum_roundtrip(CountryCode::Gbr); + } + + #[test] + fn countrycode_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<CountryCode, crate::schema::sql_types::CountryCode>( + pool.as_ref(), + "'gbr'::country_code", + CountryCode::Gbr, + ); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let institution: Institution = Default::default(); + assert_eq!(institution.pk(), institution.institution_id); + } + + #[test] + fn history_entry_serializes_model() { + let institution: Institution = Default::default(); + let user_id = "123456".to_string(); + let new_institution_history = institution.new_history_entry(&user_id); + assert_eq!( + new_institution_history.institution_id, + institution.institution_id + ); + assert_eq!(new_institution_history.user_id, user_id); + assert_eq!( + new_institution_history.data, + serde_json::Value::String(serde_json::to_string(&institution).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::funding::{Funding, NewFunding}; + use crate::model::institution::policy::InstitutionPolicy; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context, + test_context_anonymous, test_context_with_user, test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_requires_authentication_for_create_update() { + let (_guard, pool) = setup_test_db(); + + let ctx = test_context_anonymous(pool.clone()); + + let new_institution = NewInstitution { + institution_name: "Institution Policy".to_string(), + institution_doi: None, + ror: None, + country_code: Some(CountryCode::Gbr), + }; + + let institution = Institution::create(pool.as_ref(), &new_institution) + .expect("Failed to create institution"); + let patch = PatchInstitution { + institution_id: institution.institution_id, + institution_name: "Updated Institution".to_string(), + institution_doi: institution.institution_doi.clone(), + ror: institution.ror.clone(), + country_code: institution.country_code, + }; + + assert!(InstitutionPolicy::can_create(&ctx, &new_institution, ()).is_err()); + assert!(InstitutionPolicy::can_update(&ctx, &institution, &patch, ()).is_err()); + } + + #[test] + fn crud_policy_allows_authenticated_user_for_create_update() { + let (_guard, pool) = setup_test_db(); + + let ctx = test_context(pool.clone(), "institution-user"); + + let new_institution = NewInstitution { + institution_name: "Institution Policy".to_string(), + institution_doi: None, + ror: None, + country_code: Some(CountryCode::Gbr), + }; + + let institution = Institution::create(pool.as_ref(), &new_institution) + .expect("Failed to create institution"); + let patch = PatchInstitution { + institution_id: institution.institution_id, + institution_name: "Updated Institution".to_string(), + institution_doi: institution.institution_doi.clone(), + ror: institution.ror.clone(), + country_code: institution.country_code, + }; + + assert!(InstitutionPolicy::can_create(&ctx, &new_institution, ()).is_ok()); + assert!(InstitutionPolicy::can_update(&ctx, &institution, &patch, ()).is_ok()); + } + + #[test] + fn crud_policy_delete_requires_publisher_membership() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let institution = + make_institution(pool.as_ref(), format!("Institution {}", Uuid::new_v4())); + + let new_funding = NewFunding { + work_id: work.work_id, + institution_id: institution.institution_id, + program: None, + project_name: None, + project_shortname: None, + grant_number: None, + }; + Funding::create(pool.as_ref(), &new_funding).expect("Failed to create funding"); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("institution-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + assert!(InstitutionPolicy::can_delete(&ctx, &institution).is_ok()); + + let other_user = test_user_with_role("institution-user", Role::PublisherUser, "org-other"); + let other_ctx = test_context_with_user(pool.clone(), other_user); + assert!(InstitutionPolicy::can_delete(&other_ctx, &institution).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::model::tests::db::{setup_test_db, test_context}; + use crate::model::Crud; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let new_institution = NewInstitution { + institution_name: format!("Institution {}", Uuid::new_v4()), + institution_doi: None, + ror: None, + country_code: Some(CountryCode::Gbr), + }; + + let institution = Institution::create(pool.as_ref(), &new_institution) + .expect("Failed to create institution"); + let fetched = Institution::from_id(pool.as_ref(), &institution.institution_id) + .expect("Failed to fetch"); + assert_eq!(institution.institution_id, fetched.institution_id); + + let patch = PatchInstitution { + institution_id: institution.institution_id, + institution_name: "Updated Institution".to_string(), + institution_doi: institution.institution_doi.clone(), + ror: institution.ror.clone(), + country_code: institution.country_code, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = institution.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.institution_name, patch.institution_name); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Institution::from_id(pool.as_ref(), &deleted.institution_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + make_institution(pool.as_ref(), format!("Institution {}", Uuid::new_v4())); + make_institution(pool.as_ref(), format!("Institution {}", Uuid::new_v4())); + + let order = InstitutionOrderBy { + field: InstitutionField::InstitutionId, + direction: Direction::Asc, + }; + + let first = Institution::all( + pool.as_ref(), + 1, + 0, + None, + order.clone(), + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch institutions"); + let second = Institution::all( + pool.as_ref(), + 1, + 1, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch institutions"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].institution_id, second[0].institution_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + make_institution(pool.as_ref(), format!("Institution {}", Uuid::new_v4())); + make_institution(pool.as_ref(), format!("Institution {}", Uuid::new_v4())); + + let count = Institution::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count institutions"); + assert_eq!(count, 2); + } + + #[test] + fn crud_filter_matches_institution_name() { + let (_guard, pool) = setup_test_db(); + + let marker = format!("Filter {}", Uuid::new_v4()); + let matches = make_institution(pool.as_ref(), format!("Institution {marker}")); + make_institution(pool.as_ref(), "Other Institution".to_string()); + + let filtered = Institution::all( + pool.as_ref(), + 10, + 0, + Some(marker), + InstitutionOrderBy { + field: InstitutionField::InstitutionId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter institutions"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].institution_id, matches.institution_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let first = make_institution(pool.as_ref(), format!("Institution {}", Uuid::new_v4())); + let second = make_institution(pool.as_ref(), format!("Institution {}", Uuid::new_v4())); + let mut ids = [first.institution_id, second.institution_id]; + ids.sort(); + + let asc = Institution::all( + pool.as_ref(), + 2, + 0, + None, + InstitutionOrderBy { + field: InstitutionField::InstitutionId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order institutions (asc)"); + + let desc = Institution::all( + pool.as_ref(), + 2, + 0, + None, + InstitutionOrderBy { + field: InstitutionField::InstitutionId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order institutions (desc)"); + + assert_eq!(asc[0].institution_id, ids[0]); + assert_eq!(desc[0].institution_id, ids[1]); + } + + #[test] + fn crud_count_with_filter_matches_ror() { + let (_guard, pool) = setup_test_db(); + + let marker = "0abcd1234"; + Institution::create( + pool.as_ref(), + &NewInstitution { + institution_name: "Ror Match".to_string(), + institution_doi: None, + ror: Some(Ror(format!("https://ror.org/{marker}"))), + country_code: Some(CountryCode::Gbr), + }, + ) + .expect("Failed to create institution"); + Institution::create( + pool.as_ref(), + &NewInstitution { + institution_name: "Other".to_string(), + institution_doi: None, + ror: None, + country_code: Some(CountryCode::Gbr), + }, + ) + .expect("Failed to create institution"); + + let count = Institution::count( + pool.as_ref(), + Some(marker.to_string()), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count filtered institutions"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + Institution::create( + pool.as_ref(), + &NewInstitution { + institution_name: "Institution A".to_string(), + institution_doi: Some(Doi("https://doi.org/10.1234/A".to_string())), + ror: Some(Ror("https://ror.org/0aaaa0000".to_string())), + country_code: Some(CountryCode::Gbr), + }, + ) + .expect("Failed to create institution"); + Institution::create( + pool.as_ref(), + &NewInstitution { + institution_name: "Institution B".to_string(), + institution_doi: Some(Doi("https://doi.org/10.1234/B".to_string())), + ror: Some(Ror("https://ror.org/0bbbb0000".to_string())), + country_code: Some(CountryCode::Fra), + }, + ) + .expect("Failed to create institution"); + + let fields: Vec<fn() -> InstitutionField> = vec![ + || InstitutionField::InstitutionId, + || InstitutionField::InstitutionName, + || InstitutionField::InstitutionDoi, + || InstitutionField::Ror, + || InstitutionField::CountryCode, + || InstitutionField::CreatedAt, + || InstitutionField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Institution::all( + pool.as_ref(), + 10, + 0, + None, + InstitutionOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order institutions"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/issue/crud.rs b/thoth-api/src/model/issue/crud.rs index e502c1b7..d1fbcc7e 100644 --- a/thoth-api/src/model/issue/crud.rs +++ b/thoth-api/src/model/issue/crud.rs @@ -1,11 +1,9 @@ use super::{Issue, IssueField, IssueHistory, NewIssue, NewIssueHistory, PatchIssue}; -use crate::graphql::model::IssueOrderBy; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::graphql::types::inputs::IssueOrderBy; +use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; use crate::schema::{issue, issue_history}; -use crate::{crud_methods, db_insert}; -use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; -use thoth_errors::{ThothError, ThothResult}; +use diesel::{BoolExpressionMethods, Connection, ExpressionMethods, QueryDsl, RunQueryDsl}; +use thoth_errors::ThothResult; use uuid::Uuid; impl Crud for Issue { @@ -15,6 +13,7 @@ impl Crud for Issue { type FilterParameter1 = (); type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.issue_id @@ -32,6 +31,7 @@ impl Crud for Issue { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Issue>> { use crate::schema::issue::dsl::*; let mut connection = db.get()?; @@ -41,30 +41,25 @@ impl Crud for Issue { .into_boxed(); query = match order.field { - IssueField::IssueId => match order.direction { - Direction::Asc => query.order(issue_id.asc()), - Direction::Desc => query.order(issue_id.desc()), - }, - IssueField::SeriesId => match order.direction { - Direction::Asc => query.order(series_id.asc()), - Direction::Desc => query.order(series_id.desc()), - }, - IssueField::WorkId => match order.direction { - Direction::Asc => query.order(work_id.asc()), - Direction::Desc => query.order(work_id.desc()), - }, - IssueField::IssueOrdinal => match order.direction { - Direction::Asc => query.order(issue_ordinal.asc()), - Direction::Desc => query.order(issue_ordinal.desc()), - }, - IssueField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - IssueField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + IssueField::IssueId => { + apply_directional_order!(query, order.direction, order, issue_id) + } + IssueField::SeriesId => { + apply_directional_order!(query, order.direction, order, series_id) + } + IssueField::WorkId => apply_directional_order!(query, order.direction, order, work_id), + IssueField::IssueOrdinal => { + apply_directional_order!(query, order.direction, order, issue_ordinal) + } + IssueField::IssueNumber => { + apply_directional_order!(query, order.direction, order, issue_number) + } + IssueField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + IssueField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if !publishers.is_empty() { query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); @@ -89,6 +84,7 @@ impl Crud for Issue { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::issue::dsl::*; let mut connection = db.get()?; @@ -104,20 +100,20 @@ impl Crud for Issue { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } - crud_methods!(issue::table, issue::dsl::issue); } +publisher_id_impls!(Issue, NewIssue, PatchIssue, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Issue { type NewHistoryEntity = NewIssueHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { issue_id: self.issue_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -129,59 +125,25 @@ impl DbInsert for NewIssueHistory { db_insert!(issue_history::table); } -impl NewIssue { - pub fn imprints_match(&self, db: &crate::db::PgPool) -> ThothResult<()> { - issue_imprints_match(self.work_id, self.series_id, db) - } -} - -impl PatchIssue { - pub fn imprints_match(&self, db: &crate::db::PgPool) -> ThothResult<()> { - issue_imprints_match(self.work_id, self.series_id, db) - } -} - -fn issue_imprints_match(work_id: Uuid, series_id: Uuid, db: &crate::db::PgPool) -> ThothResult<()> { - use diesel::prelude::*; - - let mut connection = db.get()?; - let series_imprint = crate::schema::series::table - .select(crate::schema::series::imprint_id) - .filter(crate::schema::series::series_id.eq(series_id)) - .first::<Uuid>(&mut connection) - .expect("Error loading series for issue"); - let work_imprint = crate::schema::work::table - .select(crate::schema::work::imprint_id) - .filter(crate::schema::work::work_id.eq(work_id)) - .first::<Uuid>(&mut connection) - .expect("Error loading work for issue"); - if work_imprint == series_imprint { - Ok(()) - } else { - Err(ThothError::IssueImprintsError) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_issue_pk() { - let issue: Issue = Default::default(); - assert_eq!(issue.pk(), issue.issue_id); - } - - #[test] - fn test_new_issue_history_from_issue() { - let issue: Issue = Default::default(); - let account_id: Uuid = Default::default(); - let new_issue_history = issue.new_history_entry(&account_id); - assert_eq!(new_issue_history.issue_id, issue.issue_id); - assert_eq!(new_issue_history.account_id, account_id); - assert_eq!( - new_issue_history.data, - serde_json::Value::String(serde_json::to_string(&issue).unwrap()) - ); +impl Reorder for Issue { + db_change_ordinal!( + issue::table, + issue::issue_ordinal, + "issue_issue_ordinal_series_id_uniq" + ); + + fn get_other_objects( + &self, + connection: &mut diesel::PgConnection, + ) -> ThothResult<Vec<(Uuid, i32)>> { + issue::table + .select((issue::issue_id, issue::issue_ordinal)) + .filter( + issue::series_id + .eq(self.series_id) + .and(issue::issue_id.ne(self.issue_id)), + ) + .load::<(Uuid, i32)>(connection) + .map_err(Into::into) } } diff --git a/thoth-api/src/model/issue/mod.rs b/thoth-api/src/model/issue/mod.rs index 4d933380..1cf428f9 100644 --- a/thoth-api/src/model/issue/mod.rs +++ b/thoth-api/src/model/issue/mod.rs @@ -1,7 +1,6 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::model::series::SeriesWithImprint; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::issue; @@ -18,11 +17,12 @@ pub enum IssueField { SeriesId, WorkId, IssueOrdinal, + IssueNumber, CreatedAt, UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Issue { @@ -30,23 +30,14 @@ pub struct Issue { pub series_id: Uuid, pub work_id: Uuid, pub issue_ordinal: i32, + pub issue_number: Option<i32>, pub created_at: Timestamp, pub updated_at: Timestamp, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct IssueWithSeries { - pub issue_id: Uuid, - pub work_id: Uuid, - pub series_id: Uuid, - pub issue_ordinal: i32, - pub series: SeriesWithImprint, -} - #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new work published as a number in a periodical"), diesel(table_name = issue) )] @@ -54,11 +45,12 @@ pub struct NewIssue { pub series_id: Uuid, pub work_id: Uuid, pub issue_ordinal: i32, + pub issue_number: Option<i32>, } #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing work published as a number in a periodical"), diesel(table_name = issue, treat_none_as_null = true) )] @@ -67,35 +59,30 @@ pub struct PatchIssue { pub series_id: Uuid, pub work_id: Uuid, pub issue_ordinal: i32, + pub issue_number: Option<i32>, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct IssueHistory { pub issue_history_id: Uuid, pub issue_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } -#[cfg_attr(feature = "backend", derive(Insertable), diesel(table_name = issue_history))] +#[cfg_attr(feature = "backend", derive(diesel::Insertable), diesel(table_name = issue_history))] pub struct NewIssueHistory { pub issue_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } -impl Default for IssueWithSeries { - fn default() -> IssueWithSeries { - IssueWithSeries { - issue_id: Default::default(), - work_id: Default::default(), - series_id: Default::default(), - issue_ordinal: 1, - series: Default::default(), - } - } -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::IssuePolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/issue/policy.rs b/thoth-api/src/model/issue/policy.rs new file mode 100644 index 00000000..a3c5a753 --- /dev/null +++ b/thoth-api/src/model/issue/policy.rs @@ -0,0 +1,67 @@ +use diesel::dsl::{exists, select}; +use diesel::prelude::*; +use uuid::Uuid; + +use crate::model::issue::{Issue, NewIssue, PatchIssue}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Issue`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct IssuePolicy; + +/// Ensure the work's imprint matches the series imprint for an issue. +fn issue_imprints_match(db: &crate::db::PgPool, work_id: Uuid, series_id: Uuid) -> ThothResult<()> { + use crate::schema::{series, work}; + + let mut conn = db.get()?; + + let query = series::table + .inner_join(work::table.on(work::imprint_id.eq(series::imprint_id))) + .filter(series::series_id.eq(series_id)) + .filter(work::work_id.eq(work_id)); + + match select(exists(query)).get_result(&mut conn)? { + true => Ok(()), + false => Err(ThothError::IssueImprintsError), + } +} + +impl CreatePolicy<NewIssue> for IssuePolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewIssue, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + + issue_imprints_match(ctx.db(), data.work_id, data.series_id) + } +} + +impl UpdatePolicy<Issue, PatchIssue> for IssuePolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Issue, + patch: &PatchIssue, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + issue_imprints_match(ctx.db(), patch.work_id, patch.series_id) + } +} + +impl DeletePolicy<Issue> for IssuePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Issue) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} + +impl MovePolicy<Issue> for IssuePolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &Issue) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/issue/tests.rs b/thoth-api/src/model/issue/tests.rs new file mode 100644 index 00000000..988bc4ce --- /dev/null +++ b/thoth-api/src/model/issue/tests.rs @@ -0,0 +1,533 @@ +use super::*; + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let issue: Issue = Default::default(); + assert_eq!(issue.pk(), issue.issue_id); + } + + #[test] + fn history_entry_serializes_model() { + let issue: Issue = Default::default(); + let user_id = "123456".to_string(); + let new_issue_history = issue.new_history_entry(&user_id); + assert_eq!(new_issue_history.issue_id, issue.issue_id); + assert_eq!(new_issue_history.user_id, user_id); + assert_eq!( + new_issue_history.data, + serde_json::Value::String(serde_json::to_string(&issue).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::issue::policy::IssuePolicy; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_series, create_work, setup_test_db, + test_context_with_user, test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("issue-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let series = create_series(pool.as_ref(), &imprint); + let work = create_work(pool.as_ref(), &imprint); + let new_issue = NewIssue { + series_id: series.series_id, + work_id: work.work_id, + issue_ordinal: 1, + issue_number: Some(1), + }; + + let issue = Issue::create(pool.as_ref(), &new_issue).expect("Failed to create"); + let patch = PatchIssue { + issue_id: issue.issue_id, + series_id: issue.series_id, + work_id: issue.work_id, + issue_ordinal: 2, + issue_number: Some(2), + }; + + assert!(IssuePolicy::can_create(&ctx, &new_issue, ()).is_ok()); + assert!(IssuePolicy::can_update(&ctx, &issue, &patch, ()).is_ok()); + assert!(IssuePolicy::can_delete(&ctx, &issue).is_ok()); + assert!(IssuePolicy::can_move(&ctx, &issue).is_ok()); + } + + #[test] + fn crud_policy_rejects_mismatched_imprints() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let work = create_work(pool.as_ref(), &imprint); + let series = create_series(pool.as_ref(), &other_imprint); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("issue-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let new_issue = NewIssue { + series_id: series.series_id, + work_id: work.work_id, + issue_ordinal: 1, + issue_number: Some(1), + }; + + assert!(IssuePolicy::can_create(&ctx, &new_issue, ()).is_err()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let series = create_series(pool.as_ref(), &imprint); + let work = create_work(pool.as_ref(), &imprint); + + let new_issue = NewIssue { + series_id: series.series_id, + work_id: work.work_id, + issue_ordinal: 1, + issue_number: Some(1), + }; + + let issue = Issue::create(pool.as_ref(), &new_issue).expect("Failed to create"); + let patch = PatchIssue { + issue_id: issue.issue_id, + series_id: issue.series_id, + work_id: issue.work_id, + issue_ordinal: 2, + issue_number: Some(2), + }; + + let user = test_user_with_role("issue-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + assert!(IssuePolicy::can_create(&ctx, &new_issue, ()).is_err()); + assert!(IssuePolicy::can_update(&ctx, &issue, &patch, ()).is_err()); + assert!(IssuePolicy::can_delete(&ctx, &issue).is_err()); + assert!(IssuePolicy::can_move(&ctx, &issue).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + use uuid::Uuid; + + use crate::graphql::types::inputs::{Direction, IssueOrderBy}; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_series, create_work, setup_test_db, test_context, + }; + use crate::model::{Crud, Reorder}; + + fn make_issue( + pool: &crate::db::PgPool, + series_id: Uuid, + work_id: Uuid, + issue_ordinal: i32, + issue_number: Option<i32>, + ) -> Issue { + let new_issue = NewIssue { + series_id, + work_id, + issue_ordinal, + issue_number, + }; + + Issue::create(pool, &new_issue).expect("Failed to create issue") + } + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let series = create_series(pool.as_ref(), &imprint); + let work = create_work(pool.as_ref(), &imprint); + + let new_issue = NewIssue { + series_id: series.series_id, + work_id: work.work_id, + issue_ordinal: 1, + issue_number: Some(1), + }; + + let issue = Issue::create(pool.as_ref(), &new_issue).expect("Failed to create"); + let fetched = Issue::from_id(pool.as_ref(), &issue.issue_id).expect("Failed to fetch"); + assert_eq!(issue.issue_id, fetched.issue_id); + + let patch = PatchIssue { + issue_id: issue.issue_id, + series_id: issue.series_id, + work_id: issue.work_id, + issue_ordinal: 2, + issue_number: Some(2), + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = issue.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.issue_ordinal, patch.issue_ordinal); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Issue::from_id(pool.as_ref(), &deleted.issue_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let series = create_series(pool.as_ref(), &imprint); + let other_series = create_series(pool.as_ref(), &imprint); + let work = create_work(pool.as_ref(), &imprint); + + make_issue(pool.as_ref(), series.series_id, work.work_id, 1, None); + make_issue(pool.as_ref(), other_series.series_id, work.work_id, 1, None); + + let order = IssueOrderBy { + field: IssueField::IssueId, + direction: Direction::Asc, + }; + + let first = Issue::all( + pool.as_ref(), + 1, + 0, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch issues"); + let second = Issue::all( + pool.as_ref(), + 1, + 1, + None, + IssueOrderBy { + field: IssueField::IssueId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch issues"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].issue_id, second[0].issue_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let series = create_series(pool.as_ref(), &imprint); + let other_series = create_series(pool.as_ref(), &imprint); + let work = create_work(pool.as_ref(), &imprint); + + make_issue(pool.as_ref(), series.series_id, work.work_id, 1, None); + make_issue(pool.as_ref(), other_series.series_id, work.work_id, 1, None); + + let count = Issue::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count issues"); + assert_eq!(count, 2); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let series = create_series(pool.as_ref(), &imprint); + let other_series = create_series(pool.as_ref(), &imprint); + let work = create_work(pool.as_ref(), &imprint); + + let first = make_issue(pool.as_ref(), series.series_id, work.work_id, 1, None); + let second = make_issue(pool.as_ref(), other_series.series_id, work.work_id, 1, None); + let mut ids = [first.issue_id, second.issue_id]; + ids.sort(); + + let asc = Issue::all( + pool.as_ref(), + 2, + 0, + None, + IssueOrderBy { + field: IssueField::IssueId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order issues (asc)"); + + let desc = Issue::all( + pool.as_ref(), + 2, + 0, + None, + IssueOrderBy { + field: IssueField::IssueId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order issues (desc)"); + + assert_eq!(asc[0].issue_id, ids[0]); + assert_eq!(desc[0].issue_id, ids[1]); + } + + #[test] + fn crud_filter_parent_work_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let series = create_series(pool.as_ref(), &imprint); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + + let matches = make_issue(pool.as_ref(), series.series_id, work.work_id, 1, None); + make_issue(pool.as_ref(), series.series_id, other_work.work_id, 2, None); + + let filtered = Issue::all( + pool.as_ref(), + 10, + 0, + None, + IssueOrderBy { + field: IssueField::IssueId, + direction: Direction::Asc, + }, + vec![], + Some(work.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter issues by work"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].issue_id, matches.issue_id); + } + + #[test] + fn crud_filter_parent_series_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let series = create_series(pool.as_ref(), &imprint); + let other_series = create_series(pool.as_ref(), &imprint); + let work = create_work(pool.as_ref(), &imprint); + + let matches = make_issue(pool.as_ref(), series.series_id, work.work_id, 1, None); + make_issue(pool.as_ref(), other_series.series_id, work.work_id, 2, None); + + let filtered = Issue::all( + pool.as_ref(), + 10, + 0, + None, + IssueOrderBy { + field: IssueField::IssueId, + direction: Direction::Asc, + }, + vec![], + None, + Some(series.series_id), + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter issues by series"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].issue_id, matches.issue_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let series = create_series(pool.as_ref(), &imprint); + let work = create_work(pool.as_ref(), &imprint); + let matches = make_issue(pool.as_ref(), series.series_id, work.work_id, 1, None); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let other_series = create_series(pool.as_ref(), &other_imprint); + let other_work = create_work(pool.as_ref(), &other_imprint); + make_issue( + pool.as_ref(), + other_series.series_id, + other_work.work_id, + 1, + None, + ); + + let filtered = Issue::all( + pool.as_ref(), + 10, + 0, + None, + IssueOrderBy { + field: IssueField::IssueId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter issues by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].issue_id, matches.issue_id); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let series = create_series(pool.as_ref(), &imprint); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + + make_issue(pool.as_ref(), series.series_id, work.work_id, 1, Some(1)); + make_issue( + pool.as_ref(), + series.series_id, + other_work.work_id, + 2, + Some(2), + ); + + let fields: Vec<fn() -> IssueField> = vec![ + || IssueField::IssueId, + || IssueField::SeriesId, + || IssueField::WorkId, + || IssueField::IssueOrdinal, + || IssueField::IssueNumber, + || IssueField::CreatedAt, + || IssueField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Issue::all( + pool.as_ref(), + 10, + 0, + None, + IssueOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order issues"); + + assert_eq!(results.len(), 2); + } + } + } + + #[test] + fn crud_change_ordinal_reorders_issues() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let series = create_series(pool.as_ref(), &imprint); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + + let first = make_issue(pool.as_ref(), series.series_id, work.work_id, 1, None); + let second = make_issue(pool.as_ref(), series.series_id, other_work.work_id, 2, None); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = first + .change_ordinal(&ctx, first.issue_ordinal, 2) + .expect("Failed to change issue ordinal"); + + let refreshed_first = + Issue::from_id(pool.as_ref(), &updated.issue_id).expect("Failed to fetch"); + let refreshed_second = + Issue::from_id(pool.as_ref(), &second.issue_id).expect("Failed to fetch"); + + assert_eq!(refreshed_first.issue_ordinal, 2); + assert_eq!(refreshed_second.issue_ordinal, 1); + } +} diff --git a/thoth-api/src/model/language/crud.rs b/thoth-api/src/model/language/crud.rs index 66f7a7ed..7486ac89 100644 --- a/thoth-api/src/model/language/crud.rs +++ b/thoth-api/src/model/language/crud.rs @@ -2,11 +2,9 @@ use super::{ Language, LanguageCode, LanguageField, LanguageHistory, LanguageRelation, NewLanguage, NewLanguageHistory, PatchLanguage, }; -use crate::graphql::model::LanguageOrderBy; -use crate::graphql::utils::Direction; +use crate::graphql::types::inputs::LanguageOrderBy; use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{language, language_history}; -use crate::{crud_methods, db_insert}; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; use thoth_errors::ThothResult; use uuid::Uuid; @@ -18,6 +16,7 @@ impl Crud for Language { type FilterParameter1 = LanguageCode; type FilterParameter2 = LanguageRelation; type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.language_id @@ -35,6 +34,7 @@ impl Crud for Language { language_codes: Vec<Self::FilterParameter1>, language_relations: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Language>> { use crate::schema::language::dsl; let mut connection = db.get()?; @@ -44,34 +44,24 @@ impl Crud for Language { .into_boxed(); query = match order.field { - LanguageField::LanguageId => match order.direction { - Direction::Asc => query.order(dsl::language_id.asc()), - Direction::Desc => query.order(dsl::language_id.desc()), - }, - LanguageField::WorkId => match order.direction { - Direction::Asc => query.order(dsl::work_id.asc()), - Direction::Desc => query.order(dsl::work_id.desc()), - }, - LanguageField::LanguageCode => match order.direction { - Direction::Asc => query.order(dsl::language_code.asc()), - Direction::Desc => query.order(dsl::language_code.desc()), - }, - LanguageField::LanguageRelation => match order.direction { - Direction::Asc => query.order(dsl::language_relation.asc()), - Direction::Desc => query.order(dsl::language_relation.desc()), - }, - LanguageField::MainLanguage => match order.direction { - Direction::Asc => query.order(dsl::main_language.asc()), - Direction::Desc => query.order(dsl::main_language.desc()), - }, - LanguageField::CreatedAt => match order.direction { - Direction::Asc => query.order(dsl::created_at.asc()), - Direction::Desc => query.order(dsl::created_at.desc()), - }, - LanguageField::UpdatedAt => match order.direction { - Direction::Asc => query.order(dsl::updated_at.asc()), - Direction::Desc => query.order(dsl::updated_at.desc()), - }, + LanguageField::LanguageId => { + apply_directional_order!(query, order.direction, order, dsl::language_id) + } + LanguageField::WorkId => { + apply_directional_order!(query, order.direction, order, dsl::work_id) + } + LanguageField::LanguageCode => { + apply_directional_order!(query, order.direction, order, dsl::language_code) + } + LanguageField::LanguageRelation => { + apply_directional_order!(query, order.direction, order, dsl::language_relation) + } + LanguageField::CreatedAt => { + apply_directional_order!(query, order.direction, order, dsl::created_at) + } + LanguageField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, dsl::updated_at) + } }; if !publishers.is_empty() { query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); @@ -99,6 +89,7 @@ impl Crud for Language { language_codes: Vec<Self::FilterParameter1>, language_relations: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::language::dsl; let mut connection = db.get()?; @@ -120,20 +111,20 @@ impl Crud for Language { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } - crud_methods!(language::table, language::dsl::language); } +publisher_id_impls!(Language, NewLanguage, PatchLanguage, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Language { type NewHistoryEntity = NewLanguageHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { language_id: self.language_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -144,27 +135,3 @@ impl DbInsert for NewLanguageHistory { db_insert!(language_history::table); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_language_pk() { - let language: Language = Default::default(); - assert_eq!(language.pk(), language.language_id); - } - - #[test] - fn test_new_language_history_from_language() { - let language: Language = Default::default(); - let account_id: Uuid = Default::default(); - let new_language_history = language.new_history_entry(&account_id); - assert_eq!(new_language_history.language_id, language.language_id); - assert_eq!(new_language_history.account_id, account_id); - assert_eq!( - new_language_history.data, - serde_json::Value::String(serde_json::to_string(&language).unwrap()) - ); - } -} diff --git a/thoth-api/src/model/language/mod.rs b/thoth-api/src/model/language/mod.rs index f81259da..f4d59b66 100644 --- a/thoth-api/src/model/language/mod.rs +++ b/thoth-api/src/model/language/mod.rs @@ -11,7 +11,7 @@ use crate::schema::language_history; #[cfg_attr( feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), graphql( description = "Relation between a language listed for a work and the original language of the work's text" ), @@ -53,27 +53,25 @@ pub enum LanguageField { WorkId, LanguageCode, LanguageRelation, - MainLanguage, CreatedAt, UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Language { pub language_id: Uuid, pub work_id: Uuid, pub language_code: LanguageCode, pub language_relation: LanguageRelation, - pub main_language: bool, pub created_at: Timestamp, pub updated_at: Timestamp, } #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new description of a work's language"), diesel(table_name = language) )] @@ -81,12 +79,11 @@ pub struct NewLanguage { pub work_id: Uuid, pub language_code: LanguageCode, pub language_relation: LanguageRelation, - pub main_language: bool, } #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing description of a work's language"), diesel(table_name = language, treat_none_as_null = true) )] @@ -95,12 +92,11 @@ pub struct PatchLanguage { pub work_id: Uuid, pub language_code: LanguageCode, pub language_relation: LanguageRelation, - pub main_language: bool, } #[cfg_attr( feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), graphql(description = "Three-letter ISO 639 code representing a language"), ExistingTypePath = "crate::schema::sql_types::LanguageCode" )] @@ -1147,1069 +1143,31 @@ pub enum LanguageCode { Zza, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct LanguageHistory { pub language_history_id: Uuid, pub language_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = language_history) )] pub struct NewLanguageHistory { pub language_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } -impl Default for Language { - fn default() -> Language { - Language { - language_id: Default::default(), - work_id: Default::default(), - language_code: Default::default(), - language_relation: Default::default(), - main_language: true, - created_at: Default::default(), - updated_at: Default::default(), - } - } -} - -#[test] -fn test_languagecode_default() { - let langcode: LanguageCode = Default::default(); - assert_eq!(langcode, LanguageCode::Eng); -} - -#[test] -fn test_languagerelation_default() { - let langrelation: LanguageRelation = Default::default(); - assert_eq!(langrelation, LanguageRelation::Original); -} - -#[test] -fn test_languagerelation_display() { - assert_eq!(format!("{}", LanguageRelation::Original), "Original"); - assert_eq!( - format!("{}", LanguageRelation::TranslatedFrom), - "Translated From" - ); - assert_eq!( - format!("{}", LanguageRelation::TranslatedInto), - "Translated Into" - ); -} - -#[test] -fn test_languagecode_display() { - assert_eq!(format!("{}", LanguageCode::Aar), "AAR"); - assert_eq!(format!("{}", LanguageCode::Abk), "ABK"); - assert_eq!(format!("{}", LanguageCode::Ace), "ACE"); - assert_eq!(format!("{}", LanguageCode::Ach), "ACH"); - assert_eq!(format!("{}", LanguageCode::Ada), "ADA"); - assert_eq!(format!("{}", LanguageCode::Ady), "ADY"); - assert_eq!(format!("{}", LanguageCode::Afa), "AFA"); - assert_eq!(format!("{}", LanguageCode::Afh), "AFH"); - assert_eq!(format!("{}", LanguageCode::Afr), "AFR"); - assert_eq!(format!("{}", LanguageCode::Ain), "AIN"); - assert_eq!(format!("{}", LanguageCode::Aka), "AKA"); - assert_eq!(format!("{}", LanguageCode::Akk), "AKK"); - assert_eq!(format!("{}", LanguageCode::Alb), "ALB"); - assert_eq!(format!("{}", LanguageCode::Ale), "ALE"); - assert_eq!(format!("{}", LanguageCode::Alg), "ALG"); - assert_eq!(format!("{}", LanguageCode::Alt), "ALT"); - assert_eq!(format!("{}", LanguageCode::Amh), "AMH"); - assert_eq!(format!("{}", LanguageCode::Ang), "ANG"); - assert_eq!(format!("{}", LanguageCode::Anp), "ANP"); - assert_eq!(format!("{}", LanguageCode::Apa), "APA"); - assert_eq!(format!("{}", LanguageCode::Ara), "ARA"); - assert_eq!(format!("{}", LanguageCode::Arc), "ARC"); - assert_eq!(format!("{}", LanguageCode::Arg), "ARG"); - assert_eq!(format!("{}", LanguageCode::Arm), "ARM"); - assert_eq!(format!("{}", LanguageCode::Arn), "ARN"); - assert_eq!(format!("{}", LanguageCode::Arp), "ARP"); - assert_eq!(format!("{}", LanguageCode::Art), "ART"); - assert_eq!(format!("{}", LanguageCode::Arw), "ARW"); - assert_eq!(format!("{}", LanguageCode::Asm), "ASM"); - assert_eq!(format!("{}", LanguageCode::Ast), "AST"); - assert_eq!(format!("{}", LanguageCode::Ath), "ATH"); - assert_eq!(format!("{}", LanguageCode::Aus), "AUS"); - assert_eq!(format!("{}", LanguageCode::Ava), "AVA"); - assert_eq!(format!("{}", LanguageCode::Ave), "AVE"); - assert_eq!(format!("{}", LanguageCode::Awa), "AWA"); - assert_eq!(format!("{}", LanguageCode::Aym), "AYM"); - assert_eq!(format!("{}", LanguageCode::Aze), "AZE"); - assert_eq!(format!("{}", LanguageCode::Bad), "BAD"); - assert_eq!(format!("{}", LanguageCode::Bai), "BAI"); - assert_eq!(format!("{}", LanguageCode::Bak), "BAK"); - assert_eq!(format!("{}", LanguageCode::Bal), "BAL"); - assert_eq!(format!("{}", LanguageCode::Bam), "BAM"); - assert_eq!(format!("{}", LanguageCode::Ban), "BAN"); - assert_eq!(format!("{}", LanguageCode::Baq), "BAQ"); - assert_eq!(format!("{}", LanguageCode::Bas), "BAS"); - assert_eq!(format!("{}", LanguageCode::Bat), "BAT"); - assert_eq!(format!("{}", LanguageCode::Bej), "BEJ"); - assert_eq!(format!("{}", LanguageCode::Bel), "BEL"); - assert_eq!(format!("{}", LanguageCode::Bem), "BEM"); - assert_eq!(format!("{}", LanguageCode::Ben), "BEN"); - assert_eq!(format!("{}", LanguageCode::Ber), "BER"); - assert_eq!(format!("{}", LanguageCode::Bho), "BHO"); - assert_eq!(format!("{}", LanguageCode::Bih), "BIH"); - assert_eq!(format!("{}", LanguageCode::Bik), "BIK"); - assert_eq!(format!("{}", LanguageCode::Bin), "BIN"); - assert_eq!(format!("{}", LanguageCode::Bis), "BIS"); - assert_eq!(format!("{}", LanguageCode::Bla), "BLA"); - assert_eq!(format!("{}", LanguageCode::Bnt), "BNT"); - assert_eq!(format!("{}", LanguageCode::Bos), "BOS"); - assert_eq!(format!("{}", LanguageCode::Bra), "BRA"); - assert_eq!(format!("{}", LanguageCode::Bre), "BRE"); - assert_eq!(format!("{}", LanguageCode::Btk), "BTK"); - assert_eq!(format!("{}", LanguageCode::Bua), "BUA"); - assert_eq!(format!("{}", LanguageCode::Bug), "BUG"); - assert_eq!(format!("{}", LanguageCode::Bul), "BUL"); - assert_eq!(format!("{}", LanguageCode::Bur), "BUR"); - assert_eq!(format!("{}", LanguageCode::Byn), "BYN"); - assert_eq!(format!("{}", LanguageCode::Cad), "CAD"); - assert_eq!(format!("{}", LanguageCode::Cai), "CAI"); - assert_eq!(format!("{}", LanguageCode::Car), "CAR"); - assert_eq!(format!("{}", LanguageCode::Cat), "CAT"); - assert_eq!(format!("{}", LanguageCode::Cau), "CAU"); - assert_eq!(format!("{}", LanguageCode::Ceb), "CEB"); - assert_eq!(format!("{}", LanguageCode::Cel), "CEL"); - assert_eq!(format!("{}", LanguageCode::Cha), "CHA"); - assert_eq!(format!("{}", LanguageCode::Chb), "CHB"); - assert_eq!(format!("{}", LanguageCode::Che), "CHE"); - assert_eq!(format!("{}", LanguageCode::Chg), "CHG"); - assert_eq!(format!("{}", LanguageCode::Chi), "CHI"); - assert_eq!(format!("{}", LanguageCode::Chk), "CHK"); - assert_eq!(format!("{}", LanguageCode::Chm), "CHM"); - assert_eq!(format!("{}", LanguageCode::Chn), "CHN"); - assert_eq!(format!("{}", LanguageCode::Cho), "CHO"); - assert_eq!(format!("{}", LanguageCode::Chp), "CHP"); - assert_eq!(format!("{}", LanguageCode::Chr), "CHR"); - assert_eq!(format!("{}", LanguageCode::Chu), "CHU"); - assert_eq!(format!("{}", LanguageCode::Chv), "CHV"); - assert_eq!(format!("{}", LanguageCode::Chy), "CHY"); - assert_eq!(format!("{}", LanguageCode::Cmc), "CMC"); - assert_eq!(format!("{}", LanguageCode::Cnr), "CNR"); - assert_eq!(format!("{}", LanguageCode::Cop), "COP"); - assert_eq!(format!("{}", LanguageCode::Cor), "COR"); - assert_eq!(format!("{}", LanguageCode::Cos), "COS"); - assert_eq!(format!("{}", LanguageCode::Cpe), "CPE"); - assert_eq!(format!("{}", LanguageCode::Cpf), "CPF"); - assert_eq!(format!("{}", LanguageCode::Cpp), "CPP"); - assert_eq!(format!("{}", LanguageCode::Cre), "CRE"); - assert_eq!(format!("{}", LanguageCode::Crh), "CRH"); - assert_eq!(format!("{}", LanguageCode::Crp), "CRP"); - assert_eq!(format!("{}", LanguageCode::Csb), "CSB"); - assert_eq!(format!("{}", LanguageCode::Cus), "CUS"); - assert_eq!(format!("{}", LanguageCode::Cze), "CZE"); - assert_eq!(format!("{}", LanguageCode::Dak), "DAK"); - assert_eq!(format!("{}", LanguageCode::Dan), "DAN"); - assert_eq!(format!("{}", LanguageCode::Dar), "DAR"); - assert_eq!(format!("{}", LanguageCode::Day), "DAY"); - assert_eq!(format!("{}", LanguageCode::Del), "DEL"); - assert_eq!(format!("{}", LanguageCode::Den), "DEN"); - assert_eq!(format!("{}", LanguageCode::Dgr), "DGR"); - assert_eq!(format!("{}", LanguageCode::Din), "DIN"); - assert_eq!(format!("{}", LanguageCode::Div), "DIV"); - assert_eq!(format!("{}", LanguageCode::Doi), "DOI"); - assert_eq!(format!("{}", LanguageCode::Dra), "DRA"); - assert_eq!(format!("{}", LanguageCode::Dsb), "DSB"); - assert_eq!(format!("{}", LanguageCode::Dua), "DUA"); - assert_eq!(format!("{}", LanguageCode::Dum), "DUM"); - assert_eq!(format!("{}", LanguageCode::Dut), "DUT"); - assert_eq!(format!("{}", LanguageCode::Dyu), "DYU"); - assert_eq!(format!("{}", LanguageCode::Dzo), "DZO"); - assert_eq!(format!("{}", LanguageCode::Efi), "EFI"); - assert_eq!(format!("{}", LanguageCode::Egy), "EGY"); - assert_eq!(format!("{}", LanguageCode::Eka), "EKA"); - assert_eq!(format!("{}", LanguageCode::Elx), "ELX"); - assert_eq!(format!("{}", LanguageCode::Eng), "ENG"); - assert_eq!(format!("{}", LanguageCode::Enm), "ENM"); - assert_eq!(format!("{}", LanguageCode::Epo), "EPO"); - assert_eq!(format!("{}", LanguageCode::Est), "EST"); - assert_eq!(format!("{}", LanguageCode::Ewe), "EWE"); - assert_eq!(format!("{}", LanguageCode::Ewo), "EWO"); - assert_eq!(format!("{}", LanguageCode::Fan), "FAN"); - assert_eq!(format!("{}", LanguageCode::Fao), "FAO"); - assert_eq!(format!("{}", LanguageCode::Fat), "FAT"); - assert_eq!(format!("{}", LanguageCode::Fij), "FIJ"); - assert_eq!(format!("{}", LanguageCode::Fil), "FIL"); - assert_eq!(format!("{}", LanguageCode::Fin), "FIN"); - assert_eq!(format!("{}", LanguageCode::Fiu), "FIU"); - assert_eq!(format!("{}", LanguageCode::Fon), "FON"); - assert_eq!(format!("{}", LanguageCode::Fre), "FRE"); - assert_eq!(format!("{}", LanguageCode::Frm), "FRM"); - assert_eq!(format!("{}", LanguageCode::Fro), "FRO"); - assert_eq!(format!("{}", LanguageCode::Frr), "FRR"); - assert_eq!(format!("{}", LanguageCode::Frs), "FRS"); - assert_eq!(format!("{}", LanguageCode::Fry), "FRY"); - assert_eq!(format!("{}", LanguageCode::Ful), "FUL"); - assert_eq!(format!("{}", LanguageCode::Fur), "FUR"); - assert_eq!(format!("{}", LanguageCode::Gaa), "GAA"); - assert_eq!(format!("{}", LanguageCode::Gay), "GAY"); - assert_eq!(format!("{}", LanguageCode::Gba), "GBA"); - assert_eq!(format!("{}", LanguageCode::Gem), "GEM"); - assert_eq!(format!("{}", LanguageCode::Geo), "GEO"); - assert_eq!(format!("{}", LanguageCode::Ger), "GER"); - assert_eq!(format!("{}", LanguageCode::Gez), "GEZ"); - assert_eq!(format!("{}", LanguageCode::Gil), "GIL"); - assert_eq!(format!("{}", LanguageCode::Gla), "GLA"); - assert_eq!(format!("{}", LanguageCode::Gle), "GLE"); - assert_eq!(format!("{}", LanguageCode::Glg), "GLG"); - assert_eq!(format!("{}", LanguageCode::Glv), "GLV"); - assert_eq!(format!("{}", LanguageCode::Gmh), "GMH"); - assert_eq!(format!("{}", LanguageCode::Goh), "GOH"); - assert_eq!(format!("{}", LanguageCode::Gon), "GON"); - assert_eq!(format!("{}", LanguageCode::Gor), "GOR"); - assert_eq!(format!("{}", LanguageCode::Got), "GOT"); - assert_eq!(format!("{}", LanguageCode::Grb), "GRB"); - assert_eq!(format!("{}", LanguageCode::Grc), "GRC"); - assert_eq!(format!("{}", LanguageCode::Gre), "GRE"); - assert_eq!(format!("{}", LanguageCode::Grn), "GRN"); - assert_eq!(format!("{}", LanguageCode::Gsw), "GSW"); - assert_eq!(format!("{}", LanguageCode::Guj), "GUJ"); - assert_eq!(format!("{}", LanguageCode::Gwi), "GWI"); - assert_eq!(format!("{}", LanguageCode::Hai), "HAI"); - assert_eq!(format!("{}", LanguageCode::Hat), "HAT"); - assert_eq!(format!("{}", LanguageCode::Hau), "HAU"); - assert_eq!(format!("{}", LanguageCode::Haw), "HAW"); - assert_eq!(format!("{}", LanguageCode::Heb), "HEB"); - assert_eq!(format!("{}", LanguageCode::Her), "HER"); - assert_eq!(format!("{}", LanguageCode::Hil), "HIL"); - assert_eq!(format!("{}", LanguageCode::Him), "HIM"); - assert_eq!(format!("{}", LanguageCode::Hin), "HIN"); - assert_eq!(format!("{}", LanguageCode::Hit), "HIT"); - assert_eq!(format!("{}", LanguageCode::Hmn), "HMN"); - assert_eq!(format!("{}", LanguageCode::Hmo), "HMO"); - assert_eq!(format!("{}", LanguageCode::Hrv), "HRV"); - assert_eq!(format!("{}", LanguageCode::Hsb), "HSB"); - assert_eq!(format!("{}", LanguageCode::Hun), "HUN"); - assert_eq!(format!("{}", LanguageCode::Hup), "HUP"); - assert_eq!(format!("{}", LanguageCode::Iba), "IBA"); - assert_eq!(format!("{}", LanguageCode::Ibo), "IBO"); - assert_eq!(format!("{}", LanguageCode::Ice), "ICE"); - assert_eq!(format!("{}", LanguageCode::Ido), "IDO"); - assert_eq!(format!("{}", LanguageCode::Iii), "III"); - assert_eq!(format!("{}", LanguageCode::Ijo), "IJO"); - assert_eq!(format!("{}", LanguageCode::Iku), "IKU"); - assert_eq!(format!("{}", LanguageCode::Ile), "ILE"); - assert_eq!(format!("{}", LanguageCode::Ilo), "ILO"); - assert_eq!(format!("{}", LanguageCode::Ina), "INA"); - assert_eq!(format!("{}", LanguageCode::Inc), "INC"); - assert_eq!(format!("{}", LanguageCode::Ind), "IND"); - assert_eq!(format!("{}", LanguageCode::Ine), "INE"); - assert_eq!(format!("{}", LanguageCode::Inh), "INH"); - assert_eq!(format!("{}", LanguageCode::Ipk), "IPK"); - assert_eq!(format!("{}", LanguageCode::Ira), "IRA"); - assert_eq!(format!("{}", LanguageCode::Iro), "IRO"); - assert_eq!(format!("{}", LanguageCode::Ita), "ITA"); - assert_eq!(format!("{}", LanguageCode::Jav), "JAV"); - assert_eq!(format!("{}", LanguageCode::Jbo), "JBO"); - assert_eq!(format!("{}", LanguageCode::Jpn), "JPN"); - assert_eq!(format!("{}", LanguageCode::Jpr), "JPR"); - assert_eq!(format!("{}", LanguageCode::Jrb), "JRB"); - assert_eq!(format!("{}", LanguageCode::Kaa), "KAA"); - assert_eq!(format!("{}", LanguageCode::Kab), "KAB"); - assert_eq!(format!("{}", LanguageCode::Kac), "KAC"); - assert_eq!(format!("{}", LanguageCode::Kal), "KAL"); - assert_eq!(format!("{}", LanguageCode::Kam), "KAM"); - assert_eq!(format!("{}", LanguageCode::Kan), "KAN"); - assert_eq!(format!("{}", LanguageCode::Kar), "KAR"); - assert_eq!(format!("{}", LanguageCode::Kas), "KAS"); - assert_eq!(format!("{}", LanguageCode::Kau), "KAU"); - assert_eq!(format!("{}", LanguageCode::Kaw), "KAW"); - assert_eq!(format!("{}", LanguageCode::Kaz), "KAZ"); - assert_eq!(format!("{}", LanguageCode::Kbd), "KBD"); - assert_eq!(format!("{}", LanguageCode::Kha), "KHA"); - assert_eq!(format!("{}", LanguageCode::Khi), "KHI"); - assert_eq!(format!("{}", LanguageCode::Khm), "KHM"); - assert_eq!(format!("{}", LanguageCode::Kho), "KHO"); - assert_eq!(format!("{}", LanguageCode::Kik), "KIK"); - assert_eq!(format!("{}", LanguageCode::Kin), "KIN"); - assert_eq!(format!("{}", LanguageCode::Kir), "KIR"); - assert_eq!(format!("{}", LanguageCode::Kmb), "KMB"); - assert_eq!(format!("{}", LanguageCode::Kok), "KOK"); - assert_eq!(format!("{}", LanguageCode::Kom), "KOM"); - assert_eq!(format!("{}", LanguageCode::Kon), "KON"); - assert_eq!(format!("{}", LanguageCode::Kor), "KOR"); - assert_eq!(format!("{}", LanguageCode::Kos), "KOS"); - assert_eq!(format!("{}", LanguageCode::Kpe), "KPE"); - assert_eq!(format!("{}", LanguageCode::Krc), "KRC"); - assert_eq!(format!("{}", LanguageCode::Krl), "KRL"); - assert_eq!(format!("{}", LanguageCode::Kro), "KRO"); - assert_eq!(format!("{}", LanguageCode::Kru), "KRU"); - assert_eq!(format!("{}", LanguageCode::Kua), "KUA"); - assert_eq!(format!("{}", LanguageCode::Kum), "KUM"); - assert_eq!(format!("{}", LanguageCode::Kur), "KUR"); - assert_eq!(format!("{}", LanguageCode::Kut), "KUT"); - assert_eq!(format!("{}", LanguageCode::Lad), "LAD"); - assert_eq!(format!("{}", LanguageCode::Lah), "LAH"); - assert_eq!(format!("{}", LanguageCode::Lam), "LAM"); - assert_eq!(format!("{}", LanguageCode::Lao), "LAO"); - assert_eq!(format!("{}", LanguageCode::Lat), "LAT"); - assert_eq!(format!("{}", LanguageCode::Lav), "LAV"); - assert_eq!(format!("{}", LanguageCode::Lez), "LEZ"); - assert_eq!(format!("{}", LanguageCode::Lim), "LIM"); - assert_eq!(format!("{}", LanguageCode::Lin), "LIN"); - assert_eq!(format!("{}", LanguageCode::Lit), "LIT"); - assert_eq!(format!("{}", LanguageCode::Lol), "LOL"); - assert_eq!(format!("{}", LanguageCode::Loz), "LOZ"); - assert_eq!(format!("{}", LanguageCode::Ltz), "LTZ"); - assert_eq!(format!("{}", LanguageCode::Lua), "LUA"); - assert_eq!(format!("{}", LanguageCode::Lub), "LUB"); - assert_eq!(format!("{}", LanguageCode::Lug), "LUG"); - assert_eq!(format!("{}", LanguageCode::Lui), "LUI"); - assert_eq!(format!("{}", LanguageCode::Lun), "LUN"); - assert_eq!(format!("{}", LanguageCode::Luo), "LUO"); - assert_eq!(format!("{}", LanguageCode::Lus), "LUS"); - assert_eq!(format!("{}", LanguageCode::Mac), "MAC"); - assert_eq!(format!("{}", LanguageCode::Mad), "MAD"); - assert_eq!(format!("{}", LanguageCode::Mag), "MAG"); - assert_eq!(format!("{}", LanguageCode::Mah), "MAH"); - assert_eq!(format!("{}", LanguageCode::Mai), "MAI"); - assert_eq!(format!("{}", LanguageCode::Mak), "MAK"); - assert_eq!(format!("{}", LanguageCode::Mal), "MAL"); - assert_eq!(format!("{}", LanguageCode::Man), "MAN"); - assert_eq!(format!("{}", LanguageCode::Mao), "MAO"); - assert_eq!(format!("{}", LanguageCode::Map), "MAP"); - assert_eq!(format!("{}", LanguageCode::Mar), "MAR"); - assert_eq!(format!("{}", LanguageCode::Mas), "MAS"); - assert_eq!(format!("{}", LanguageCode::May), "MAY"); - assert_eq!(format!("{}", LanguageCode::Mdf), "MDF"); - assert_eq!(format!("{}", LanguageCode::Mdr), "MDR"); - assert_eq!(format!("{}", LanguageCode::Men), "MEN"); - assert_eq!(format!("{}", LanguageCode::Mga), "MGA"); - assert_eq!(format!("{}", LanguageCode::Mic), "MIC"); - assert_eq!(format!("{}", LanguageCode::Min), "MIN"); - assert_eq!(format!("{}", LanguageCode::Mis), "MIS"); - assert_eq!(format!("{}", LanguageCode::Mkh), "MKH"); - assert_eq!(format!("{}", LanguageCode::Mlg), "MLG"); - assert_eq!(format!("{}", LanguageCode::Mlt), "MLT"); - assert_eq!(format!("{}", LanguageCode::Mnc), "MNC"); - assert_eq!(format!("{}", LanguageCode::Mni), "MNI"); - assert_eq!(format!("{}", LanguageCode::Mno), "MNO"); - assert_eq!(format!("{}", LanguageCode::Moh), "MOH"); - assert_eq!(format!("{}", LanguageCode::Mon), "MON"); - assert_eq!(format!("{}", LanguageCode::Mos), "MOS"); - assert_eq!(format!("{}", LanguageCode::Mul), "MUL"); - assert_eq!(format!("{}", LanguageCode::Mun), "MUN"); - assert_eq!(format!("{}", LanguageCode::Mus), "MUS"); - assert_eq!(format!("{}", LanguageCode::Mwl), "MWL"); - assert_eq!(format!("{}", LanguageCode::Mwr), "MWR"); - assert_eq!(format!("{}", LanguageCode::Myn), "MYN"); - assert_eq!(format!("{}", LanguageCode::Myv), "MYV"); - assert_eq!(format!("{}", LanguageCode::Nah), "NAH"); - assert_eq!(format!("{}", LanguageCode::Nai), "NAI"); - assert_eq!(format!("{}", LanguageCode::Nap), "NAP"); - assert_eq!(format!("{}", LanguageCode::Nau), "NAU"); - assert_eq!(format!("{}", LanguageCode::Nav), "NAV"); - assert_eq!(format!("{}", LanguageCode::Nbl), "NBL"); - assert_eq!(format!("{}", LanguageCode::Nde), "NDE"); - assert_eq!(format!("{}", LanguageCode::Ndo), "NDO"); - assert_eq!(format!("{}", LanguageCode::Nds), "NDS"); - assert_eq!(format!("{}", LanguageCode::Nep), "NEP"); - assert_eq!(format!("{}", LanguageCode::New), "NEW"); - assert_eq!(format!("{}", LanguageCode::Nia), "NIA"); - assert_eq!(format!("{}", LanguageCode::Nic), "NIC"); - assert_eq!(format!("{}", LanguageCode::Niu), "NIU"); - assert_eq!(format!("{}", LanguageCode::Nno), "NNO"); - assert_eq!(format!("{}", LanguageCode::Nob), "NOB"); - assert_eq!(format!("{}", LanguageCode::Nog), "NOG"); - assert_eq!(format!("{}", LanguageCode::Non), "NON"); - assert_eq!(format!("{}", LanguageCode::Nor), "NOR"); - assert_eq!(format!("{}", LanguageCode::Nqo), "NQO"); - assert_eq!(format!("{}", LanguageCode::Nso), "NSO"); - assert_eq!(format!("{}", LanguageCode::Nub), "NUB"); - assert_eq!(format!("{}", LanguageCode::Nwc), "NWC"); - assert_eq!(format!("{}", LanguageCode::Nya), "NYA"); - assert_eq!(format!("{}", LanguageCode::Nym), "NYM"); - assert_eq!(format!("{}", LanguageCode::Nyn), "NYN"); - assert_eq!(format!("{}", LanguageCode::Nyo), "NYO"); - assert_eq!(format!("{}", LanguageCode::Nzi), "NZI"); - assert_eq!(format!("{}", LanguageCode::Oci), "OCI"); - assert_eq!(format!("{}", LanguageCode::Oji), "OJI"); - assert_eq!(format!("{}", LanguageCode::Ori), "ORI"); - assert_eq!(format!("{}", LanguageCode::Orm), "ORM"); - assert_eq!(format!("{}", LanguageCode::Osa), "OSA"); - assert_eq!(format!("{}", LanguageCode::Oss), "OSS"); - assert_eq!(format!("{}", LanguageCode::Ota), "OTA"); - assert_eq!(format!("{}", LanguageCode::Oto), "OTO"); - assert_eq!(format!("{}", LanguageCode::Paa), "PAA"); - assert_eq!(format!("{}", LanguageCode::Pag), "PAG"); - assert_eq!(format!("{}", LanguageCode::Pal), "PAL"); - assert_eq!(format!("{}", LanguageCode::Pam), "PAM"); - assert_eq!(format!("{}", LanguageCode::Pan), "PAN"); - assert_eq!(format!("{}", LanguageCode::Pap), "PAP"); - assert_eq!(format!("{}", LanguageCode::Pau), "PAU"); - assert_eq!(format!("{}", LanguageCode::Peo), "PEO"); - assert_eq!(format!("{}", LanguageCode::Per), "PER"); - assert_eq!(format!("{}", LanguageCode::Phi), "PHI"); - assert_eq!(format!("{}", LanguageCode::Phn), "PHN"); - assert_eq!(format!("{}", LanguageCode::Pli), "PLI"); - assert_eq!(format!("{}", LanguageCode::Pol), "POL"); - assert_eq!(format!("{}", LanguageCode::Pon), "PON"); - assert_eq!(format!("{}", LanguageCode::Por), "POR"); - assert_eq!(format!("{}", LanguageCode::Pra), "PRA"); - assert_eq!(format!("{}", LanguageCode::Pro), "PRO"); - assert_eq!(format!("{}", LanguageCode::Pus), "PUS"); - assert_eq!(format!("{}", LanguageCode::Qaa), "QAA"); - assert_eq!(format!("{}", LanguageCode::Que), "QUE"); - assert_eq!(format!("{}", LanguageCode::Raj), "RAJ"); - assert_eq!(format!("{}", LanguageCode::Rap), "RAP"); - assert_eq!(format!("{}", LanguageCode::Rar), "RAR"); - assert_eq!(format!("{}", LanguageCode::Roa), "ROA"); - assert_eq!(format!("{}", LanguageCode::Roh), "ROH"); - assert_eq!(format!("{}", LanguageCode::Rom), "ROM"); - assert_eq!(format!("{}", LanguageCode::Rum), "RUM"); - assert_eq!(format!("{}", LanguageCode::Run), "RUN"); - assert_eq!(format!("{}", LanguageCode::Rup), "RUP"); - assert_eq!(format!("{}", LanguageCode::Rus), "RUS"); - assert_eq!(format!("{}", LanguageCode::Sad), "SAD"); - assert_eq!(format!("{}", LanguageCode::Sag), "SAG"); - assert_eq!(format!("{}", LanguageCode::Sah), "SAH"); - assert_eq!(format!("{}", LanguageCode::Sai), "SAI"); - assert_eq!(format!("{}", LanguageCode::Sal), "SAL"); - assert_eq!(format!("{}", LanguageCode::Sam), "SAM"); - assert_eq!(format!("{}", LanguageCode::San), "SAN"); - assert_eq!(format!("{}", LanguageCode::Sas), "SAS"); - assert_eq!(format!("{}", LanguageCode::Sat), "SAT"); - assert_eq!(format!("{}", LanguageCode::Scn), "SCN"); - assert_eq!(format!("{}", LanguageCode::Sco), "SCO"); - assert_eq!(format!("{}", LanguageCode::Sel), "SEL"); - assert_eq!(format!("{}", LanguageCode::Sem), "SEM"); - assert_eq!(format!("{}", LanguageCode::Sga), "SGA"); - assert_eq!(format!("{}", LanguageCode::Sgn), "SGN"); - assert_eq!(format!("{}", LanguageCode::Shn), "SHN"); - assert_eq!(format!("{}", LanguageCode::Sid), "SID"); - assert_eq!(format!("{}", LanguageCode::Sin), "SIN"); - assert_eq!(format!("{}", LanguageCode::Sio), "SIO"); - assert_eq!(format!("{}", LanguageCode::Sit), "SIT"); - assert_eq!(format!("{}", LanguageCode::Sla), "SLA"); - assert_eq!(format!("{}", LanguageCode::Slo), "SLO"); - assert_eq!(format!("{}", LanguageCode::Slv), "SLV"); - assert_eq!(format!("{}", LanguageCode::Sma), "SMA"); - assert_eq!(format!("{}", LanguageCode::Sme), "SME"); - assert_eq!(format!("{}", LanguageCode::Smi), "SMI"); - assert_eq!(format!("{}", LanguageCode::Smj), "SMJ"); - assert_eq!(format!("{}", LanguageCode::Smn), "SMN"); - assert_eq!(format!("{}", LanguageCode::Smo), "SMO"); - assert_eq!(format!("{}", LanguageCode::Sms), "SMS"); - assert_eq!(format!("{}", LanguageCode::Sna), "SNA"); - assert_eq!(format!("{}", LanguageCode::Snd), "SND"); - assert_eq!(format!("{}", LanguageCode::Snk), "SNK"); - assert_eq!(format!("{}", LanguageCode::Sog), "SOG"); - assert_eq!(format!("{}", LanguageCode::Som), "SOM"); - assert_eq!(format!("{}", LanguageCode::Son), "SON"); - assert_eq!(format!("{}", LanguageCode::Sot), "SOT"); - assert_eq!(format!("{}", LanguageCode::Spa), "SPA"); - assert_eq!(format!("{}", LanguageCode::Srd), "SRD"); - assert_eq!(format!("{}", LanguageCode::Srn), "SRN"); - assert_eq!(format!("{}", LanguageCode::Srp), "SRP"); - assert_eq!(format!("{}", LanguageCode::Srr), "SRR"); - assert_eq!(format!("{}", LanguageCode::Ssa), "SSA"); - assert_eq!(format!("{}", LanguageCode::Ssw), "SSW"); - assert_eq!(format!("{}", LanguageCode::Suk), "SUK"); - assert_eq!(format!("{}", LanguageCode::Sun), "SUN"); - assert_eq!(format!("{}", LanguageCode::Sus), "SUS"); - assert_eq!(format!("{}", LanguageCode::Sux), "SUX"); - assert_eq!(format!("{}", LanguageCode::Swa), "SWA"); - assert_eq!(format!("{}", LanguageCode::Swe), "SWE"); - assert_eq!(format!("{}", LanguageCode::Syc), "SYC"); - assert_eq!(format!("{}", LanguageCode::Syr), "SYR"); - assert_eq!(format!("{}", LanguageCode::Tah), "TAH"); - assert_eq!(format!("{}", LanguageCode::Tai), "TAI"); - assert_eq!(format!("{}", LanguageCode::Tam), "TAM"); - assert_eq!(format!("{}", LanguageCode::Tat), "TAT"); - assert_eq!(format!("{}", LanguageCode::Tel), "TEL"); - assert_eq!(format!("{}", LanguageCode::Tem), "TEM"); - assert_eq!(format!("{}", LanguageCode::Ter), "TER"); - assert_eq!(format!("{}", LanguageCode::Tet), "TET"); - assert_eq!(format!("{}", LanguageCode::Tgk), "TGK"); - assert_eq!(format!("{}", LanguageCode::Tgl), "TGL"); - assert_eq!(format!("{}", LanguageCode::Tha), "THA"); - assert_eq!(format!("{}", LanguageCode::Tib), "TIB"); - assert_eq!(format!("{}", LanguageCode::Tig), "TIG"); - assert_eq!(format!("{}", LanguageCode::Tir), "TIR"); - assert_eq!(format!("{}", LanguageCode::Tiv), "TIV"); - assert_eq!(format!("{}", LanguageCode::Tkl), "TKL"); - assert_eq!(format!("{}", LanguageCode::Tlh), "TLH"); - assert_eq!(format!("{}", LanguageCode::Tli), "TLI"); - assert_eq!(format!("{}", LanguageCode::Tmh), "TMH"); - assert_eq!(format!("{}", LanguageCode::Tog), "TOG"); - assert_eq!(format!("{}", LanguageCode::Ton), "TON"); - assert_eq!(format!("{}", LanguageCode::Tpi), "TPI"); - assert_eq!(format!("{}", LanguageCode::Tsi), "TSI"); - assert_eq!(format!("{}", LanguageCode::Tsn), "TSN"); - assert_eq!(format!("{}", LanguageCode::Tso), "TSO"); - assert_eq!(format!("{}", LanguageCode::Tuk), "TUK"); - assert_eq!(format!("{}", LanguageCode::Tum), "TUM"); - assert_eq!(format!("{}", LanguageCode::Tup), "TUP"); - assert_eq!(format!("{}", LanguageCode::Tur), "TUR"); - assert_eq!(format!("{}", LanguageCode::Tut), "TUT"); - assert_eq!(format!("{}", LanguageCode::Tvl), "TVL"); - assert_eq!(format!("{}", LanguageCode::Twi), "TWI"); - assert_eq!(format!("{}", LanguageCode::Tyv), "TYV"); - assert_eq!(format!("{}", LanguageCode::Udm), "UDM"); - assert_eq!(format!("{}", LanguageCode::Uga), "UGA"); - assert_eq!(format!("{}", LanguageCode::Uig), "UIG"); - assert_eq!(format!("{}", LanguageCode::Ukr), "UKR"); - assert_eq!(format!("{}", LanguageCode::Umb), "UMB"); - assert_eq!(format!("{}", LanguageCode::Und), "UND"); - assert_eq!(format!("{}", LanguageCode::Urd), "URD"); - assert_eq!(format!("{}", LanguageCode::Uzb), "UZB"); - assert_eq!(format!("{}", LanguageCode::Vai), "VAI"); - assert_eq!(format!("{}", LanguageCode::Ven), "VEN"); - assert_eq!(format!("{}", LanguageCode::Vie), "VIE"); - assert_eq!(format!("{}", LanguageCode::Vol), "VOL"); - assert_eq!(format!("{}", LanguageCode::Vot), "VOT"); - assert_eq!(format!("{}", LanguageCode::Wak), "WAK"); - assert_eq!(format!("{}", LanguageCode::Wal), "WAL"); - assert_eq!(format!("{}", LanguageCode::War), "WAR"); - assert_eq!(format!("{}", LanguageCode::Was), "WAS"); - assert_eq!(format!("{}", LanguageCode::Wel), "WEL"); - assert_eq!(format!("{}", LanguageCode::Wen), "WEN"); - assert_eq!(format!("{}", LanguageCode::Wln), "WLN"); - assert_eq!(format!("{}", LanguageCode::Wol), "WOL"); - assert_eq!(format!("{}", LanguageCode::Xal), "XAL"); - assert_eq!(format!("{}", LanguageCode::Xho), "XHO"); - assert_eq!(format!("{}", LanguageCode::Yao), "YAO"); - assert_eq!(format!("{}", LanguageCode::Yap), "YAP"); - assert_eq!(format!("{}", LanguageCode::Yid), "YID"); - assert_eq!(format!("{}", LanguageCode::Yor), "YOR"); - assert_eq!(format!("{}", LanguageCode::Ypk), "YPK"); - assert_eq!(format!("{}", LanguageCode::Zap), "ZAP"); - assert_eq!(format!("{}", LanguageCode::Zbl), "ZBL"); - assert_eq!(format!("{}", LanguageCode::Zen), "ZEN"); - assert_eq!(format!("{}", LanguageCode::Zgh), "ZGH"); - assert_eq!(format!("{}", LanguageCode::Zha), "ZHA"); - assert_eq!(format!("{}", LanguageCode::Znd), "ZND"); - assert_eq!(format!("{}", LanguageCode::Zul), "ZUL"); - assert_eq!(format!("{}", LanguageCode::Zun), "ZUN"); - assert_eq!(format!("{}", LanguageCode::Zxx), "ZXX"); - assert_eq!(format!("{}", LanguageCode::Zza), "ZZA"); -} - -#[test] -fn test_languagerelation_fromstr() { - use std::str::FromStr; - assert_eq!( - LanguageRelation::from_str("Original").unwrap(), - LanguageRelation::Original - ); - assert_eq!( - LanguageRelation::from_str("Translated From").unwrap(), - LanguageRelation::TranslatedFrom - ); - assert_eq!( - LanguageRelation::from_str("Translated Into").unwrap(), - LanguageRelation::TranslatedInto - ); - - assert!(LanguageRelation::from_str("Invented").is_err()); -} - -#[test] -fn test_languagecode_fromstr() { - use std::str::FromStr; - assert_eq!(LanguageCode::from_str("AAR").unwrap(), LanguageCode::Aar); - assert_eq!(LanguageCode::from_str("ABK").unwrap(), LanguageCode::Abk); - assert_eq!(LanguageCode::from_str("ACE").unwrap(), LanguageCode::Ace); - assert_eq!(LanguageCode::from_str("ACH").unwrap(), LanguageCode::Ach); - assert_eq!(LanguageCode::from_str("ADA").unwrap(), LanguageCode::Ada); - assert_eq!(LanguageCode::from_str("ADY").unwrap(), LanguageCode::Ady); - assert_eq!(LanguageCode::from_str("AFA").unwrap(), LanguageCode::Afa); - assert_eq!(LanguageCode::from_str("AFH").unwrap(), LanguageCode::Afh); - assert_eq!(LanguageCode::from_str("AFR").unwrap(), LanguageCode::Afr); - assert_eq!(LanguageCode::from_str("AIN").unwrap(), LanguageCode::Ain); - assert_eq!(LanguageCode::from_str("AKA").unwrap(), LanguageCode::Aka); - assert_eq!(LanguageCode::from_str("AKK").unwrap(), LanguageCode::Akk); - assert_eq!(LanguageCode::from_str("ALB").unwrap(), LanguageCode::Alb); - assert_eq!(LanguageCode::from_str("ALE").unwrap(), LanguageCode::Ale); - assert_eq!(LanguageCode::from_str("ALG").unwrap(), LanguageCode::Alg); - assert_eq!(LanguageCode::from_str("ALT").unwrap(), LanguageCode::Alt); - assert_eq!(LanguageCode::from_str("AMH").unwrap(), LanguageCode::Amh); - assert_eq!(LanguageCode::from_str("ANG").unwrap(), LanguageCode::Ang); - assert_eq!(LanguageCode::from_str("ANP").unwrap(), LanguageCode::Anp); - assert_eq!(LanguageCode::from_str("APA").unwrap(), LanguageCode::Apa); - assert_eq!(LanguageCode::from_str("ARA").unwrap(), LanguageCode::Ara); - assert_eq!(LanguageCode::from_str("ARC").unwrap(), LanguageCode::Arc); - assert_eq!(LanguageCode::from_str("ARG").unwrap(), LanguageCode::Arg); - assert_eq!(LanguageCode::from_str("ARM").unwrap(), LanguageCode::Arm); - assert_eq!(LanguageCode::from_str("ARN").unwrap(), LanguageCode::Arn); - assert_eq!(LanguageCode::from_str("ARP").unwrap(), LanguageCode::Arp); - assert_eq!(LanguageCode::from_str("ART").unwrap(), LanguageCode::Art); - assert_eq!(LanguageCode::from_str("ARW").unwrap(), LanguageCode::Arw); - assert_eq!(LanguageCode::from_str("ASM").unwrap(), LanguageCode::Asm); - assert_eq!(LanguageCode::from_str("AST").unwrap(), LanguageCode::Ast); - assert_eq!(LanguageCode::from_str("ATH").unwrap(), LanguageCode::Ath); - assert_eq!(LanguageCode::from_str("AUS").unwrap(), LanguageCode::Aus); - assert_eq!(LanguageCode::from_str("AVA").unwrap(), LanguageCode::Ava); - assert_eq!(LanguageCode::from_str("AVE").unwrap(), LanguageCode::Ave); - assert_eq!(LanguageCode::from_str("AWA").unwrap(), LanguageCode::Awa); - assert_eq!(LanguageCode::from_str("AYM").unwrap(), LanguageCode::Aym); - assert_eq!(LanguageCode::from_str("AZE").unwrap(), LanguageCode::Aze); - assert_eq!(LanguageCode::from_str("BAD").unwrap(), LanguageCode::Bad); - assert_eq!(LanguageCode::from_str("BAI").unwrap(), LanguageCode::Bai); - assert_eq!(LanguageCode::from_str("BAK").unwrap(), LanguageCode::Bak); - assert_eq!(LanguageCode::from_str("BAL").unwrap(), LanguageCode::Bal); - assert_eq!(LanguageCode::from_str("BAM").unwrap(), LanguageCode::Bam); - assert_eq!(LanguageCode::from_str("BAN").unwrap(), LanguageCode::Ban); - assert_eq!(LanguageCode::from_str("BAQ").unwrap(), LanguageCode::Baq); - assert_eq!(LanguageCode::from_str("BAS").unwrap(), LanguageCode::Bas); - assert_eq!(LanguageCode::from_str("BAT").unwrap(), LanguageCode::Bat); - assert_eq!(LanguageCode::from_str("BEJ").unwrap(), LanguageCode::Bej); - assert_eq!(LanguageCode::from_str("BEL").unwrap(), LanguageCode::Bel); - assert_eq!(LanguageCode::from_str("BEM").unwrap(), LanguageCode::Bem); - assert_eq!(LanguageCode::from_str("BEN").unwrap(), LanguageCode::Ben); - assert_eq!(LanguageCode::from_str("BER").unwrap(), LanguageCode::Ber); - assert_eq!(LanguageCode::from_str("BHO").unwrap(), LanguageCode::Bho); - assert_eq!(LanguageCode::from_str("BIH").unwrap(), LanguageCode::Bih); - assert_eq!(LanguageCode::from_str("BIK").unwrap(), LanguageCode::Bik); - assert_eq!(LanguageCode::from_str("BIN").unwrap(), LanguageCode::Bin); - assert_eq!(LanguageCode::from_str("BIS").unwrap(), LanguageCode::Bis); - assert_eq!(LanguageCode::from_str("BLA").unwrap(), LanguageCode::Bla); - assert_eq!(LanguageCode::from_str("BNT").unwrap(), LanguageCode::Bnt); - assert_eq!(LanguageCode::from_str("BOS").unwrap(), LanguageCode::Bos); - assert_eq!(LanguageCode::from_str("BRA").unwrap(), LanguageCode::Bra); - assert_eq!(LanguageCode::from_str("BRE").unwrap(), LanguageCode::Bre); - assert_eq!(LanguageCode::from_str("BTK").unwrap(), LanguageCode::Btk); - assert_eq!(LanguageCode::from_str("BUA").unwrap(), LanguageCode::Bua); - assert_eq!(LanguageCode::from_str("BUG").unwrap(), LanguageCode::Bug); - assert_eq!(LanguageCode::from_str("BUL").unwrap(), LanguageCode::Bul); - assert_eq!(LanguageCode::from_str("BUR").unwrap(), LanguageCode::Bur); - assert_eq!(LanguageCode::from_str("BYN").unwrap(), LanguageCode::Byn); - assert_eq!(LanguageCode::from_str("CAD").unwrap(), LanguageCode::Cad); - assert_eq!(LanguageCode::from_str("CAI").unwrap(), LanguageCode::Cai); - assert_eq!(LanguageCode::from_str("CAR").unwrap(), LanguageCode::Car); - assert_eq!(LanguageCode::from_str("CAT").unwrap(), LanguageCode::Cat); - assert_eq!(LanguageCode::from_str("CAU").unwrap(), LanguageCode::Cau); - assert_eq!(LanguageCode::from_str("CEB").unwrap(), LanguageCode::Ceb); - assert_eq!(LanguageCode::from_str("CEL").unwrap(), LanguageCode::Cel); - assert_eq!(LanguageCode::from_str("CHA").unwrap(), LanguageCode::Cha); - assert_eq!(LanguageCode::from_str("CHB").unwrap(), LanguageCode::Chb); - assert_eq!(LanguageCode::from_str("CHE").unwrap(), LanguageCode::Che); - assert_eq!(LanguageCode::from_str("CHG").unwrap(), LanguageCode::Chg); - assert_eq!(LanguageCode::from_str("CHI").unwrap(), LanguageCode::Chi); - assert_eq!(LanguageCode::from_str("CHK").unwrap(), LanguageCode::Chk); - assert_eq!(LanguageCode::from_str("CHM").unwrap(), LanguageCode::Chm); - assert_eq!(LanguageCode::from_str("CHN").unwrap(), LanguageCode::Chn); - assert_eq!(LanguageCode::from_str("CHO").unwrap(), LanguageCode::Cho); - assert_eq!(LanguageCode::from_str("CHP").unwrap(), LanguageCode::Chp); - assert_eq!(LanguageCode::from_str("CHR").unwrap(), LanguageCode::Chr); - assert_eq!(LanguageCode::from_str("CHU").unwrap(), LanguageCode::Chu); - assert_eq!(LanguageCode::from_str("CHV").unwrap(), LanguageCode::Chv); - assert_eq!(LanguageCode::from_str("CHY").unwrap(), LanguageCode::Chy); - assert_eq!(LanguageCode::from_str("CMC").unwrap(), LanguageCode::Cmc); - assert_eq!(LanguageCode::from_str("CNR").unwrap(), LanguageCode::Cnr); - assert_eq!(LanguageCode::from_str("COP").unwrap(), LanguageCode::Cop); - assert_eq!(LanguageCode::from_str("COR").unwrap(), LanguageCode::Cor); - assert_eq!(LanguageCode::from_str("COS").unwrap(), LanguageCode::Cos); - assert_eq!(LanguageCode::from_str("CPE").unwrap(), LanguageCode::Cpe); - assert_eq!(LanguageCode::from_str("CPF").unwrap(), LanguageCode::Cpf); - assert_eq!(LanguageCode::from_str("CPP").unwrap(), LanguageCode::Cpp); - assert_eq!(LanguageCode::from_str("CRE").unwrap(), LanguageCode::Cre); - assert_eq!(LanguageCode::from_str("CRH").unwrap(), LanguageCode::Crh); - assert_eq!(LanguageCode::from_str("CRP").unwrap(), LanguageCode::Crp); - assert_eq!(LanguageCode::from_str("CSB").unwrap(), LanguageCode::Csb); - assert_eq!(LanguageCode::from_str("CUS").unwrap(), LanguageCode::Cus); - assert_eq!(LanguageCode::from_str("CZE").unwrap(), LanguageCode::Cze); - assert_eq!(LanguageCode::from_str("DAK").unwrap(), LanguageCode::Dak); - assert_eq!(LanguageCode::from_str("DAN").unwrap(), LanguageCode::Dan); - assert_eq!(LanguageCode::from_str("DAR").unwrap(), LanguageCode::Dar); - assert_eq!(LanguageCode::from_str("DAY").unwrap(), LanguageCode::Day); - assert_eq!(LanguageCode::from_str("DEL").unwrap(), LanguageCode::Del); - assert_eq!(LanguageCode::from_str("DEN").unwrap(), LanguageCode::Den); - assert_eq!(LanguageCode::from_str("DGR").unwrap(), LanguageCode::Dgr); - assert_eq!(LanguageCode::from_str("DIN").unwrap(), LanguageCode::Din); - assert_eq!(LanguageCode::from_str("DIV").unwrap(), LanguageCode::Div); - assert_eq!(LanguageCode::from_str("DOI").unwrap(), LanguageCode::Doi); - assert_eq!(LanguageCode::from_str("DRA").unwrap(), LanguageCode::Dra); - assert_eq!(LanguageCode::from_str("DSB").unwrap(), LanguageCode::Dsb); - assert_eq!(LanguageCode::from_str("DUA").unwrap(), LanguageCode::Dua); - assert_eq!(LanguageCode::from_str("DUM").unwrap(), LanguageCode::Dum); - assert_eq!(LanguageCode::from_str("DUT").unwrap(), LanguageCode::Dut); - assert_eq!(LanguageCode::from_str("DYU").unwrap(), LanguageCode::Dyu); - assert_eq!(LanguageCode::from_str("DZO").unwrap(), LanguageCode::Dzo); - assert_eq!(LanguageCode::from_str("EFI").unwrap(), LanguageCode::Efi); - assert_eq!(LanguageCode::from_str("EGY").unwrap(), LanguageCode::Egy); - assert_eq!(LanguageCode::from_str("EKA").unwrap(), LanguageCode::Eka); - assert_eq!(LanguageCode::from_str("ELX").unwrap(), LanguageCode::Elx); - assert_eq!(LanguageCode::from_str("ENG").unwrap(), LanguageCode::Eng); - assert_eq!(LanguageCode::from_str("ENM").unwrap(), LanguageCode::Enm); - assert_eq!(LanguageCode::from_str("EPO").unwrap(), LanguageCode::Epo); - assert_eq!(LanguageCode::from_str("EST").unwrap(), LanguageCode::Est); - assert_eq!(LanguageCode::from_str("EWE").unwrap(), LanguageCode::Ewe); - assert_eq!(LanguageCode::from_str("EWO").unwrap(), LanguageCode::Ewo); - assert_eq!(LanguageCode::from_str("FAN").unwrap(), LanguageCode::Fan); - assert_eq!(LanguageCode::from_str("FAO").unwrap(), LanguageCode::Fao); - assert_eq!(LanguageCode::from_str("FAT").unwrap(), LanguageCode::Fat); - assert_eq!(LanguageCode::from_str("FIJ").unwrap(), LanguageCode::Fij); - assert_eq!(LanguageCode::from_str("FIL").unwrap(), LanguageCode::Fil); - assert_eq!(LanguageCode::from_str("FIN").unwrap(), LanguageCode::Fin); - assert_eq!(LanguageCode::from_str("FIU").unwrap(), LanguageCode::Fiu); - assert_eq!(LanguageCode::from_str("FON").unwrap(), LanguageCode::Fon); - assert_eq!(LanguageCode::from_str("FRE").unwrap(), LanguageCode::Fre); - assert_eq!(LanguageCode::from_str("FRM").unwrap(), LanguageCode::Frm); - assert_eq!(LanguageCode::from_str("FRO").unwrap(), LanguageCode::Fro); - assert_eq!(LanguageCode::from_str("FRR").unwrap(), LanguageCode::Frr); - assert_eq!(LanguageCode::from_str("FRS").unwrap(), LanguageCode::Frs); - assert_eq!(LanguageCode::from_str("FRY").unwrap(), LanguageCode::Fry); - assert_eq!(LanguageCode::from_str("FUL").unwrap(), LanguageCode::Ful); - assert_eq!(LanguageCode::from_str("FUR").unwrap(), LanguageCode::Fur); - assert_eq!(LanguageCode::from_str("GAA").unwrap(), LanguageCode::Gaa); - assert_eq!(LanguageCode::from_str("GAY").unwrap(), LanguageCode::Gay); - assert_eq!(LanguageCode::from_str("GBA").unwrap(), LanguageCode::Gba); - assert_eq!(LanguageCode::from_str("GEM").unwrap(), LanguageCode::Gem); - assert_eq!(LanguageCode::from_str("GEO").unwrap(), LanguageCode::Geo); - assert_eq!(LanguageCode::from_str("GER").unwrap(), LanguageCode::Ger); - assert_eq!(LanguageCode::from_str("GEZ").unwrap(), LanguageCode::Gez); - assert_eq!(LanguageCode::from_str("GIL").unwrap(), LanguageCode::Gil); - assert_eq!(LanguageCode::from_str("GLA").unwrap(), LanguageCode::Gla); - assert_eq!(LanguageCode::from_str("GLE").unwrap(), LanguageCode::Gle); - assert_eq!(LanguageCode::from_str("GLG").unwrap(), LanguageCode::Glg); - assert_eq!(LanguageCode::from_str("GLV").unwrap(), LanguageCode::Glv); - assert_eq!(LanguageCode::from_str("GMH").unwrap(), LanguageCode::Gmh); - assert_eq!(LanguageCode::from_str("GOH").unwrap(), LanguageCode::Goh); - assert_eq!(LanguageCode::from_str("GON").unwrap(), LanguageCode::Gon); - assert_eq!(LanguageCode::from_str("GOR").unwrap(), LanguageCode::Gor); - assert_eq!(LanguageCode::from_str("GOT").unwrap(), LanguageCode::Got); - assert_eq!(LanguageCode::from_str("GRB").unwrap(), LanguageCode::Grb); - assert_eq!(LanguageCode::from_str("GRC").unwrap(), LanguageCode::Grc); - assert_eq!(LanguageCode::from_str("GRE").unwrap(), LanguageCode::Gre); - assert_eq!(LanguageCode::from_str("GRN").unwrap(), LanguageCode::Grn); - assert_eq!(LanguageCode::from_str("GSW").unwrap(), LanguageCode::Gsw); - assert_eq!(LanguageCode::from_str("GUJ").unwrap(), LanguageCode::Guj); - assert_eq!(LanguageCode::from_str("GWI").unwrap(), LanguageCode::Gwi); - assert_eq!(LanguageCode::from_str("HAI").unwrap(), LanguageCode::Hai); - assert_eq!(LanguageCode::from_str("HAT").unwrap(), LanguageCode::Hat); - assert_eq!(LanguageCode::from_str("HAU").unwrap(), LanguageCode::Hau); - assert_eq!(LanguageCode::from_str("HAW").unwrap(), LanguageCode::Haw); - assert_eq!(LanguageCode::from_str("HEB").unwrap(), LanguageCode::Heb); - assert_eq!(LanguageCode::from_str("HER").unwrap(), LanguageCode::Her); - assert_eq!(LanguageCode::from_str("HIL").unwrap(), LanguageCode::Hil); - assert_eq!(LanguageCode::from_str("HIM").unwrap(), LanguageCode::Him); - assert_eq!(LanguageCode::from_str("HIN").unwrap(), LanguageCode::Hin); - assert_eq!(LanguageCode::from_str("HIT").unwrap(), LanguageCode::Hit); - assert_eq!(LanguageCode::from_str("HMN").unwrap(), LanguageCode::Hmn); - assert_eq!(LanguageCode::from_str("HMO").unwrap(), LanguageCode::Hmo); - assert_eq!(LanguageCode::from_str("HRV").unwrap(), LanguageCode::Hrv); - assert_eq!(LanguageCode::from_str("HSB").unwrap(), LanguageCode::Hsb); - assert_eq!(LanguageCode::from_str("HUN").unwrap(), LanguageCode::Hun); - assert_eq!(LanguageCode::from_str("HUP").unwrap(), LanguageCode::Hup); - assert_eq!(LanguageCode::from_str("IBA").unwrap(), LanguageCode::Iba); - assert_eq!(LanguageCode::from_str("IBO").unwrap(), LanguageCode::Ibo); - assert_eq!(LanguageCode::from_str("ICE").unwrap(), LanguageCode::Ice); - assert_eq!(LanguageCode::from_str("IDO").unwrap(), LanguageCode::Ido); - assert_eq!(LanguageCode::from_str("III").unwrap(), LanguageCode::Iii); - assert_eq!(LanguageCode::from_str("IJO").unwrap(), LanguageCode::Ijo); - assert_eq!(LanguageCode::from_str("IKU").unwrap(), LanguageCode::Iku); - assert_eq!(LanguageCode::from_str("ILE").unwrap(), LanguageCode::Ile); - assert_eq!(LanguageCode::from_str("ILO").unwrap(), LanguageCode::Ilo); - assert_eq!(LanguageCode::from_str("INA").unwrap(), LanguageCode::Ina); - assert_eq!(LanguageCode::from_str("INC").unwrap(), LanguageCode::Inc); - assert_eq!(LanguageCode::from_str("IND").unwrap(), LanguageCode::Ind); - assert_eq!(LanguageCode::from_str("INE").unwrap(), LanguageCode::Ine); - assert_eq!(LanguageCode::from_str("INH").unwrap(), LanguageCode::Inh); - assert_eq!(LanguageCode::from_str("IPK").unwrap(), LanguageCode::Ipk); - assert_eq!(LanguageCode::from_str("IRA").unwrap(), LanguageCode::Ira); - assert_eq!(LanguageCode::from_str("IRO").unwrap(), LanguageCode::Iro); - assert_eq!(LanguageCode::from_str("ITA").unwrap(), LanguageCode::Ita); - assert_eq!(LanguageCode::from_str("JAV").unwrap(), LanguageCode::Jav); - assert_eq!(LanguageCode::from_str("JBO").unwrap(), LanguageCode::Jbo); - assert_eq!(LanguageCode::from_str("JPN").unwrap(), LanguageCode::Jpn); - assert_eq!(LanguageCode::from_str("JPR").unwrap(), LanguageCode::Jpr); - assert_eq!(LanguageCode::from_str("JRB").unwrap(), LanguageCode::Jrb); - assert_eq!(LanguageCode::from_str("KAA").unwrap(), LanguageCode::Kaa); - assert_eq!(LanguageCode::from_str("KAB").unwrap(), LanguageCode::Kab); - assert_eq!(LanguageCode::from_str("KAC").unwrap(), LanguageCode::Kac); - assert_eq!(LanguageCode::from_str("KAL").unwrap(), LanguageCode::Kal); - assert_eq!(LanguageCode::from_str("KAM").unwrap(), LanguageCode::Kam); - assert_eq!(LanguageCode::from_str("KAN").unwrap(), LanguageCode::Kan); - assert_eq!(LanguageCode::from_str("KAR").unwrap(), LanguageCode::Kar); - assert_eq!(LanguageCode::from_str("KAS").unwrap(), LanguageCode::Kas); - assert_eq!(LanguageCode::from_str("KAU").unwrap(), LanguageCode::Kau); - assert_eq!(LanguageCode::from_str("KAW").unwrap(), LanguageCode::Kaw); - assert_eq!(LanguageCode::from_str("KAZ").unwrap(), LanguageCode::Kaz); - assert_eq!(LanguageCode::from_str("KBD").unwrap(), LanguageCode::Kbd); - assert_eq!(LanguageCode::from_str("KHA").unwrap(), LanguageCode::Kha); - assert_eq!(LanguageCode::from_str("KHI").unwrap(), LanguageCode::Khi); - assert_eq!(LanguageCode::from_str("KHM").unwrap(), LanguageCode::Khm); - assert_eq!(LanguageCode::from_str("KHO").unwrap(), LanguageCode::Kho); - assert_eq!(LanguageCode::from_str("KIK").unwrap(), LanguageCode::Kik); - assert_eq!(LanguageCode::from_str("KIN").unwrap(), LanguageCode::Kin); - assert_eq!(LanguageCode::from_str("KIR").unwrap(), LanguageCode::Kir); - assert_eq!(LanguageCode::from_str("KMB").unwrap(), LanguageCode::Kmb); - assert_eq!(LanguageCode::from_str("KOK").unwrap(), LanguageCode::Kok); - assert_eq!(LanguageCode::from_str("KOM").unwrap(), LanguageCode::Kom); - assert_eq!(LanguageCode::from_str("KON").unwrap(), LanguageCode::Kon); - assert_eq!(LanguageCode::from_str("KOR").unwrap(), LanguageCode::Kor); - assert_eq!(LanguageCode::from_str("KOS").unwrap(), LanguageCode::Kos); - assert_eq!(LanguageCode::from_str("KPE").unwrap(), LanguageCode::Kpe); - assert_eq!(LanguageCode::from_str("KRC").unwrap(), LanguageCode::Krc); - assert_eq!(LanguageCode::from_str("KRL").unwrap(), LanguageCode::Krl); - assert_eq!(LanguageCode::from_str("KRO").unwrap(), LanguageCode::Kro); - assert_eq!(LanguageCode::from_str("KRU").unwrap(), LanguageCode::Kru); - assert_eq!(LanguageCode::from_str("KUA").unwrap(), LanguageCode::Kua); - assert_eq!(LanguageCode::from_str("KUM").unwrap(), LanguageCode::Kum); - assert_eq!(LanguageCode::from_str("KUR").unwrap(), LanguageCode::Kur); - assert_eq!(LanguageCode::from_str("KUT").unwrap(), LanguageCode::Kut); - assert_eq!(LanguageCode::from_str("LAD").unwrap(), LanguageCode::Lad); - assert_eq!(LanguageCode::from_str("LAH").unwrap(), LanguageCode::Lah); - assert_eq!(LanguageCode::from_str("LAM").unwrap(), LanguageCode::Lam); - assert_eq!(LanguageCode::from_str("LAO").unwrap(), LanguageCode::Lao); - assert_eq!(LanguageCode::from_str("LAT").unwrap(), LanguageCode::Lat); - assert_eq!(LanguageCode::from_str("LAV").unwrap(), LanguageCode::Lav); - assert_eq!(LanguageCode::from_str("LEZ").unwrap(), LanguageCode::Lez); - assert_eq!(LanguageCode::from_str("LIM").unwrap(), LanguageCode::Lim); - assert_eq!(LanguageCode::from_str("LIN").unwrap(), LanguageCode::Lin); - assert_eq!(LanguageCode::from_str("LIT").unwrap(), LanguageCode::Lit); - assert_eq!(LanguageCode::from_str("LOL").unwrap(), LanguageCode::Lol); - assert_eq!(LanguageCode::from_str("LOZ").unwrap(), LanguageCode::Loz); - assert_eq!(LanguageCode::from_str("LTZ").unwrap(), LanguageCode::Ltz); - assert_eq!(LanguageCode::from_str("LUA").unwrap(), LanguageCode::Lua); - assert_eq!(LanguageCode::from_str("LUB").unwrap(), LanguageCode::Lub); - assert_eq!(LanguageCode::from_str("LUG").unwrap(), LanguageCode::Lug); - assert_eq!(LanguageCode::from_str("LUI").unwrap(), LanguageCode::Lui); - assert_eq!(LanguageCode::from_str("LUN").unwrap(), LanguageCode::Lun); - assert_eq!(LanguageCode::from_str("LUO").unwrap(), LanguageCode::Luo); - assert_eq!(LanguageCode::from_str("LUS").unwrap(), LanguageCode::Lus); - assert_eq!(LanguageCode::from_str("MAC").unwrap(), LanguageCode::Mac); - assert_eq!(LanguageCode::from_str("MAD").unwrap(), LanguageCode::Mad); - assert_eq!(LanguageCode::from_str("MAG").unwrap(), LanguageCode::Mag); - assert_eq!(LanguageCode::from_str("MAH").unwrap(), LanguageCode::Mah); - assert_eq!(LanguageCode::from_str("MAI").unwrap(), LanguageCode::Mai); - assert_eq!(LanguageCode::from_str("MAK").unwrap(), LanguageCode::Mak); - assert_eq!(LanguageCode::from_str("MAL").unwrap(), LanguageCode::Mal); - assert_eq!(LanguageCode::from_str("MAN").unwrap(), LanguageCode::Man); - assert_eq!(LanguageCode::from_str("MAO").unwrap(), LanguageCode::Mao); - assert_eq!(LanguageCode::from_str("MAP").unwrap(), LanguageCode::Map); - assert_eq!(LanguageCode::from_str("MAR").unwrap(), LanguageCode::Mar); - assert_eq!(LanguageCode::from_str("MAS").unwrap(), LanguageCode::Mas); - assert_eq!(LanguageCode::from_str("MAY").unwrap(), LanguageCode::May); - assert_eq!(LanguageCode::from_str("MDF").unwrap(), LanguageCode::Mdf); - assert_eq!(LanguageCode::from_str("MDR").unwrap(), LanguageCode::Mdr); - assert_eq!(LanguageCode::from_str("MEN").unwrap(), LanguageCode::Men); - assert_eq!(LanguageCode::from_str("MGA").unwrap(), LanguageCode::Mga); - assert_eq!(LanguageCode::from_str("MIC").unwrap(), LanguageCode::Mic); - assert_eq!(LanguageCode::from_str("MIN").unwrap(), LanguageCode::Min); - assert_eq!(LanguageCode::from_str("MIS").unwrap(), LanguageCode::Mis); - assert_eq!(LanguageCode::from_str("MKH").unwrap(), LanguageCode::Mkh); - assert_eq!(LanguageCode::from_str("MLG").unwrap(), LanguageCode::Mlg); - assert_eq!(LanguageCode::from_str("MLT").unwrap(), LanguageCode::Mlt); - assert_eq!(LanguageCode::from_str("MNC").unwrap(), LanguageCode::Mnc); - assert_eq!(LanguageCode::from_str("MNI").unwrap(), LanguageCode::Mni); - assert_eq!(LanguageCode::from_str("MNO").unwrap(), LanguageCode::Mno); - assert_eq!(LanguageCode::from_str("MOH").unwrap(), LanguageCode::Moh); - assert_eq!(LanguageCode::from_str("MON").unwrap(), LanguageCode::Mon); - assert_eq!(LanguageCode::from_str("MOS").unwrap(), LanguageCode::Mos); - assert_eq!(LanguageCode::from_str("MUL").unwrap(), LanguageCode::Mul); - assert_eq!(LanguageCode::from_str("MUN").unwrap(), LanguageCode::Mun); - assert_eq!(LanguageCode::from_str("MUS").unwrap(), LanguageCode::Mus); - assert_eq!(LanguageCode::from_str("MWL").unwrap(), LanguageCode::Mwl); - assert_eq!(LanguageCode::from_str("MWR").unwrap(), LanguageCode::Mwr); - assert_eq!(LanguageCode::from_str("MYN").unwrap(), LanguageCode::Myn); - assert_eq!(LanguageCode::from_str("MYV").unwrap(), LanguageCode::Myv); - assert_eq!(LanguageCode::from_str("NAH").unwrap(), LanguageCode::Nah); - assert_eq!(LanguageCode::from_str("NAI").unwrap(), LanguageCode::Nai); - assert_eq!(LanguageCode::from_str("NAP").unwrap(), LanguageCode::Nap); - assert_eq!(LanguageCode::from_str("NAU").unwrap(), LanguageCode::Nau); - assert_eq!(LanguageCode::from_str("NAV").unwrap(), LanguageCode::Nav); - assert_eq!(LanguageCode::from_str("NBL").unwrap(), LanguageCode::Nbl); - assert_eq!(LanguageCode::from_str("NDE").unwrap(), LanguageCode::Nde); - assert_eq!(LanguageCode::from_str("NDO").unwrap(), LanguageCode::Ndo); - assert_eq!(LanguageCode::from_str("NDS").unwrap(), LanguageCode::Nds); - assert_eq!(LanguageCode::from_str("NEP").unwrap(), LanguageCode::Nep); - assert_eq!(LanguageCode::from_str("NEW").unwrap(), LanguageCode::New); - assert_eq!(LanguageCode::from_str("NIA").unwrap(), LanguageCode::Nia); - assert_eq!(LanguageCode::from_str("NIC").unwrap(), LanguageCode::Nic); - assert_eq!(LanguageCode::from_str("NIU").unwrap(), LanguageCode::Niu); - assert_eq!(LanguageCode::from_str("NNO").unwrap(), LanguageCode::Nno); - assert_eq!(LanguageCode::from_str("NOB").unwrap(), LanguageCode::Nob); - assert_eq!(LanguageCode::from_str("NOG").unwrap(), LanguageCode::Nog); - assert_eq!(LanguageCode::from_str("NON").unwrap(), LanguageCode::Non); - assert_eq!(LanguageCode::from_str("NOR").unwrap(), LanguageCode::Nor); - assert_eq!(LanguageCode::from_str("NQO").unwrap(), LanguageCode::Nqo); - assert_eq!(LanguageCode::from_str("NSO").unwrap(), LanguageCode::Nso); - assert_eq!(LanguageCode::from_str("NUB").unwrap(), LanguageCode::Nub); - assert_eq!(LanguageCode::from_str("NWC").unwrap(), LanguageCode::Nwc); - assert_eq!(LanguageCode::from_str("NYA").unwrap(), LanguageCode::Nya); - assert_eq!(LanguageCode::from_str("NYM").unwrap(), LanguageCode::Nym); - assert_eq!(LanguageCode::from_str("NYN").unwrap(), LanguageCode::Nyn); - assert_eq!(LanguageCode::from_str("NYO").unwrap(), LanguageCode::Nyo); - assert_eq!(LanguageCode::from_str("NZI").unwrap(), LanguageCode::Nzi); - assert_eq!(LanguageCode::from_str("OCI").unwrap(), LanguageCode::Oci); - assert_eq!(LanguageCode::from_str("OJI").unwrap(), LanguageCode::Oji); - assert_eq!(LanguageCode::from_str("ORI").unwrap(), LanguageCode::Ori); - assert_eq!(LanguageCode::from_str("ORM").unwrap(), LanguageCode::Orm); - assert_eq!(LanguageCode::from_str("OSA").unwrap(), LanguageCode::Osa); - assert_eq!(LanguageCode::from_str("OSS").unwrap(), LanguageCode::Oss); - assert_eq!(LanguageCode::from_str("OTA").unwrap(), LanguageCode::Ota); - assert_eq!(LanguageCode::from_str("OTO").unwrap(), LanguageCode::Oto); - assert_eq!(LanguageCode::from_str("PAA").unwrap(), LanguageCode::Paa); - assert_eq!(LanguageCode::from_str("PAG").unwrap(), LanguageCode::Pag); - assert_eq!(LanguageCode::from_str("PAL").unwrap(), LanguageCode::Pal); - assert_eq!(LanguageCode::from_str("PAM").unwrap(), LanguageCode::Pam); - assert_eq!(LanguageCode::from_str("PAN").unwrap(), LanguageCode::Pan); - assert_eq!(LanguageCode::from_str("PAP").unwrap(), LanguageCode::Pap); - assert_eq!(LanguageCode::from_str("PAU").unwrap(), LanguageCode::Pau); - assert_eq!(LanguageCode::from_str("PEO").unwrap(), LanguageCode::Peo); - assert_eq!(LanguageCode::from_str("PER").unwrap(), LanguageCode::Per); - assert_eq!(LanguageCode::from_str("PHI").unwrap(), LanguageCode::Phi); - assert_eq!(LanguageCode::from_str("PHN").unwrap(), LanguageCode::Phn); - assert_eq!(LanguageCode::from_str("PLI").unwrap(), LanguageCode::Pli); - assert_eq!(LanguageCode::from_str("POL").unwrap(), LanguageCode::Pol); - assert_eq!(LanguageCode::from_str("PON").unwrap(), LanguageCode::Pon); - assert_eq!(LanguageCode::from_str("POR").unwrap(), LanguageCode::Por); - assert_eq!(LanguageCode::from_str("PRA").unwrap(), LanguageCode::Pra); - assert_eq!(LanguageCode::from_str("PRO").unwrap(), LanguageCode::Pro); - assert_eq!(LanguageCode::from_str("PUS").unwrap(), LanguageCode::Pus); - assert_eq!(LanguageCode::from_str("QAA").unwrap(), LanguageCode::Qaa); - assert_eq!(LanguageCode::from_str("QUE").unwrap(), LanguageCode::Que); - assert_eq!(LanguageCode::from_str("RAJ").unwrap(), LanguageCode::Raj); - assert_eq!(LanguageCode::from_str("RAP").unwrap(), LanguageCode::Rap); - assert_eq!(LanguageCode::from_str("RAR").unwrap(), LanguageCode::Rar); - assert_eq!(LanguageCode::from_str("ROA").unwrap(), LanguageCode::Roa); - assert_eq!(LanguageCode::from_str("ROH").unwrap(), LanguageCode::Roh); - assert_eq!(LanguageCode::from_str("ROM").unwrap(), LanguageCode::Rom); - assert_eq!(LanguageCode::from_str("RUM").unwrap(), LanguageCode::Rum); - assert_eq!(LanguageCode::from_str("RUN").unwrap(), LanguageCode::Run); - assert_eq!(LanguageCode::from_str("RUP").unwrap(), LanguageCode::Rup); - assert_eq!(LanguageCode::from_str("RUS").unwrap(), LanguageCode::Rus); - assert_eq!(LanguageCode::from_str("SAD").unwrap(), LanguageCode::Sad); - assert_eq!(LanguageCode::from_str("SAG").unwrap(), LanguageCode::Sag); - assert_eq!(LanguageCode::from_str("SAH").unwrap(), LanguageCode::Sah); - assert_eq!(LanguageCode::from_str("SAI").unwrap(), LanguageCode::Sai); - assert_eq!(LanguageCode::from_str("SAL").unwrap(), LanguageCode::Sal); - assert_eq!(LanguageCode::from_str("SAM").unwrap(), LanguageCode::Sam); - assert_eq!(LanguageCode::from_str("SAN").unwrap(), LanguageCode::San); - assert_eq!(LanguageCode::from_str("SAS").unwrap(), LanguageCode::Sas); - assert_eq!(LanguageCode::from_str("SAT").unwrap(), LanguageCode::Sat); - assert_eq!(LanguageCode::from_str("SCN").unwrap(), LanguageCode::Scn); - assert_eq!(LanguageCode::from_str("SCO").unwrap(), LanguageCode::Sco); - assert_eq!(LanguageCode::from_str("SEL").unwrap(), LanguageCode::Sel); - assert_eq!(LanguageCode::from_str("SEM").unwrap(), LanguageCode::Sem); - assert_eq!(LanguageCode::from_str("SGA").unwrap(), LanguageCode::Sga); - assert_eq!(LanguageCode::from_str("SGN").unwrap(), LanguageCode::Sgn); - assert_eq!(LanguageCode::from_str("SHN").unwrap(), LanguageCode::Shn); - assert_eq!(LanguageCode::from_str("SID").unwrap(), LanguageCode::Sid); - assert_eq!(LanguageCode::from_str("SIN").unwrap(), LanguageCode::Sin); - assert_eq!(LanguageCode::from_str("SIO").unwrap(), LanguageCode::Sio); - assert_eq!(LanguageCode::from_str("SIT").unwrap(), LanguageCode::Sit); - assert_eq!(LanguageCode::from_str("SLA").unwrap(), LanguageCode::Sla); - assert_eq!(LanguageCode::from_str("SLO").unwrap(), LanguageCode::Slo); - assert_eq!(LanguageCode::from_str("SLV").unwrap(), LanguageCode::Slv); - assert_eq!(LanguageCode::from_str("SMA").unwrap(), LanguageCode::Sma); - assert_eq!(LanguageCode::from_str("SME").unwrap(), LanguageCode::Sme); - assert_eq!(LanguageCode::from_str("SMI").unwrap(), LanguageCode::Smi); - assert_eq!(LanguageCode::from_str("SMJ").unwrap(), LanguageCode::Smj); - assert_eq!(LanguageCode::from_str("SMN").unwrap(), LanguageCode::Smn); - assert_eq!(LanguageCode::from_str("SMO").unwrap(), LanguageCode::Smo); - assert_eq!(LanguageCode::from_str("SMS").unwrap(), LanguageCode::Sms); - assert_eq!(LanguageCode::from_str("SNA").unwrap(), LanguageCode::Sna); - assert_eq!(LanguageCode::from_str("SND").unwrap(), LanguageCode::Snd); - assert_eq!(LanguageCode::from_str("SNK").unwrap(), LanguageCode::Snk); - assert_eq!(LanguageCode::from_str("SOG").unwrap(), LanguageCode::Sog); - assert_eq!(LanguageCode::from_str("SOM").unwrap(), LanguageCode::Som); - assert_eq!(LanguageCode::from_str("SON").unwrap(), LanguageCode::Son); - assert_eq!(LanguageCode::from_str("SOT").unwrap(), LanguageCode::Sot); - assert_eq!(LanguageCode::from_str("SPA").unwrap(), LanguageCode::Spa); - assert_eq!(LanguageCode::from_str("SRD").unwrap(), LanguageCode::Srd); - assert_eq!(LanguageCode::from_str("SRN").unwrap(), LanguageCode::Srn); - assert_eq!(LanguageCode::from_str("SRP").unwrap(), LanguageCode::Srp); - assert_eq!(LanguageCode::from_str("SRR").unwrap(), LanguageCode::Srr); - assert_eq!(LanguageCode::from_str("SSA").unwrap(), LanguageCode::Ssa); - assert_eq!(LanguageCode::from_str("SSW").unwrap(), LanguageCode::Ssw); - assert_eq!(LanguageCode::from_str("SUK").unwrap(), LanguageCode::Suk); - assert_eq!(LanguageCode::from_str("SUN").unwrap(), LanguageCode::Sun); - assert_eq!(LanguageCode::from_str("SUS").unwrap(), LanguageCode::Sus); - assert_eq!(LanguageCode::from_str("SUX").unwrap(), LanguageCode::Sux); - assert_eq!(LanguageCode::from_str("SWA").unwrap(), LanguageCode::Swa); - assert_eq!(LanguageCode::from_str("SWE").unwrap(), LanguageCode::Swe); - assert_eq!(LanguageCode::from_str("SYC").unwrap(), LanguageCode::Syc); - assert_eq!(LanguageCode::from_str("SYR").unwrap(), LanguageCode::Syr); - assert_eq!(LanguageCode::from_str("TAH").unwrap(), LanguageCode::Tah); - assert_eq!(LanguageCode::from_str("TAI").unwrap(), LanguageCode::Tai); - assert_eq!(LanguageCode::from_str("TAM").unwrap(), LanguageCode::Tam); - assert_eq!(LanguageCode::from_str("TAT").unwrap(), LanguageCode::Tat); - assert_eq!(LanguageCode::from_str("TEL").unwrap(), LanguageCode::Tel); - assert_eq!(LanguageCode::from_str("TEM").unwrap(), LanguageCode::Tem); - assert_eq!(LanguageCode::from_str("TER").unwrap(), LanguageCode::Ter); - assert_eq!(LanguageCode::from_str("TET").unwrap(), LanguageCode::Tet); - assert_eq!(LanguageCode::from_str("TGK").unwrap(), LanguageCode::Tgk); - assert_eq!(LanguageCode::from_str("TGL").unwrap(), LanguageCode::Tgl); - assert_eq!(LanguageCode::from_str("THA").unwrap(), LanguageCode::Tha); - assert_eq!(LanguageCode::from_str("TIB").unwrap(), LanguageCode::Tib); - assert_eq!(LanguageCode::from_str("TIG").unwrap(), LanguageCode::Tig); - assert_eq!(LanguageCode::from_str("TIR").unwrap(), LanguageCode::Tir); - assert_eq!(LanguageCode::from_str("TIV").unwrap(), LanguageCode::Tiv); - assert_eq!(LanguageCode::from_str("TKL").unwrap(), LanguageCode::Tkl); - assert_eq!(LanguageCode::from_str("TLH").unwrap(), LanguageCode::Tlh); - assert_eq!(LanguageCode::from_str("TLI").unwrap(), LanguageCode::Tli); - assert_eq!(LanguageCode::from_str("TMH").unwrap(), LanguageCode::Tmh); - assert_eq!(LanguageCode::from_str("TOG").unwrap(), LanguageCode::Tog); - assert_eq!(LanguageCode::from_str("TON").unwrap(), LanguageCode::Ton); - assert_eq!(LanguageCode::from_str("TPI").unwrap(), LanguageCode::Tpi); - assert_eq!(LanguageCode::from_str("TSI").unwrap(), LanguageCode::Tsi); - assert_eq!(LanguageCode::from_str("TSN").unwrap(), LanguageCode::Tsn); - assert_eq!(LanguageCode::from_str("TSO").unwrap(), LanguageCode::Tso); - assert_eq!(LanguageCode::from_str("TUK").unwrap(), LanguageCode::Tuk); - assert_eq!(LanguageCode::from_str("TUM").unwrap(), LanguageCode::Tum); - assert_eq!(LanguageCode::from_str("TUP").unwrap(), LanguageCode::Tup); - assert_eq!(LanguageCode::from_str("TUR").unwrap(), LanguageCode::Tur); - assert_eq!(LanguageCode::from_str("TUT").unwrap(), LanguageCode::Tut); - assert_eq!(LanguageCode::from_str("TVL").unwrap(), LanguageCode::Tvl); - assert_eq!(LanguageCode::from_str("TWI").unwrap(), LanguageCode::Twi); - assert_eq!(LanguageCode::from_str("TYV").unwrap(), LanguageCode::Tyv); - assert_eq!(LanguageCode::from_str("UDM").unwrap(), LanguageCode::Udm); - assert_eq!(LanguageCode::from_str("UGA").unwrap(), LanguageCode::Uga); - assert_eq!(LanguageCode::from_str("UIG").unwrap(), LanguageCode::Uig); - assert_eq!(LanguageCode::from_str("UKR").unwrap(), LanguageCode::Ukr); - assert_eq!(LanguageCode::from_str("UMB").unwrap(), LanguageCode::Umb); - assert_eq!(LanguageCode::from_str("UND").unwrap(), LanguageCode::Und); - assert_eq!(LanguageCode::from_str("URD").unwrap(), LanguageCode::Urd); - assert_eq!(LanguageCode::from_str("UZB").unwrap(), LanguageCode::Uzb); - assert_eq!(LanguageCode::from_str("VAI").unwrap(), LanguageCode::Vai); - assert_eq!(LanguageCode::from_str("VEN").unwrap(), LanguageCode::Ven); - assert_eq!(LanguageCode::from_str("VIE").unwrap(), LanguageCode::Vie); - assert_eq!(LanguageCode::from_str("VOL").unwrap(), LanguageCode::Vol); - assert_eq!(LanguageCode::from_str("VOT").unwrap(), LanguageCode::Vot); - assert_eq!(LanguageCode::from_str("WAK").unwrap(), LanguageCode::Wak); - assert_eq!(LanguageCode::from_str("WAL").unwrap(), LanguageCode::Wal); - assert_eq!(LanguageCode::from_str("WAR").unwrap(), LanguageCode::War); - assert_eq!(LanguageCode::from_str("WAS").unwrap(), LanguageCode::Was); - assert_eq!(LanguageCode::from_str("WEL").unwrap(), LanguageCode::Wel); - assert_eq!(LanguageCode::from_str("WEN").unwrap(), LanguageCode::Wen); - assert_eq!(LanguageCode::from_str("WLN").unwrap(), LanguageCode::Wln); - assert_eq!(LanguageCode::from_str("WOL").unwrap(), LanguageCode::Wol); - assert_eq!(LanguageCode::from_str("XAL").unwrap(), LanguageCode::Xal); - assert_eq!(LanguageCode::from_str("XHO").unwrap(), LanguageCode::Xho); - assert_eq!(LanguageCode::from_str("YAO").unwrap(), LanguageCode::Yao); - assert_eq!(LanguageCode::from_str("YAP").unwrap(), LanguageCode::Yap); - assert_eq!(LanguageCode::from_str("YID").unwrap(), LanguageCode::Yid); - assert_eq!(LanguageCode::from_str("YOR").unwrap(), LanguageCode::Yor); - assert_eq!(LanguageCode::from_str("YPK").unwrap(), LanguageCode::Ypk); - assert_eq!(LanguageCode::from_str("ZAP").unwrap(), LanguageCode::Zap); - assert_eq!(LanguageCode::from_str("ZBL").unwrap(), LanguageCode::Zbl); - assert_eq!(LanguageCode::from_str("ZEN").unwrap(), LanguageCode::Zen); - assert_eq!(LanguageCode::from_str("ZGH").unwrap(), LanguageCode::Zgh); - assert_eq!(LanguageCode::from_str("ZHA").unwrap(), LanguageCode::Zha); - assert_eq!(LanguageCode::from_str("ZND").unwrap(), LanguageCode::Znd); - assert_eq!(LanguageCode::from_str("ZUL").unwrap(), LanguageCode::Zul); - assert_eq!(LanguageCode::from_str("ZUN").unwrap(), LanguageCode::Zun); - assert_eq!(LanguageCode::from_str("ZXX").unwrap(), LanguageCode::Zxx); - assert_eq!(LanguageCode::from_str("ZZA").unwrap(), LanguageCode::Zza); - - assert!(LanguageRelation::from_str("ESP").is_err()); - assert!(LanguageRelation::from_str("ZZZ").is_err()); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::LanguagePolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/language/policy.rs b/thoth-api/src/model/language/policy.rs new file mode 100644 index 00000000..1b481681 --- /dev/null +++ b/thoth-api/src/model/language/policy.rs @@ -0,0 +1,37 @@ +use crate::model::language::{Language, NewLanguage, PatchLanguage}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Language`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct LanguagePolicy; + +impl CreatePolicy<NewLanguage> for LanguagePolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewLanguage, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Language, PatchLanguage> for LanguagePolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Language, + patch: &PatchLanguage, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + Ok(()) + } +} + +impl DeletePolicy<Language> for LanguagePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Language) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/language/tests.rs b/thoth-api/src/model/language/tests.rs new file mode 100644 index 00000000..24135edb --- /dev/null +++ b/thoth-api/src/model/language/tests.rs @@ -0,0 +1,1704 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_language( + pool: &crate::db::PgPool, + work_id: Uuid, + language_code: LanguageCode, + language_relation: LanguageRelation, +) -> Language { + let new_language = NewLanguage { + work_id, + language_code, + language_relation, + }; + + Language::create(pool, &new_language).expect("Failed to create language") +} + +mod defaults { + use super::*; + + #[test] + fn languagecode_default_is_eng() { + let langcode: LanguageCode = Default::default(); + assert_eq!(langcode, LanguageCode::Eng); + } + + #[test] + fn languagerelation_default_is_original() { + let langrelation: LanguageRelation = Default::default(); + assert_eq!(langrelation, LanguageRelation::Original); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn languagerelation_display_formats_expected_strings() { + assert_eq!(format!("{}", LanguageRelation::Original), "Original"); + assert_eq!( + format!("{}", LanguageRelation::TranslatedFrom), + "Translated From" + ); + assert_eq!( + format!("{}", LanguageRelation::TranslatedInto), + "Translated Into" + ); + } + + #[test] + fn languagecode_display_formats_expected_strings() { + assert_eq!(format!("{}", LanguageCode::Aar), "AAR"); + assert_eq!(format!("{}", LanguageCode::Abk), "ABK"); + assert_eq!(format!("{}", LanguageCode::Ace), "ACE"); + assert_eq!(format!("{}", LanguageCode::Ach), "ACH"); + assert_eq!(format!("{}", LanguageCode::Ada), "ADA"); + assert_eq!(format!("{}", LanguageCode::Ady), "ADY"); + assert_eq!(format!("{}", LanguageCode::Afa), "AFA"); + assert_eq!(format!("{}", LanguageCode::Afh), "AFH"); + assert_eq!(format!("{}", LanguageCode::Afr), "AFR"); + assert_eq!(format!("{}", LanguageCode::Ain), "AIN"); + assert_eq!(format!("{}", LanguageCode::Aka), "AKA"); + assert_eq!(format!("{}", LanguageCode::Akk), "AKK"); + assert_eq!(format!("{}", LanguageCode::Alb), "ALB"); + assert_eq!(format!("{}", LanguageCode::Ale), "ALE"); + assert_eq!(format!("{}", LanguageCode::Alg), "ALG"); + assert_eq!(format!("{}", LanguageCode::Alt), "ALT"); + assert_eq!(format!("{}", LanguageCode::Amh), "AMH"); + assert_eq!(format!("{}", LanguageCode::Ang), "ANG"); + assert_eq!(format!("{}", LanguageCode::Anp), "ANP"); + assert_eq!(format!("{}", LanguageCode::Apa), "APA"); + assert_eq!(format!("{}", LanguageCode::Ara), "ARA"); + assert_eq!(format!("{}", LanguageCode::Arc), "ARC"); + assert_eq!(format!("{}", LanguageCode::Arg), "ARG"); + assert_eq!(format!("{}", LanguageCode::Arm), "ARM"); + assert_eq!(format!("{}", LanguageCode::Arn), "ARN"); + assert_eq!(format!("{}", LanguageCode::Arp), "ARP"); + assert_eq!(format!("{}", LanguageCode::Art), "ART"); + assert_eq!(format!("{}", LanguageCode::Arw), "ARW"); + assert_eq!(format!("{}", LanguageCode::Asm), "ASM"); + assert_eq!(format!("{}", LanguageCode::Ast), "AST"); + assert_eq!(format!("{}", LanguageCode::Ath), "ATH"); + assert_eq!(format!("{}", LanguageCode::Aus), "AUS"); + assert_eq!(format!("{}", LanguageCode::Ava), "AVA"); + assert_eq!(format!("{}", LanguageCode::Ave), "AVE"); + assert_eq!(format!("{}", LanguageCode::Awa), "AWA"); + assert_eq!(format!("{}", LanguageCode::Aym), "AYM"); + assert_eq!(format!("{}", LanguageCode::Aze), "AZE"); + assert_eq!(format!("{}", LanguageCode::Bad), "BAD"); + assert_eq!(format!("{}", LanguageCode::Bai), "BAI"); + assert_eq!(format!("{}", LanguageCode::Bak), "BAK"); + assert_eq!(format!("{}", LanguageCode::Bal), "BAL"); + assert_eq!(format!("{}", LanguageCode::Bam), "BAM"); + assert_eq!(format!("{}", LanguageCode::Ban), "BAN"); + assert_eq!(format!("{}", LanguageCode::Baq), "BAQ"); + assert_eq!(format!("{}", LanguageCode::Bas), "BAS"); + assert_eq!(format!("{}", LanguageCode::Bat), "BAT"); + assert_eq!(format!("{}", LanguageCode::Bej), "BEJ"); + assert_eq!(format!("{}", LanguageCode::Bel), "BEL"); + assert_eq!(format!("{}", LanguageCode::Bem), "BEM"); + assert_eq!(format!("{}", LanguageCode::Ben), "BEN"); + assert_eq!(format!("{}", LanguageCode::Ber), "BER"); + assert_eq!(format!("{}", LanguageCode::Bho), "BHO"); + assert_eq!(format!("{}", LanguageCode::Bih), "BIH"); + assert_eq!(format!("{}", LanguageCode::Bik), "BIK"); + assert_eq!(format!("{}", LanguageCode::Bin), "BIN"); + assert_eq!(format!("{}", LanguageCode::Bis), "BIS"); + assert_eq!(format!("{}", LanguageCode::Bla), "BLA"); + assert_eq!(format!("{}", LanguageCode::Bnt), "BNT"); + assert_eq!(format!("{}", LanguageCode::Bos), "BOS"); + assert_eq!(format!("{}", LanguageCode::Bra), "BRA"); + assert_eq!(format!("{}", LanguageCode::Bre), "BRE"); + assert_eq!(format!("{}", LanguageCode::Btk), "BTK"); + assert_eq!(format!("{}", LanguageCode::Bua), "BUA"); + assert_eq!(format!("{}", LanguageCode::Bug), "BUG"); + assert_eq!(format!("{}", LanguageCode::Bul), "BUL"); + assert_eq!(format!("{}", LanguageCode::Bur), "BUR"); + assert_eq!(format!("{}", LanguageCode::Byn), "BYN"); + assert_eq!(format!("{}", LanguageCode::Cad), "CAD"); + assert_eq!(format!("{}", LanguageCode::Cai), "CAI"); + assert_eq!(format!("{}", LanguageCode::Car), "CAR"); + assert_eq!(format!("{}", LanguageCode::Cat), "CAT"); + assert_eq!(format!("{}", LanguageCode::Cau), "CAU"); + assert_eq!(format!("{}", LanguageCode::Ceb), "CEB"); + assert_eq!(format!("{}", LanguageCode::Cel), "CEL"); + assert_eq!(format!("{}", LanguageCode::Cha), "CHA"); + assert_eq!(format!("{}", LanguageCode::Chb), "CHB"); + assert_eq!(format!("{}", LanguageCode::Che), "CHE"); + assert_eq!(format!("{}", LanguageCode::Chg), "CHG"); + assert_eq!(format!("{}", LanguageCode::Chi), "CHI"); + assert_eq!(format!("{}", LanguageCode::Chk), "CHK"); + assert_eq!(format!("{}", LanguageCode::Chm), "CHM"); + assert_eq!(format!("{}", LanguageCode::Chn), "CHN"); + assert_eq!(format!("{}", LanguageCode::Cho), "CHO"); + assert_eq!(format!("{}", LanguageCode::Chp), "CHP"); + assert_eq!(format!("{}", LanguageCode::Chr), "CHR"); + assert_eq!(format!("{}", LanguageCode::Chu), "CHU"); + assert_eq!(format!("{}", LanguageCode::Chv), "CHV"); + assert_eq!(format!("{}", LanguageCode::Chy), "CHY"); + assert_eq!(format!("{}", LanguageCode::Cmc), "CMC"); + assert_eq!(format!("{}", LanguageCode::Cnr), "CNR"); + assert_eq!(format!("{}", LanguageCode::Cop), "COP"); + assert_eq!(format!("{}", LanguageCode::Cor), "COR"); + assert_eq!(format!("{}", LanguageCode::Cos), "COS"); + assert_eq!(format!("{}", LanguageCode::Cpe), "CPE"); + assert_eq!(format!("{}", LanguageCode::Cpf), "CPF"); + assert_eq!(format!("{}", LanguageCode::Cpp), "CPP"); + assert_eq!(format!("{}", LanguageCode::Cre), "CRE"); + assert_eq!(format!("{}", LanguageCode::Crh), "CRH"); + assert_eq!(format!("{}", LanguageCode::Crp), "CRP"); + assert_eq!(format!("{}", LanguageCode::Csb), "CSB"); + assert_eq!(format!("{}", LanguageCode::Cus), "CUS"); + assert_eq!(format!("{}", LanguageCode::Cze), "CZE"); + assert_eq!(format!("{}", LanguageCode::Dak), "DAK"); + assert_eq!(format!("{}", LanguageCode::Dan), "DAN"); + assert_eq!(format!("{}", LanguageCode::Dar), "DAR"); + assert_eq!(format!("{}", LanguageCode::Day), "DAY"); + assert_eq!(format!("{}", LanguageCode::Del), "DEL"); + assert_eq!(format!("{}", LanguageCode::Den), "DEN"); + assert_eq!(format!("{}", LanguageCode::Dgr), "DGR"); + assert_eq!(format!("{}", LanguageCode::Din), "DIN"); + assert_eq!(format!("{}", LanguageCode::Div), "DIV"); + assert_eq!(format!("{}", LanguageCode::Doi), "DOI"); + assert_eq!(format!("{}", LanguageCode::Dra), "DRA"); + assert_eq!(format!("{}", LanguageCode::Dsb), "DSB"); + assert_eq!(format!("{}", LanguageCode::Dua), "DUA"); + assert_eq!(format!("{}", LanguageCode::Dum), "DUM"); + assert_eq!(format!("{}", LanguageCode::Dut), "DUT"); + assert_eq!(format!("{}", LanguageCode::Dyu), "DYU"); + assert_eq!(format!("{}", LanguageCode::Dzo), "DZO"); + assert_eq!(format!("{}", LanguageCode::Efi), "EFI"); + assert_eq!(format!("{}", LanguageCode::Egy), "EGY"); + assert_eq!(format!("{}", LanguageCode::Eka), "EKA"); + assert_eq!(format!("{}", LanguageCode::Elx), "ELX"); + assert_eq!(format!("{}", LanguageCode::Eng), "ENG"); + assert_eq!(format!("{}", LanguageCode::Enm), "ENM"); + assert_eq!(format!("{}", LanguageCode::Epo), "EPO"); + assert_eq!(format!("{}", LanguageCode::Est), "EST"); + assert_eq!(format!("{}", LanguageCode::Ewe), "EWE"); + assert_eq!(format!("{}", LanguageCode::Ewo), "EWO"); + assert_eq!(format!("{}", LanguageCode::Fan), "FAN"); + assert_eq!(format!("{}", LanguageCode::Fao), "FAO"); + assert_eq!(format!("{}", LanguageCode::Fat), "FAT"); + assert_eq!(format!("{}", LanguageCode::Fij), "FIJ"); + assert_eq!(format!("{}", LanguageCode::Fil), "FIL"); + assert_eq!(format!("{}", LanguageCode::Fin), "FIN"); + assert_eq!(format!("{}", LanguageCode::Fiu), "FIU"); + assert_eq!(format!("{}", LanguageCode::Fon), "FON"); + assert_eq!(format!("{}", LanguageCode::Fre), "FRE"); + assert_eq!(format!("{}", LanguageCode::Frm), "FRM"); + assert_eq!(format!("{}", LanguageCode::Fro), "FRO"); + assert_eq!(format!("{}", LanguageCode::Frr), "FRR"); + assert_eq!(format!("{}", LanguageCode::Frs), "FRS"); + assert_eq!(format!("{}", LanguageCode::Fry), "FRY"); + assert_eq!(format!("{}", LanguageCode::Ful), "FUL"); + assert_eq!(format!("{}", LanguageCode::Fur), "FUR"); + assert_eq!(format!("{}", LanguageCode::Gaa), "GAA"); + assert_eq!(format!("{}", LanguageCode::Gay), "GAY"); + assert_eq!(format!("{}", LanguageCode::Gba), "GBA"); + assert_eq!(format!("{}", LanguageCode::Gem), "GEM"); + assert_eq!(format!("{}", LanguageCode::Geo), "GEO"); + assert_eq!(format!("{}", LanguageCode::Ger), "GER"); + assert_eq!(format!("{}", LanguageCode::Gez), "GEZ"); + assert_eq!(format!("{}", LanguageCode::Gil), "GIL"); + assert_eq!(format!("{}", LanguageCode::Gla), "GLA"); + assert_eq!(format!("{}", LanguageCode::Gle), "GLE"); + assert_eq!(format!("{}", LanguageCode::Glg), "GLG"); + assert_eq!(format!("{}", LanguageCode::Glv), "GLV"); + assert_eq!(format!("{}", LanguageCode::Gmh), "GMH"); + assert_eq!(format!("{}", LanguageCode::Goh), "GOH"); + assert_eq!(format!("{}", LanguageCode::Gon), "GON"); + assert_eq!(format!("{}", LanguageCode::Gor), "GOR"); + assert_eq!(format!("{}", LanguageCode::Got), "GOT"); + assert_eq!(format!("{}", LanguageCode::Grb), "GRB"); + assert_eq!(format!("{}", LanguageCode::Grc), "GRC"); + assert_eq!(format!("{}", LanguageCode::Gre), "GRE"); + assert_eq!(format!("{}", LanguageCode::Grn), "GRN"); + assert_eq!(format!("{}", LanguageCode::Gsw), "GSW"); + assert_eq!(format!("{}", LanguageCode::Guj), "GUJ"); + assert_eq!(format!("{}", LanguageCode::Gwi), "GWI"); + assert_eq!(format!("{}", LanguageCode::Hai), "HAI"); + assert_eq!(format!("{}", LanguageCode::Hat), "HAT"); + assert_eq!(format!("{}", LanguageCode::Hau), "HAU"); + assert_eq!(format!("{}", LanguageCode::Haw), "HAW"); + assert_eq!(format!("{}", LanguageCode::Heb), "HEB"); + assert_eq!(format!("{}", LanguageCode::Her), "HER"); + assert_eq!(format!("{}", LanguageCode::Hil), "HIL"); + assert_eq!(format!("{}", LanguageCode::Him), "HIM"); + assert_eq!(format!("{}", LanguageCode::Hin), "HIN"); + assert_eq!(format!("{}", LanguageCode::Hit), "HIT"); + assert_eq!(format!("{}", LanguageCode::Hmn), "HMN"); + assert_eq!(format!("{}", LanguageCode::Hmo), "HMO"); + assert_eq!(format!("{}", LanguageCode::Hrv), "HRV"); + assert_eq!(format!("{}", LanguageCode::Hsb), "HSB"); + assert_eq!(format!("{}", LanguageCode::Hun), "HUN"); + assert_eq!(format!("{}", LanguageCode::Hup), "HUP"); + assert_eq!(format!("{}", LanguageCode::Iba), "IBA"); + assert_eq!(format!("{}", LanguageCode::Ibo), "IBO"); + assert_eq!(format!("{}", LanguageCode::Ice), "ICE"); + assert_eq!(format!("{}", LanguageCode::Ido), "IDO"); + assert_eq!(format!("{}", LanguageCode::Iii), "III"); + assert_eq!(format!("{}", LanguageCode::Ijo), "IJO"); + assert_eq!(format!("{}", LanguageCode::Iku), "IKU"); + assert_eq!(format!("{}", LanguageCode::Ile), "ILE"); + assert_eq!(format!("{}", LanguageCode::Ilo), "ILO"); + assert_eq!(format!("{}", LanguageCode::Ina), "INA"); + assert_eq!(format!("{}", LanguageCode::Inc), "INC"); + assert_eq!(format!("{}", LanguageCode::Ind), "IND"); + assert_eq!(format!("{}", LanguageCode::Ine), "INE"); + assert_eq!(format!("{}", LanguageCode::Inh), "INH"); + assert_eq!(format!("{}", LanguageCode::Ipk), "IPK"); + assert_eq!(format!("{}", LanguageCode::Ira), "IRA"); + assert_eq!(format!("{}", LanguageCode::Iro), "IRO"); + assert_eq!(format!("{}", LanguageCode::Ita), "ITA"); + assert_eq!(format!("{}", LanguageCode::Jav), "JAV"); + assert_eq!(format!("{}", LanguageCode::Jbo), "JBO"); + assert_eq!(format!("{}", LanguageCode::Jpn), "JPN"); + assert_eq!(format!("{}", LanguageCode::Jpr), "JPR"); + assert_eq!(format!("{}", LanguageCode::Jrb), "JRB"); + assert_eq!(format!("{}", LanguageCode::Kaa), "KAA"); + assert_eq!(format!("{}", LanguageCode::Kab), "KAB"); + assert_eq!(format!("{}", LanguageCode::Kac), "KAC"); + assert_eq!(format!("{}", LanguageCode::Kal), "KAL"); + assert_eq!(format!("{}", LanguageCode::Kam), "KAM"); + assert_eq!(format!("{}", LanguageCode::Kan), "KAN"); + assert_eq!(format!("{}", LanguageCode::Kar), "KAR"); + assert_eq!(format!("{}", LanguageCode::Kas), "KAS"); + assert_eq!(format!("{}", LanguageCode::Kau), "KAU"); + assert_eq!(format!("{}", LanguageCode::Kaw), "KAW"); + assert_eq!(format!("{}", LanguageCode::Kaz), "KAZ"); + assert_eq!(format!("{}", LanguageCode::Kbd), "KBD"); + assert_eq!(format!("{}", LanguageCode::Kha), "KHA"); + assert_eq!(format!("{}", LanguageCode::Khi), "KHI"); + assert_eq!(format!("{}", LanguageCode::Khm), "KHM"); + assert_eq!(format!("{}", LanguageCode::Kho), "KHO"); + assert_eq!(format!("{}", LanguageCode::Kik), "KIK"); + assert_eq!(format!("{}", LanguageCode::Kin), "KIN"); + assert_eq!(format!("{}", LanguageCode::Kir), "KIR"); + assert_eq!(format!("{}", LanguageCode::Kmb), "KMB"); + assert_eq!(format!("{}", LanguageCode::Kok), "KOK"); + assert_eq!(format!("{}", LanguageCode::Kom), "KOM"); + assert_eq!(format!("{}", LanguageCode::Kon), "KON"); + assert_eq!(format!("{}", LanguageCode::Kor), "KOR"); + assert_eq!(format!("{}", LanguageCode::Kos), "KOS"); + assert_eq!(format!("{}", LanguageCode::Kpe), "KPE"); + assert_eq!(format!("{}", LanguageCode::Krc), "KRC"); + assert_eq!(format!("{}", LanguageCode::Krl), "KRL"); + assert_eq!(format!("{}", LanguageCode::Kro), "KRO"); + assert_eq!(format!("{}", LanguageCode::Kru), "KRU"); + assert_eq!(format!("{}", LanguageCode::Kua), "KUA"); + assert_eq!(format!("{}", LanguageCode::Kum), "KUM"); + assert_eq!(format!("{}", LanguageCode::Kur), "KUR"); + assert_eq!(format!("{}", LanguageCode::Kut), "KUT"); + assert_eq!(format!("{}", LanguageCode::Lad), "LAD"); + assert_eq!(format!("{}", LanguageCode::Lah), "LAH"); + assert_eq!(format!("{}", LanguageCode::Lam), "LAM"); + assert_eq!(format!("{}", LanguageCode::Lao), "LAO"); + assert_eq!(format!("{}", LanguageCode::Lat), "LAT"); + assert_eq!(format!("{}", LanguageCode::Lav), "LAV"); + assert_eq!(format!("{}", LanguageCode::Lez), "LEZ"); + assert_eq!(format!("{}", LanguageCode::Lim), "LIM"); + assert_eq!(format!("{}", LanguageCode::Lin), "LIN"); + assert_eq!(format!("{}", LanguageCode::Lit), "LIT"); + assert_eq!(format!("{}", LanguageCode::Lol), "LOL"); + assert_eq!(format!("{}", LanguageCode::Loz), "LOZ"); + assert_eq!(format!("{}", LanguageCode::Ltz), "LTZ"); + assert_eq!(format!("{}", LanguageCode::Lua), "LUA"); + assert_eq!(format!("{}", LanguageCode::Lub), "LUB"); + assert_eq!(format!("{}", LanguageCode::Lug), "LUG"); + assert_eq!(format!("{}", LanguageCode::Lui), "LUI"); + assert_eq!(format!("{}", LanguageCode::Lun), "LUN"); + assert_eq!(format!("{}", LanguageCode::Luo), "LUO"); + assert_eq!(format!("{}", LanguageCode::Lus), "LUS"); + assert_eq!(format!("{}", LanguageCode::Mac), "MAC"); + assert_eq!(format!("{}", LanguageCode::Mad), "MAD"); + assert_eq!(format!("{}", LanguageCode::Mag), "MAG"); + assert_eq!(format!("{}", LanguageCode::Mah), "MAH"); + assert_eq!(format!("{}", LanguageCode::Mai), "MAI"); + assert_eq!(format!("{}", LanguageCode::Mak), "MAK"); + assert_eq!(format!("{}", LanguageCode::Mal), "MAL"); + assert_eq!(format!("{}", LanguageCode::Man), "MAN"); + assert_eq!(format!("{}", LanguageCode::Mao), "MAO"); + assert_eq!(format!("{}", LanguageCode::Map), "MAP"); + assert_eq!(format!("{}", LanguageCode::Mar), "MAR"); + assert_eq!(format!("{}", LanguageCode::Mas), "MAS"); + assert_eq!(format!("{}", LanguageCode::May), "MAY"); + assert_eq!(format!("{}", LanguageCode::Mdf), "MDF"); + assert_eq!(format!("{}", LanguageCode::Mdr), "MDR"); + assert_eq!(format!("{}", LanguageCode::Men), "MEN"); + assert_eq!(format!("{}", LanguageCode::Mga), "MGA"); + assert_eq!(format!("{}", LanguageCode::Mic), "MIC"); + assert_eq!(format!("{}", LanguageCode::Min), "MIN"); + assert_eq!(format!("{}", LanguageCode::Mis), "MIS"); + assert_eq!(format!("{}", LanguageCode::Mkh), "MKH"); + assert_eq!(format!("{}", LanguageCode::Mlg), "MLG"); + assert_eq!(format!("{}", LanguageCode::Mlt), "MLT"); + assert_eq!(format!("{}", LanguageCode::Mnc), "MNC"); + assert_eq!(format!("{}", LanguageCode::Mni), "MNI"); + assert_eq!(format!("{}", LanguageCode::Mno), "MNO"); + assert_eq!(format!("{}", LanguageCode::Moh), "MOH"); + assert_eq!(format!("{}", LanguageCode::Mon), "MON"); + assert_eq!(format!("{}", LanguageCode::Mos), "MOS"); + assert_eq!(format!("{}", LanguageCode::Mul), "MUL"); + assert_eq!(format!("{}", LanguageCode::Mun), "MUN"); + assert_eq!(format!("{}", LanguageCode::Mus), "MUS"); + assert_eq!(format!("{}", LanguageCode::Mwl), "MWL"); + assert_eq!(format!("{}", LanguageCode::Mwr), "MWR"); + assert_eq!(format!("{}", LanguageCode::Myn), "MYN"); + assert_eq!(format!("{}", LanguageCode::Myv), "MYV"); + assert_eq!(format!("{}", LanguageCode::Nah), "NAH"); + assert_eq!(format!("{}", LanguageCode::Nai), "NAI"); + assert_eq!(format!("{}", LanguageCode::Nap), "NAP"); + assert_eq!(format!("{}", LanguageCode::Nau), "NAU"); + assert_eq!(format!("{}", LanguageCode::Nav), "NAV"); + assert_eq!(format!("{}", LanguageCode::Nbl), "NBL"); + assert_eq!(format!("{}", LanguageCode::Nde), "NDE"); + assert_eq!(format!("{}", LanguageCode::Ndo), "NDO"); + assert_eq!(format!("{}", LanguageCode::Nds), "NDS"); + assert_eq!(format!("{}", LanguageCode::Nep), "NEP"); + assert_eq!(format!("{}", LanguageCode::New), "NEW"); + assert_eq!(format!("{}", LanguageCode::Nia), "NIA"); + assert_eq!(format!("{}", LanguageCode::Nic), "NIC"); + assert_eq!(format!("{}", LanguageCode::Niu), "NIU"); + assert_eq!(format!("{}", LanguageCode::Nno), "NNO"); + assert_eq!(format!("{}", LanguageCode::Nob), "NOB"); + assert_eq!(format!("{}", LanguageCode::Nog), "NOG"); + assert_eq!(format!("{}", LanguageCode::Non), "NON"); + assert_eq!(format!("{}", LanguageCode::Nor), "NOR"); + assert_eq!(format!("{}", LanguageCode::Nqo), "NQO"); + assert_eq!(format!("{}", LanguageCode::Nso), "NSO"); + assert_eq!(format!("{}", LanguageCode::Nub), "NUB"); + assert_eq!(format!("{}", LanguageCode::Nwc), "NWC"); + assert_eq!(format!("{}", LanguageCode::Nya), "NYA"); + assert_eq!(format!("{}", LanguageCode::Nym), "NYM"); + assert_eq!(format!("{}", LanguageCode::Nyn), "NYN"); + assert_eq!(format!("{}", LanguageCode::Nyo), "NYO"); + assert_eq!(format!("{}", LanguageCode::Nzi), "NZI"); + assert_eq!(format!("{}", LanguageCode::Oci), "OCI"); + assert_eq!(format!("{}", LanguageCode::Oji), "OJI"); + assert_eq!(format!("{}", LanguageCode::Ori), "ORI"); + assert_eq!(format!("{}", LanguageCode::Orm), "ORM"); + assert_eq!(format!("{}", LanguageCode::Osa), "OSA"); + assert_eq!(format!("{}", LanguageCode::Oss), "OSS"); + assert_eq!(format!("{}", LanguageCode::Ota), "OTA"); + assert_eq!(format!("{}", LanguageCode::Oto), "OTO"); + assert_eq!(format!("{}", LanguageCode::Paa), "PAA"); + assert_eq!(format!("{}", LanguageCode::Pag), "PAG"); + assert_eq!(format!("{}", LanguageCode::Pal), "PAL"); + assert_eq!(format!("{}", LanguageCode::Pam), "PAM"); + assert_eq!(format!("{}", LanguageCode::Pan), "PAN"); + assert_eq!(format!("{}", LanguageCode::Pap), "PAP"); + assert_eq!(format!("{}", LanguageCode::Pau), "PAU"); + assert_eq!(format!("{}", LanguageCode::Peo), "PEO"); + assert_eq!(format!("{}", LanguageCode::Per), "PER"); + assert_eq!(format!("{}", LanguageCode::Phi), "PHI"); + assert_eq!(format!("{}", LanguageCode::Phn), "PHN"); + assert_eq!(format!("{}", LanguageCode::Pli), "PLI"); + assert_eq!(format!("{}", LanguageCode::Pol), "POL"); + assert_eq!(format!("{}", LanguageCode::Pon), "PON"); + assert_eq!(format!("{}", LanguageCode::Por), "POR"); + assert_eq!(format!("{}", LanguageCode::Pra), "PRA"); + assert_eq!(format!("{}", LanguageCode::Pro), "PRO"); + assert_eq!(format!("{}", LanguageCode::Pus), "PUS"); + assert_eq!(format!("{}", LanguageCode::Qaa), "QAA"); + assert_eq!(format!("{}", LanguageCode::Que), "QUE"); + assert_eq!(format!("{}", LanguageCode::Raj), "RAJ"); + assert_eq!(format!("{}", LanguageCode::Rap), "RAP"); + assert_eq!(format!("{}", LanguageCode::Rar), "RAR"); + assert_eq!(format!("{}", LanguageCode::Roa), "ROA"); + assert_eq!(format!("{}", LanguageCode::Roh), "ROH"); + assert_eq!(format!("{}", LanguageCode::Rom), "ROM"); + assert_eq!(format!("{}", LanguageCode::Rum), "RUM"); + assert_eq!(format!("{}", LanguageCode::Run), "RUN"); + assert_eq!(format!("{}", LanguageCode::Rup), "RUP"); + assert_eq!(format!("{}", LanguageCode::Rus), "RUS"); + assert_eq!(format!("{}", LanguageCode::Sad), "SAD"); + assert_eq!(format!("{}", LanguageCode::Sag), "SAG"); + assert_eq!(format!("{}", LanguageCode::Sah), "SAH"); + assert_eq!(format!("{}", LanguageCode::Sai), "SAI"); + assert_eq!(format!("{}", LanguageCode::Sal), "SAL"); + assert_eq!(format!("{}", LanguageCode::Sam), "SAM"); + assert_eq!(format!("{}", LanguageCode::San), "SAN"); + assert_eq!(format!("{}", LanguageCode::Sas), "SAS"); + assert_eq!(format!("{}", LanguageCode::Sat), "SAT"); + assert_eq!(format!("{}", LanguageCode::Scn), "SCN"); + assert_eq!(format!("{}", LanguageCode::Sco), "SCO"); + assert_eq!(format!("{}", LanguageCode::Sel), "SEL"); + assert_eq!(format!("{}", LanguageCode::Sem), "SEM"); + assert_eq!(format!("{}", LanguageCode::Sga), "SGA"); + assert_eq!(format!("{}", LanguageCode::Sgn), "SGN"); + assert_eq!(format!("{}", LanguageCode::Shn), "SHN"); + assert_eq!(format!("{}", LanguageCode::Sid), "SID"); + assert_eq!(format!("{}", LanguageCode::Sin), "SIN"); + assert_eq!(format!("{}", LanguageCode::Sio), "SIO"); + assert_eq!(format!("{}", LanguageCode::Sit), "SIT"); + assert_eq!(format!("{}", LanguageCode::Sla), "SLA"); + assert_eq!(format!("{}", LanguageCode::Slo), "SLO"); + assert_eq!(format!("{}", LanguageCode::Slv), "SLV"); + assert_eq!(format!("{}", LanguageCode::Sma), "SMA"); + assert_eq!(format!("{}", LanguageCode::Sme), "SME"); + assert_eq!(format!("{}", LanguageCode::Smi), "SMI"); + assert_eq!(format!("{}", LanguageCode::Smj), "SMJ"); + assert_eq!(format!("{}", LanguageCode::Smn), "SMN"); + assert_eq!(format!("{}", LanguageCode::Smo), "SMO"); + assert_eq!(format!("{}", LanguageCode::Sms), "SMS"); + assert_eq!(format!("{}", LanguageCode::Sna), "SNA"); + assert_eq!(format!("{}", LanguageCode::Snd), "SND"); + assert_eq!(format!("{}", LanguageCode::Snk), "SNK"); + assert_eq!(format!("{}", LanguageCode::Sog), "SOG"); + assert_eq!(format!("{}", LanguageCode::Som), "SOM"); + assert_eq!(format!("{}", LanguageCode::Son), "SON"); + assert_eq!(format!("{}", LanguageCode::Sot), "SOT"); + assert_eq!(format!("{}", LanguageCode::Spa), "SPA"); + assert_eq!(format!("{}", LanguageCode::Srd), "SRD"); + assert_eq!(format!("{}", LanguageCode::Srn), "SRN"); + assert_eq!(format!("{}", LanguageCode::Srp), "SRP"); + assert_eq!(format!("{}", LanguageCode::Srr), "SRR"); + assert_eq!(format!("{}", LanguageCode::Ssa), "SSA"); + assert_eq!(format!("{}", LanguageCode::Ssw), "SSW"); + assert_eq!(format!("{}", LanguageCode::Suk), "SUK"); + assert_eq!(format!("{}", LanguageCode::Sun), "SUN"); + assert_eq!(format!("{}", LanguageCode::Sus), "SUS"); + assert_eq!(format!("{}", LanguageCode::Sux), "SUX"); + assert_eq!(format!("{}", LanguageCode::Swa), "SWA"); + assert_eq!(format!("{}", LanguageCode::Swe), "SWE"); + assert_eq!(format!("{}", LanguageCode::Syc), "SYC"); + assert_eq!(format!("{}", LanguageCode::Syr), "SYR"); + assert_eq!(format!("{}", LanguageCode::Tah), "TAH"); + assert_eq!(format!("{}", LanguageCode::Tai), "TAI"); + assert_eq!(format!("{}", LanguageCode::Tam), "TAM"); + assert_eq!(format!("{}", LanguageCode::Tat), "TAT"); + assert_eq!(format!("{}", LanguageCode::Tel), "TEL"); + assert_eq!(format!("{}", LanguageCode::Tem), "TEM"); + assert_eq!(format!("{}", LanguageCode::Ter), "TER"); + assert_eq!(format!("{}", LanguageCode::Tet), "TET"); + assert_eq!(format!("{}", LanguageCode::Tgk), "TGK"); + assert_eq!(format!("{}", LanguageCode::Tgl), "TGL"); + assert_eq!(format!("{}", LanguageCode::Tha), "THA"); + assert_eq!(format!("{}", LanguageCode::Tib), "TIB"); + assert_eq!(format!("{}", LanguageCode::Tig), "TIG"); + assert_eq!(format!("{}", LanguageCode::Tir), "TIR"); + assert_eq!(format!("{}", LanguageCode::Tiv), "TIV"); + assert_eq!(format!("{}", LanguageCode::Tkl), "TKL"); + assert_eq!(format!("{}", LanguageCode::Tlh), "TLH"); + assert_eq!(format!("{}", LanguageCode::Tli), "TLI"); + assert_eq!(format!("{}", LanguageCode::Tmh), "TMH"); + assert_eq!(format!("{}", LanguageCode::Tog), "TOG"); + assert_eq!(format!("{}", LanguageCode::Ton), "TON"); + assert_eq!(format!("{}", LanguageCode::Tpi), "TPI"); + assert_eq!(format!("{}", LanguageCode::Tsi), "TSI"); + assert_eq!(format!("{}", LanguageCode::Tsn), "TSN"); + assert_eq!(format!("{}", LanguageCode::Tso), "TSO"); + assert_eq!(format!("{}", LanguageCode::Tuk), "TUK"); + assert_eq!(format!("{}", LanguageCode::Tum), "TUM"); + assert_eq!(format!("{}", LanguageCode::Tup), "TUP"); + assert_eq!(format!("{}", LanguageCode::Tur), "TUR"); + assert_eq!(format!("{}", LanguageCode::Tut), "TUT"); + assert_eq!(format!("{}", LanguageCode::Tvl), "TVL"); + assert_eq!(format!("{}", LanguageCode::Twi), "TWI"); + assert_eq!(format!("{}", LanguageCode::Tyv), "TYV"); + assert_eq!(format!("{}", LanguageCode::Udm), "UDM"); + assert_eq!(format!("{}", LanguageCode::Uga), "UGA"); + assert_eq!(format!("{}", LanguageCode::Uig), "UIG"); + assert_eq!(format!("{}", LanguageCode::Ukr), "UKR"); + assert_eq!(format!("{}", LanguageCode::Umb), "UMB"); + assert_eq!(format!("{}", LanguageCode::Und), "UND"); + assert_eq!(format!("{}", LanguageCode::Urd), "URD"); + assert_eq!(format!("{}", LanguageCode::Uzb), "UZB"); + assert_eq!(format!("{}", LanguageCode::Vai), "VAI"); + assert_eq!(format!("{}", LanguageCode::Ven), "VEN"); + assert_eq!(format!("{}", LanguageCode::Vie), "VIE"); + assert_eq!(format!("{}", LanguageCode::Vol), "VOL"); + assert_eq!(format!("{}", LanguageCode::Vot), "VOT"); + assert_eq!(format!("{}", LanguageCode::Wak), "WAK"); + assert_eq!(format!("{}", LanguageCode::Wal), "WAL"); + assert_eq!(format!("{}", LanguageCode::War), "WAR"); + assert_eq!(format!("{}", LanguageCode::Was), "WAS"); + assert_eq!(format!("{}", LanguageCode::Wel), "WEL"); + assert_eq!(format!("{}", LanguageCode::Wen), "WEN"); + assert_eq!(format!("{}", LanguageCode::Wln), "WLN"); + assert_eq!(format!("{}", LanguageCode::Wol), "WOL"); + assert_eq!(format!("{}", LanguageCode::Xal), "XAL"); + assert_eq!(format!("{}", LanguageCode::Xho), "XHO"); + assert_eq!(format!("{}", LanguageCode::Yao), "YAO"); + assert_eq!(format!("{}", LanguageCode::Yap), "YAP"); + assert_eq!(format!("{}", LanguageCode::Yid), "YID"); + assert_eq!(format!("{}", LanguageCode::Yor), "YOR"); + assert_eq!(format!("{}", LanguageCode::Ypk), "YPK"); + assert_eq!(format!("{}", LanguageCode::Zap), "ZAP"); + assert_eq!(format!("{}", LanguageCode::Zbl), "ZBL"); + assert_eq!(format!("{}", LanguageCode::Zen), "ZEN"); + assert_eq!(format!("{}", LanguageCode::Zgh), "ZGH"); + assert_eq!(format!("{}", LanguageCode::Zha), "ZHA"); + assert_eq!(format!("{}", LanguageCode::Znd), "ZND"); + assert_eq!(format!("{}", LanguageCode::Zul), "ZUL"); + assert_eq!(format!("{}", LanguageCode::Zun), "ZUN"); + assert_eq!(format!("{}", LanguageCode::Zxx), "ZXX"); + assert_eq!(format!("{}", LanguageCode::Zza), "ZZA"); + } + + #[test] + fn languagerelation_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + LanguageRelation::from_str("Original").unwrap(), + LanguageRelation::Original + ); + assert_eq!( + LanguageRelation::from_str("Translated From").unwrap(), + LanguageRelation::TranslatedFrom + ); + assert_eq!( + LanguageRelation::from_str("Translated Into").unwrap(), + LanguageRelation::TranslatedInto + ); + + assert!(LanguageRelation::from_str("Invented").is_err()); + } + + #[test] + fn languagecode_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!(LanguageCode::from_str("AAR").unwrap(), LanguageCode::Aar); + assert_eq!(LanguageCode::from_str("ABK").unwrap(), LanguageCode::Abk); + assert_eq!(LanguageCode::from_str("ACE").unwrap(), LanguageCode::Ace); + assert_eq!(LanguageCode::from_str("ACH").unwrap(), LanguageCode::Ach); + assert_eq!(LanguageCode::from_str("ADA").unwrap(), LanguageCode::Ada); + assert_eq!(LanguageCode::from_str("ADY").unwrap(), LanguageCode::Ady); + assert_eq!(LanguageCode::from_str("AFA").unwrap(), LanguageCode::Afa); + assert_eq!(LanguageCode::from_str("AFH").unwrap(), LanguageCode::Afh); + assert_eq!(LanguageCode::from_str("AFR").unwrap(), LanguageCode::Afr); + assert_eq!(LanguageCode::from_str("AIN").unwrap(), LanguageCode::Ain); + assert_eq!(LanguageCode::from_str("AKA").unwrap(), LanguageCode::Aka); + assert_eq!(LanguageCode::from_str("AKK").unwrap(), LanguageCode::Akk); + assert_eq!(LanguageCode::from_str("ALB").unwrap(), LanguageCode::Alb); + assert_eq!(LanguageCode::from_str("ALE").unwrap(), LanguageCode::Ale); + assert_eq!(LanguageCode::from_str("ALG").unwrap(), LanguageCode::Alg); + assert_eq!(LanguageCode::from_str("ALT").unwrap(), LanguageCode::Alt); + assert_eq!(LanguageCode::from_str("AMH").unwrap(), LanguageCode::Amh); + assert_eq!(LanguageCode::from_str("ANG").unwrap(), LanguageCode::Ang); + assert_eq!(LanguageCode::from_str("ANP").unwrap(), LanguageCode::Anp); + assert_eq!(LanguageCode::from_str("APA").unwrap(), LanguageCode::Apa); + assert_eq!(LanguageCode::from_str("ARA").unwrap(), LanguageCode::Ara); + assert_eq!(LanguageCode::from_str("ARC").unwrap(), LanguageCode::Arc); + assert_eq!(LanguageCode::from_str("ARG").unwrap(), LanguageCode::Arg); + assert_eq!(LanguageCode::from_str("ARM").unwrap(), LanguageCode::Arm); + assert_eq!(LanguageCode::from_str("ARN").unwrap(), LanguageCode::Arn); + assert_eq!(LanguageCode::from_str("ARP").unwrap(), LanguageCode::Arp); + assert_eq!(LanguageCode::from_str("ART").unwrap(), LanguageCode::Art); + assert_eq!(LanguageCode::from_str("ARW").unwrap(), LanguageCode::Arw); + assert_eq!(LanguageCode::from_str("ASM").unwrap(), LanguageCode::Asm); + assert_eq!(LanguageCode::from_str("AST").unwrap(), LanguageCode::Ast); + assert_eq!(LanguageCode::from_str("ATH").unwrap(), LanguageCode::Ath); + assert_eq!(LanguageCode::from_str("AUS").unwrap(), LanguageCode::Aus); + assert_eq!(LanguageCode::from_str("AVA").unwrap(), LanguageCode::Ava); + assert_eq!(LanguageCode::from_str("AVE").unwrap(), LanguageCode::Ave); + assert_eq!(LanguageCode::from_str("AWA").unwrap(), LanguageCode::Awa); + assert_eq!(LanguageCode::from_str("AYM").unwrap(), LanguageCode::Aym); + assert_eq!(LanguageCode::from_str("AZE").unwrap(), LanguageCode::Aze); + assert_eq!(LanguageCode::from_str("BAD").unwrap(), LanguageCode::Bad); + assert_eq!(LanguageCode::from_str("BAI").unwrap(), LanguageCode::Bai); + assert_eq!(LanguageCode::from_str("BAK").unwrap(), LanguageCode::Bak); + assert_eq!(LanguageCode::from_str("BAL").unwrap(), LanguageCode::Bal); + assert_eq!(LanguageCode::from_str("BAM").unwrap(), LanguageCode::Bam); + assert_eq!(LanguageCode::from_str("BAN").unwrap(), LanguageCode::Ban); + assert_eq!(LanguageCode::from_str("BAQ").unwrap(), LanguageCode::Baq); + assert_eq!(LanguageCode::from_str("BAS").unwrap(), LanguageCode::Bas); + assert_eq!(LanguageCode::from_str("BAT").unwrap(), LanguageCode::Bat); + assert_eq!(LanguageCode::from_str("BEJ").unwrap(), LanguageCode::Bej); + assert_eq!(LanguageCode::from_str("BEL").unwrap(), LanguageCode::Bel); + assert_eq!(LanguageCode::from_str("BEM").unwrap(), LanguageCode::Bem); + assert_eq!(LanguageCode::from_str("BEN").unwrap(), LanguageCode::Ben); + assert_eq!(LanguageCode::from_str("BER").unwrap(), LanguageCode::Ber); + assert_eq!(LanguageCode::from_str("BHO").unwrap(), LanguageCode::Bho); + assert_eq!(LanguageCode::from_str("BIH").unwrap(), LanguageCode::Bih); + assert_eq!(LanguageCode::from_str("BIK").unwrap(), LanguageCode::Bik); + assert_eq!(LanguageCode::from_str("BIN").unwrap(), LanguageCode::Bin); + assert_eq!(LanguageCode::from_str("BIS").unwrap(), LanguageCode::Bis); + assert_eq!(LanguageCode::from_str("BLA").unwrap(), LanguageCode::Bla); + assert_eq!(LanguageCode::from_str("BNT").unwrap(), LanguageCode::Bnt); + assert_eq!(LanguageCode::from_str("BOS").unwrap(), LanguageCode::Bos); + assert_eq!(LanguageCode::from_str("BRA").unwrap(), LanguageCode::Bra); + assert_eq!(LanguageCode::from_str("BRE").unwrap(), LanguageCode::Bre); + assert_eq!(LanguageCode::from_str("BTK").unwrap(), LanguageCode::Btk); + assert_eq!(LanguageCode::from_str("BUA").unwrap(), LanguageCode::Bua); + assert_eq!(LanguageCode::from_str("BUG").unwrap(), LanguageCode::Bug); + assert_eq!(LanguageCode::from_str("BUL").unwrap(), LanguageCode::Bul); + assert_eq!(LanguageCode::from_str("BUR").unwrap(), LanguageCode::Bur); + assert_eq!(LanguageCode::from_str("BYN").unwrap(), LanguageCode::Byn); + assert_eq!(LanguageCode::from_str("CAD").unwrap(), LanguageCode::Cad); + assert_eq!(LanguageCode::from_str("CAI").unwrap(), LanguageCode::Cai); + assert_eq!(LanguageCode::from_str("CAR").unwrap(), LanguageCode::Car); + assert_eq!(LanguageCode::from_str("CAT").unwrap(), LanguageCode::Cat); + assert_eq!(LanguageCode::from_str("CAU").unwrap(), LanguageCode::Cau); + assert_eq!(LanguageCode::from_str("CEB").unwrap(), LanguageCode::Ceb); + assert_eq!(LanguageCode::from_str("CEL").unwrap(), LanguageCode::Cel); + assert_eq!(LanguageCode::from_str("CHA").unwrap(), LanguageCode::Cha); + assert_eq!(LanguageCode::from_str("CHB").unwrap(), LanguageCode::Chb); + assert_eq!(LanguageCode::from_str("CHE").unwrap(), LanguageCode::Che); + assert_eq!(LanguageCode::from_str("CHG").unwrap(), LanguageCode::Chg); + assert_eq!(LanguageCode::from_str("CHI").unwrap(), LanguageCode::Chi); + assert_eq!(LanguageCode::from_str("CHK").unwrap(), LanguageCode::Chk); + assert_eq!(LanguageCode::from_str("CHM").unwrap(), LanguageCode::Chm); + assert_eq!(LanguageCode::from_str("CHN").unwrap(), LanguageCode::Chn); + assert_eq!(LanguageCode::from_str("CHO").unwrap(), LanguageCode::Cho); + assert_eq!(LanguageCode::from_str("CHP").unwrap(), LanguageCode::Chp); + assert_eq!(LanguageCode::from_str("CHR").unwrap(), LanguageCode::Chr); + assert_eq!(LanguageCode::from_str("CHU").unwrap(), LanguageCode::Chu); + assert_eq!(LanguageCode::from_str("CHV").unwrap(), LanguageCode::Chv); + assert_eq!(LanguageCode::from_str("CHY").unwrap(), LanguageCode::Chy); + assert_eq!(LanguageCode::from_str("CMC").unwrap(), LanguageCode::Cmc); + assert_eq!(LanguageCode::from_str("CNR").unwrap(), LanguageCode::Cnr); + assert_eq!(LanguageCode::from_str("COP").unwrap(), LanguageCode::Cop); + assert_eq!(LanguageCode::from_str("COR").unwrap(), LanguageCode::Cor); + assert_eq!(LanguageCode::from_str("COS").unwrap(), LanguageCode::Cos); + assert_eq!(LanguageCode::from_str("CPE").unwrap(), LanguageCode::Cpe); + assert_eq!(LanguageCode::from_str("CPF").unwrap(), LanguageCode::Cpf); + assert_eq!(LanguageCode::from_str("CPP").unwrap(), LanguageCode::Cpp); + assert_eq!(LanguageCode::from_str("CRE").unwrap(), LanguageCode::Cre); + assert_eq!(LanguageCode::from_str("CRH").unwrap(), LanguageCode::Crh); + assert_eq!(LanguageCode::from_str("CRP").unwrap(), LanguageCode::Crp); + assert_eq!(LanguageCode::from_str("CSB").unwrap(), LanguageCode::Csb); + assert_eq!(LanguageCode::from_str("CUS").unwrap(), LanguageCode::Cus); + assert_eq!(LanguageCode::from_str("CZE").unwrap(), LanguageCode::Cze); + assert_eq!(LanguageCode::from_str("DAK").unwrap(), LanguageCode::Dak); + assert_eq!(LanguageCode::from_str("DAN").unwrap(), LanguageCode::Dan); + assert_eq!(LanguageCode::from_str("DAR").unwrap(), LanguageCode::Dar); + assert_eq!(LanguageCode::from_str("DAY").unwrap(), LanguageCode::Day); + assert_eq!(LanguageCode::from_str("DEL").unwrap(), LanguageCode::Del); + assert_eq!(LanguageCode::from_str("DEN").unwrap(), LanguageCode::Den); + assert_eq!(LanguageCode::from_str("DGR").unwrap(), LanguageCode::Dgr); + assert_eq!(LanguageCode::from_str("DIN").unwrap(), LanguageCode::Din); + assert_eq!(LanguageCode::from_str("DIV").unwrap(), LanguageCode::Div); + assert_eq!(LanguageCode::from_str("DOI").unwrap(), LanguageCode::Doi); + assert_eq!(LanguageCode::from_str("DRA").unwrap(), LanguageCode::Dra); + assert_eq!(LanguageCode::from_str("DSB").unwrap(), LanguageCode::Dsb); + assert_eq!(LanguageCode::from_str("DUA").unwrap(), LanguageCode::Dua); + assert_eq!(LanguageCode::from_str("DUM").unwrap(), LanguageCode::Dum); + assert_eq!(LanguageCode::from_str("DUT").unwrap(), LanguageCode::Dut); + assert_eq!(LanguageCode::from_str("DYU").unwrap(), LanguageCode::Dyu); + assert_eq!(LanguageCode::from_str("DZO").unwrap(), LanguageCode::Dzo); + assert_eq!(LanguageCode::from_str("EFI").unwrap(), LanguageCode::Efi); + assert_eq!(LanguageCode::from_str("EGY").unwrap(), LanguageCode::Egy); + assert_eq!(LanguageCode::from_str("EKA").unwrap(), LanguageCode::Eka); + assert_eq!(LanguageCode::from_str("ELX").unwrap(), LanguageCode::Elx); + assert_eq!(LanguageCode::from_str("ENG").unwrap(), LanguageCode::Eng); + assert_eq!(LanguageCode::from_str("ENM").unwrap(), LanguageCode::Enm); + assert_eq!(LanguageCode::from_str("EPO").unwrap(), LanguageCode::Epo); + assert_eq!(LanguageCode::from_str("EST").unwrap(), LanguageCode::Est); + assert_eq!(LanguageCode::from_str("EWE").unwrap(), LanguageCode::Ewe); + assert_eq!(LanguageCode::from_str("EWO").unwrap(), LanguageCode::Ewo); + assert_eq!(LanguageCode::from_str("FAN").unwrap(), LanguageCode::Fan); + assert_eq!(LanguageCode::from_str("FAO").unwrap(), LanguageCode::Fao); + assert_eq!(LanguageCode::from_str("FAT").unwrap(), LanguageCode::Fat); + assert_eq!(LanguageCode::from_str("FIJ").unwrap(), LanguageCode::Fij); + assert_eq!(LanguageCode::from_str("FIL").unwrap(), LanguageCode::Fil); + assert_eq!(LanguageCode::from_str("FIN").unwrap(), LanguageCode::Fin); + assert_eq!(LanguageCode::from_str("FIU").unwrap(), LanguageCode::Fiu); + assert_eq!(LanguageCode::from_str("FON").unwrap(), LanguageCode::Fon); + assert_eq!(LanguageCode::from_str("FRE").unwrap(), LanguageCode::Fre); + assert_eq!(LanguageCode::from_str("FRM").unwrap(), LanguageCode::Frm); + assert_eq!(LanguageCode::from_str("FRO").unwrap(), LanguageCode::Fro); + assert_eq!(LanguageCode::from_str("FRR").unwrap(), LanguageCode::Frr); + assert_eq!(LanguageCode::from_str("FRS").unwrap(), LanguageCode::Frs); + assert_eq!(LanguageCode::from_str("FRY").unwrap(), LanguageCode::Fry); + assert_eq!(LanguageCode::from_str("FUL").unwrap(), LanguageCode::Ful); + assert_eq!(LanguageCode::from_str("FUR").unwrap(), LanguageCode::Fur); + assert_eq!(LanguageCode::from_str("GAA").unwrap(), LanguageCode::Gaa); + assert_eq!(LanguageCode::from_str("GAY").unwrap(), LanguageCode::Gay); + assert_eq!(LanguageCode::from_str("GBA").unwrap(), LanguageCode::Gba); + assert_eq!(LanguageCode::from_str("GEM").unwrap(), LanguageCode::Gem); + assert_eq!(LanguageCode::from_str("GEO").unwrap(), LanguageCode::Geo); + assert_eq!(LanguageCode::from_str("GER").unwrap(), LanguageCode::Ger); + assert_eq!(LanguageCode::from_str("GEZ").unwrap(), LanguageCode::Gez); + assert_eq!(LanguageCode::from_str("GIL").unwrap(), LanguageCode::Gil); + assert_eq!(LanguageCode::from_str("GLA").unwrap(), LanguageCode::Gla); + assert_eq!(LanguageCode::from_str("GLE").unwrap(), LanguageCode::Gle); + assert_eq!(LanguageCode::from_str("GLG").unwrap(), LanguageCode::Glg); + assert_eq!(LanguageCode::from_str("GLV").unwrap(), LanguageCode::Glv); + assert_eq!(LanguageCode::from_str("GMH").unwrap(), LanguageCode::Gmh); + assert_eq!(LanguageCode::from_str("GOH").unwrap(), LanguageCode::Goh); + assert_eq!(LanguageCode::from_str("GON").unwrap(), LanguageCode::Gon); + assert_eq!(LanguageCode::from_str("GOR").unwrap(), LanguageCode::Gor); + assert_eq!(LanguageCode::from_str("GOT").unwrap(), LanguageCode::Got); + assert_eq!(LanguageCode::from_str("GRB").unwrap(), LanguageCode::Grb); + assert_eq!(LanguageCode::from_str("GRC").unwrap(), LanguageCode::Grc); + assert_eq!(LanguageCode::from_str("GRE").unwrap(), LanguageCode::Gre); + assert_eq!(LanguageCode::from_str("GRN").unwrap(), LanguageCode::Grn); + assert_eq!(LanguageCode::from_str("GSW").unwrap(), LanguageCode::Gsw); + assert_eq!(LanguageCode::from_str("GUJ").unwrap(), LanguageCode::Guj); + assert_eq!(LanguageCode::from_str("GWI").unwrap(), LanguageCode::Gwi); + assert_eq!(LanguageCode::from_str("HAI").unwrap(), LanguageCode::Hai); + assert_eq!(LanguageCode::from_str("HAT").unwrap(), LanguageCode::Hat); + assert_eq!(LanguageCode::from_str("HAU").unwrap(), LanguageCode::Hau); + assert_eq!(LanguageCode::from_str("HAW").unwrap(), LanguageCode::Haw); + assert_eq!(LanguageCode::from_str("HEB").unwrap(), LanguageCode::Heb); + assert_eq!(LanguageCode::from_str("HER").unwrap(), LanguageCode::Her); + assert_eq!(LanguageCode::from_str("HIL").unwrap(), LanguageCode::Hil); + assert_eq!(LanguageCode::from_str("HIM").unwrap(), LanguageCode::Him); + assert_eq!(LanguageCode::from_str("HIN").unwrap(), LanguageCode::Hin); + assert_eq!(LanguageCode::from_str("HIT").unwrap(), LanguageCode::Hit); + assert_eq!(LanguageCode::from_str("HMN").unwrap(), LanguageCode::Hmn); + assert_eq!(LanguageCode::from_str("HMO").unwrap(), LanguageCode::Hmo); + assert_eq!(LanguageCode::from_str("HRV").unwrap(), LanguageCode::Hrv); + assert_eq!(LanguageCode::from_str("HSB").unwrap(), LanguageCode::Hsb); + assert_eq!(LanguageCode::from_str("HUN").unwrap(), LanguageCode::Hun); + assert_eq!(LanguageCode::from_str("HUP").unwrap(), LanguageCode::Hup); + assert_eq!(LanguageCode::from_str("IBA").unwrap(), LanguageCode::Iba); + assert_eq!(LanguageCode::from_str("IBO").unwrap(), LanguageCode::Ibo); + assert_eq!(LanguageCode::from_str("ICE").unwrap(), LanguageCode::Ice); + assert_eq!(LanguageCode::from_str("IDO").unwrap(), LanguageCode::Ido); + assert_eq!(LanguageCode::from_str("III").unwrap(), LanguageCode::Iii); + assert_eq!(LanguageCode::from_str("IJO").unwrap(), LanguageCode::Ijo); + assert_eq!(LanguageCode::from_str("IKU").unwrap(), LanguageCode::Iku); + assert_eq!(LanguageCode::from_str("ILE").unwrap(), LanguageCode::Ile); + assert_eq!(LanguageCode::from_str("ILO").unwrap(), LanguageCode::Ilo); + assert_eq!(LanguageCode::from_str("INA").unwrap(), LanguageCode::Ina); + assert_eq!(LanguageCode::from_str("INC").unwrap(), LanguageCode::Inc); + assert_eq!(LanguageCode::from_str("IND").unwrap(), LanguageCode::Ind); + assert_eq!(LanguageCode::from_str("INE").unwrap(), LanguageCode::Ine); + assert_eq!(LanguageCode::from_str("INH").unwrap(), LanguageCode::Inh); + assert_eq!(LanguageCode::from_str("IPK").unwrap(), LanguageCode::Ipk); + assert_eq!(LanguageCode::from_str("IRA").unwrap(), LanguageCode::Ira); + assert_eq!(LanguageCode::from_str("IRO").unwrap(), LanguageCode::Iro); + assert_eq!(LanguageCode::from_str("ITA").unwrap(), LanguageCode::Ita); + assert_eq!(LanguageCode::from_str("JAV").unwrap(), LanguageCode::Jav); + assert_eq!(LanguageCode::from_str("JBO").unwrap(), LanguageCode::Jbo); + assert_eq!(LanguageCode::from_str("JPN").unwrap(), LanguageCode::Jpn); + assert_eq!(LanguageCode::from_str("JPR").unwrap(), LanguageCode::Jpr); + assert_eq!(LanguageCode::from_str("JRB").unwrap(), LanguageCode::Jrb); + assert_eq!(LanguageCode::from_str("KAA").unwrap(), LanguageCode::Kaa); + assert_eq!(LanguageCode::from_str("KAB").unwrap(), LanguageCode::Kab); + assert_eq!(LanguageCode::from_str("KAC").unwrap(), LanguageCode::Kac); + assert_eq!(LanguageCode::from_str("KAL").unwrap(), LanguageCode::Kal); + assert_eq!(LanguageCode::from_str("KAM").unwrap(), LanguageCode::Kam); + assert_eq!(LanguageCode::from_str("KAN").unwrap(), LanguageCode::Kan); + assert_eq!(LanguageCode::from_str("KAR").unwrap(), LanguageCode::Kar); + assert_eq!(LanguageCode::from_str("KAS").unwrap(), LanguageCode::Kas); + assert_eq!(LanguageCode::from_str("KAU").unwrap(), LanguageCode::Kau); + assert_eq!(LanguageCode::from_str("KAW").unwrap(), LanguageCode::Kaw); + assert_eq!(LanguageCode::from_str("KAZ").unwrap(), LanguageCode::Kaz); + assert_eq!(LanguageCode::from_str("KBD").unwrap(), LanguageCode::Kbd); + assert_eq!(LanguageCode::from_str("KHA").unwrap(), LanguageCode::Kha); + assert_eq!(LanguageCode::from_str("KHI").unwrap(), LanguageCode::Khi); + assert_eq!(LanguageCode::from_str("KHM").unwrap(), LanguageCode::Khm); + assert_eq!(LanguageCode::from_str("KHO").unwrap(), LanguageCode::Kho); + assert_eq!(LanguageCode::from_str("KIK").unwrap(), LanguageCode::Kik); + assert_eq!(LanguageCode::from_str("KIN").unwrap(), LanguageCode::Kin); + assert_eq!(LanguageCode::from_str("KIR").unwrap(), LanguageCode::Kir); + assert_eq!(LanguageCode::from_str("KMB").unwrap(), LanguageCode::Kmb); + assert_eq!(LanguageCode::from_str("KOK").unwrap(), LanguageCode::Kok); + assert_eq!(LanguageCode::from_str("KOM").unwrap(), LanguageCode::Kom); + assert_eq!(LanguageCode::from_str("KON").unwrap(), LanguageCode::Kon); + assert_eq!(LanguageCode::from_str("KOR").unwrap(), LanguageCode::Kor); + assert_eq!(LanguageCode::from_str("KOS").unwrap(), LanguageCode::Kos); + assert_eq!(LanguageCode::from_str("KPE").unwrap(), LanguageCode::Kpe); + assert_eq!(LanguageCode::from_str("KRC").unwrap(), LanguageCode::Krc); + assert_eq!(LanguageCode::from_str("KRL").unwrap(), LanguageCode::Krl); + assert_eq!(LanguageCode::from_str("KRO").unwrap(), LanguageCode::Kro); + assert_eq!(LanguageCode::from_str("KRU").unwrap(), LanguageCode::Kru); + assert_eq!(LanguageCode::from_str("KUA").unwrap(), LanguageCode::Kua); + assert_eq!(LanguageCode::from_str("KUM").unwrap(), LanguageCode::Kum); + assert_eq!(LanguageCode::from_str("KUR").unwrap(), LanguageCode::Kur); + assert_eq!(LanguageCode::from_str("KUT").unwrap(), LanguageCode::Kut); + assert_eq!(LanguageCode::from_str("LAD").unwrap(), LanguageCode::Lad); + assert_eq!(LanguageCode::from_str("LAH").unwrap(), LanguageCode::Lah); + assert_eq!(LanguageCode::from_str("LAM").unwrap(), LanguageCode::Lam); + assert_eq!(LanguageCode::from_str("LAO").unwrap(), LanguageCode::Lao); + assert_eq!(LanguageCode::from_str("LAT").unwrap(), LanguageCode::Lat); + assert_eq!(LanguageCode::from_str("LAV").unwrap(), LanguageCode::Lav); + assert_eq!(LanguageCode::from_str("LEZ").unwrap(), LanguageCode::Lez); + assert_eq!(LanguageCode::from_str("LIM").unwrap(), LanguageCode::Lim); + assert_eq!(LanguageCode::from_str("LIN").unwrap(), LanguageCode::Lin); + assert_eq!(LanguageCode::from_str("LIT").unwrap(), LanguageCode::Lit); + assert_eq!(LanguageCode::from_str("LOL").unwrap(), LanguageCode::Lol); + assert_eq!(LanguageCode::from_str("LOZ").unwrap(), LanguageCode::Loz); + assert_eq!(LanguageCode::from_str("LTZ").unwrap(), LanguageCode::Ltz); + assert_eq!(LanguageCode::from_str("LUA").unwrap(), LanguageCode::Lua); + assert_eq!(LanguageCode::from_str("LUB").unwrap(), LanguageCode::Lub); + assert_eq!(LanguageCode::from_str("LUG").unwrap(), LanguageCode::Lug); + assert_eq!(LanguageCode::from_str("LUI").unwrap(), LanguageCode::Lui); + assert_eq!(LanguageCode::from_str("LUN").unwrap(), LanguageCode::Lun); + assert_eq!(LanguageCode::from_str("LUO").unwrap(), LanguageCode::Luo); + assert_eq!(LanguageCode::from_str("LUS").unwrap(), LanguageCode::Lus); + assert_eq!(LanguageCode::from_str("MAC").unwrap(), LanguageCode::Mac); + assert_eq!(LanguageCode::from_str("MAD").unwrap(), LanguageCode::Mad); + assert_eq!(LanguageCode::from_str("MAG").unwrap(), LanguageCode::Mag); + assert_eq!(LanguageCode::from_str("MAH").unwrap(), LanguageCode::Mah); + assert_eq!(LanguageCode::from_str("MAI").unwrap(), LanguageCode::Mai); + assert_eq!(LanguageCode::from_str("MAK").unwrap(), LanguageCode::Mak); + assert_eq!(LanguageCode::from_str("MAL").unwrap(), LanguageCode::Mal); + assert_eq!(LanguageCode::from_str("MAN").unwrap(), LanguageCode::Man); + assert_eq!(LanguageCode::from_str("MAO").unwrap(), LanguageCode::Mao); + assert_eq!(LanguageCode::from_str("MAP").unwrap(), LanguageCode::Map); + assert_eq!(LanguageCode::from_str("MAR").unwrap(), LanguageCode::Mar); + assert_eq!(LanguageCode::from_str("MAS").unwrap(), LanguageCode::Mas); + assert_eq!(LanguageCode::from_str("MAY").unwrap(), LanguageCode::May); + assert_eq!(LanguageCode::from_str("MDF").unwrap(), LanguageCode::Mdf); + assert_eq!(LanguageCode::from_str("MDR").unwrap(), LanguageCode::Mdr); + assert_eq!(LanguageCode::from_str("MEN").unwrap(), LanguageCode::Men); + assert_eq!(LanguageCode::from_str("MGA").unwrap(), LanguageCode::Mga); + assert_eq!(LanguageCode::from_str("MIC").unwrap(), LanguageCode::Mic); + assert_eq!(LanguageCode::from_str("MIN").unwrap(), LanguageCode::Min); + assert_eq!(LanguageCode::from_str("MIS").unwrap(), LanguageCode::Mis); + assert_eq!(LanguageCode::from_str("MKH").unwrap(), LanguageCode::Mkh); + assert_eq!(LanguageCode::from_str("MLG").unwrap(), LanguageCode::Mlg); + assert_eq!(LanguageCode::from_str("MLT").unwrap(), LanguageCode::Mlt); + assert_eq!(LanguageCode::from_str("MNC").unwrap(), LanguageCode::Mnc); + assert_eq!(LanguageCode::from_str("MNI").unwrap(), LanguageCode::Mni); + assert_eq!(LanguageCode::from_str("MNO").unwrap(), LanguageCode::Mno); + assert_eq!(LanguageCode::from_str("MOH").unwrap(), LanguageCode::Moh); + assert_eq!(LanguageCode::from_str("MON").unwrap(), LanguageCode::Mon); + assert_eq!(LanguageCode::from_str("MOS").unwrap(), LanguageCode::Mos); + assert_eq!(LanguageCode::from_str("MUL").unwrap(), LanguageCode::Mul); + assert_eq!(LanguageCode::from_str("MUN").unwrap(), LanguageCode::Mun); + assert_eq!(LanguageCode::from_str("MUS").unwrap(), LanguageCode::Mus); + assert_eq!(LanguageCode::from_str("MWL").unwrap(), LanguageCode::Mwl); + assert_eq!(LanguageCode::from_str("MWR").unwrap(), LanguageCode::Mwr); + assert_eq!(LanguageCode::from_str("MYN").unwrap(), LanguageCode::Myn); + assert_eq!(LanguageCode::from_str("MYV").unwrap(), LanguageCode::Myv); + assert_eq!(LanguageCode::from_str("NAH").unwrap(), LanguageCode::Nah); + assert_eq!(LanguageCode::from_str("NAI").unwrap(), LanguageCode::Nai); + assert_eq!(LanguageCode::from_str("NAP").unwrap(), LanguageCode::Nap); + assert_eq!(LanguageCode::from_str("NAU").unwrap(), LanguageCode::Nau); + assert_eq!(LanguageCode::from_str("NAV").unwrap(), LanguageCode::Nav); + assert_eq!(LanguageCode::from_str("NBL").unwrap(), LanguageCode::Nbl); + assert_eq!(LanguageCode::from_str("NDE").unwrap(), LanguageCode::Nde); + assert_eq!(LanguageCode::from_str("NDO").unwrap(), LanguageCode::Ndo); + assert_eq!(LanguageCode::from_str("NDS").unwrap(), LanguageCode::Nds); + assert_eq!(LanguageCode::from_str("NEP").unwrap(), LanguageCode::Nep); + assert_eq!(LanguageCode::from_str("NEW").unwrap(), LanguageCode::New); + assert_eq!(LanguageCode::from_str("NIA").unwrap(), LanguageCode::Nia); + assert_eq!(LanguageCode::from_str("NIC").unwrap(), LanguageCode::Nic); + assert_eq!(LanguageCode::from_str("NIU").unwrap(), LanguageCode::Niu); + assert_eq!(LanguageCode::from_str("NNO").unwrap(), LanguageCode::Nno); + assert_eq!(LanguageCode::from_str("NOB").unwrap(), LanguageCode::Nob); + assert_eq!(LanguageCode::from_str("NOG").unwrap(), LanguageCode::Nog); + assert_eq!(LanguageCode::from_str("NON").unwrap(), LanguageCode::Non); + assert_eq!(LanguageCode::from_str("NOR").unwrap(), LanguageCode::Nor); + assert_eq!(LanguageCode::from_str("NQO").unwrap(), LanguageCode::Nqo); + assert_eq!(LanguageCode::from_str("NSO").unwrap(), LanguageCode::Nso); + assert_eq!(LanguageCode::from_str("NUB").unwrap(), LanguageCode::Nub); + assert_eq!(LanguageCode::from_str("NWC").unwrap(), LanguageCode::Nwc); + assert_eq!(LanguageCode::from_str("NYA").unwrap(), LanguageCode::Nya); + assert_eq!(LanguageCode::from_str("NYM").unwrap(), LanguageCode::Nym); + assert_eq!(LanguageCode::from_str("NYN").unwrap(), LanguageCode::Nyn); + assert_eq!(LanguageCode::from_str("NYO").unwrap(), LanguageCode::Nyo); + assert_eq!(LanguageCode::from_str("NZI").unwrap(), LanguageCode::Nzi); + assert_eq!(LanguageCode::from_str("OCI").unwrap(), LanguageCode::Oci); + assert_eq!(LanguageCode::from_str("OJI").unwrap(), LanguageCode::Oji); + assert_eq!(LanguageCode::from_str("ORI").unwrap(), LanguageCode::Ori); + assert_eq!(LanguageCode::from_str("ORM").unwrap(), LanguageCode::Orm); + assert_eq!(LanguageCode::from_str("OSA").unwrap(), LanguageCode::Osa); + assert_eq!(LanguageCode::from_str("OSS").unwrap(), LanguageCode::Oss); + assert_eq!(LanguageCode::from_str("OTA").unwrap(), LanguageCode::Ota); + assert_eq!(LanguageCode::from_str("OTO").unwrap(), LanguageCode::Oto); + assert_eq!(LanguageCode::from_str("PAA").unwrap(), LanguageCode::Paa); + assert_eq!(LanguageCode::from_str("PAG").unwrap(), LanguageCode::Pag); + assert_eq!(LanguageCode::from_str("PAL").unwrap(), LanguageCode::Pal); + assert_eq!(LanguageCode::from_str("PAM").unwrap(), LanguageCode::Pam); + assert_eq!(LanguageCode::from_str("PAN").unwrap(), LanguageCode::Pan); + assert_eq!(LanguageCode::from_str("PAP").unwrap(), LanguageCode::Pap); + assert_eq!(LanguageCode::from_str("PAU").unwrap(), LanguageCode::Pau); + assert_eq!(LanguageCode::from_str("PEO").unwrap(), LanguageCode::Peo); + assert_eq!(LanguageCode::from_str("PER").unwrap(), LanguageCode::Per); + assert_eq!(LanguageCode::from_str("PHI").unwrap(), LanguageCode::Phi); + assert_eq!(LanguageCode::from_str("PHN").unwrap(), LanguageCode::Phn); + assert_eq!(LanguageCode::from_str("PLI").unwrap(), LanguageCode::Pli); + assert_eq!(LanguageCode::from_str("POL").unwrap(), LanguageCode::Pol); + assert_eq!(LanguageCode::from_str("PON").unwrap(), LanguageCode::Pon); + assert_eq!(LanguageCode::from_str("POR").unwrap(), LanguageCode::Por); + assert_eq!(LanguageCode::from_str("PRA").unwrap(), LanguageCode::Pra); + assert_eq!(LanguageCode::from_str("PRO").unwrap(), LanguageCode::Pro); + assert_eq!(LanguageCode::from_str("PUS").unwrap(), LanguageCode::Pus); + assert_eq!(LanguageCode::from_str("QAA").unwrap(), LanguageCode::Qaa); + assert_eq!(LanguageCode::from_str("QUE").unwrap(), LanguageCode::Que); + assert_eq!(LanguageCode::from_str("RAJ").unwrap(), LanguageCode::Raj); + assert_eq!(LanguageCode::from_str("RAP").unwrap(), LanguageCode::Rap); + assert_eq!(LanguageCode::from_str("RAR").unwrap(), LanguageCode::Rar); + assert_eq!(LanguageCode::from_str("ROA").unwrap(), LanguageCode::Roa); + assert_eq!(LanguageCode::from_str("ROH").unwrap(), LanguageCode::Roh); + assert_eq!(LanguageCode::from_str("ROM").unwrap(), LanguageCode::Rom); + assert_eq!(LanguageCode::from_str("RUM").unwrap(), LanguageCode::Rum); + assert_eq!(LanguageCode::from_str("RUN").unwrap(), LanguageCode::Run); + assert_eq!(LanguageCode::from_str("RUP").unwrap(), LanguageCode::Rup); + assert_eq!(LanguageCode::from_str("RUS").unwrap(), LanguageCode::Rus); + assert_eq!(LanguageCode::from_str("SAD").unwrap(), LanguageCode::Sad); + assert_eq!(LanguageCode::from_str("SAG").unwrap(), LanguageCode::Sag); + assert_eq!(LanguageCode::from_str("SAH").unwrap(), LanguageCode::Sah); + assert_eq!(LanguageCode::from_str("SAI").unwrap(), LanguageCode::Sai); + assert_eq!(LanguageCode::from_str("SAL").unwrap(), LanguageCode::Sal); + assert_eq!(LanguageCode::from_str("SAM").unwrap(), LanguageCode::Sam); + assert_eq!(LanguageCode::from_str("SAN").unwrap(), LanguageCode::San); + assert_eq!(LanguageCode::from_str("SAS").unwrap(), LanguageCode::Sas); + assert_eq!(LanguageCode::from_str("SAT").unwrap(), LanguageCode::Sat); + assert_eq!(LanguageCode::from_str("SCN").unwrap(), LanguageCode::Scn); + assert_eq!(LanguageCode::from_str("SCO").unwrap(), LanguageCode::Sco); + assert_eq!(LanguageCode::from_str("SEL").unwrap(), LanguageCode::Sel); + assert_eq!(LanguageCode::from_str("SEM").unwrap(), LanguageCode::Sem); + assert_eq!(LanguageCode::from_str("SGA").unwrap(), LanguageCode::Sga); + assert_eq!(LanguageCode::from_str("SGN").unwrap(), LanguageCode::Sgn); + assert_eq!(LanguageCode::from_str("SHN").unwrap(), LanguageCode::Shn); + assert_eq!(LanguageCode::from_str("SID").unwrap(), LanguageCode::Sid); + assert_eq!(LanguageCode::from_str("SIN").unwrap(), LanguageCode::Sin); + assert_eq!(LanguageCode::from_str("SIO").unwrap(), LanguageCode::Sio); + assert_eq!(LanguageCode::from_str("SIT").unwrap(), LanguageCode::Sit); + assert_eq!(LanguageCode::from_str("SLA").unwrap(), LanguageCode::Sla); + assert_eq!(LanguageCode::from_str("SLO").unwrap(), LanguageCode::Slo); + assert_eq!(LanguageCode::from_str("SLV").unwrap(), LanguageCode::Slv); + assert_eq!(LanguageCode::from_str("SMA").unwrap(), LanguageCode::Sma); + assert_eq!(LanguageCode::from_str("SME").unwrap(), LanguageCode::Sme); + assert_eq!(LanguageCode::from_str("SMI").unwrap(), LanguageCode::Smi); + assert_eq!(LanguageCode::from_str("SMJ").unwrap(), LanguageCode::Smj); + assert_eq!(LanguageCode::from_str("SMN").unwrap(), LanguageCode::Smn); + assert_eq!(LanguageCode::from_str("SMO").unwrap(), LanguageCode::Smo); + assert_eq!(LanguageCode::from_str("SMS").unwrap(), LanguageCode::Sms); + assert_eq!(LanguageCode::from_str("SNA").unwrap(), LanguageCode::Sna); + assert_eq!(LanguageCode::from_str("SND").unwrap(), LanguageCode::Snd); + assert_eq!(LanguageCode::from_str("SNK").unwrap(), LanguageCode::Snk); + assert_eq!(LanguageCode::from_str("SOG").unwrap(), LanguageCode::Sog); + assert_eq!(LanguageCode::from_str("SOM").unwrap(), LanguageCode::Som); + assert_eq!(LanguageCode::from_str("SON").unwrap(), LanguageCode::Son); + assert_eq!(LanguageCode::from_str("SOT").unwrap(), LanguageCode::Sot); + assert_eq!(LanguageCode::from_str("SPA").unwrap(), LanguageCode::Spa); + assert_eq!(LanguageCode::from_str("SRD").unwrap(), LanguageCode::Srd); + assert_eq!(LanguageCode::from_str("SRN").unwrap(), LanguageCode::Srn); + assert_eq!(LanguageCode::from_str("SRP").unwrap(), LanguageCode::Srp); + assert_eq!(LanguageCode::from_str("SRR").unwrap(), LanguageCode::Srr); + assert_eq!(LanguageCode::from_str("SSA").unwrap(), LanguageCode::Ssa); + assert_eq!(LanguageCode::from_str("SSW").unwrap(), LanguageCode::Ssw); + assert_eq!(LanguageCode::from_str("SUK").unwrap(), LanguageCode::Suk); + assert_eq!(LanguageCode::from_str("SUN").unwrap(), LanguageCode::Sun); + assert_eq!(LanguageCode::from_str("SUS").unwrap(), LanguageCode::Sus); + assert_eq!(LanguageCode::from_str("SUX").unwrap(), LanguageCode::Sux); + assert_eq!(LanguageCode::from_str("SWA").unwrap(), LanguageCode::Swa); + assert_eq!(LanguageCode::from_str("SWE").unwrap(), LanguageCode::Swe); + assert_eq!(LanguageCode::from_str("SYC").unwrap(), LanguageCode::Syc); + assert_eq!(LanguageCode::from_str("SYR").unwrap(), LanguageCode::Syr); + assert_eq!(LanguageCode::from_str("TAH").unwrap(), LanguageCode::Tah); + assert_eq!(LanguageCode::from_str("TAI").unwrap(), LanguageCode::Tai); + assert_eq!(LanguageCode::from_str("TAM").unwrap(), LanguageCode::Tam); + assert_eq!(LanguageCode::from_str("TAT").unwrap(), LanguageCode::Tat); + assert_eq!(LanguageCode::from_str("TEL").unwrap(), LanguageCode::Tel); + assert_eq!(LanguageCode::from_str("TEM").unwrap(), LanguageCode::Tem); + assert_eq!(LanguageCode::from_str("TER").unwrap(), LanguageCode::Ter); + assert_eq!(LanguageCode::from_str("TET").unwrap(), LanguageCode::Tet); + assert_eq!(LanguageCode::from_str("TGK").unwrap(), LanguageCode::Tgk); + assert_eq!(LanguageCode::from_str("TGL").unwrap(), LanguageCode::Tgl); + assert_eq!(LanguageCode::from_str("THA").unwrap(), LanguageCode::Tha); + assert_eq!(LanguageCode::from_str("TIB").unwrap(), LanguageCode::Tib); + assert_eq!(LanguageCode::from_str("TIG").unwrap(), LanguageCode::Tig); + assert_eq!(LanguageCode::from_str("TIR").unwrap(), LanguageCode::Tir); + assert_eq!(LanguageCode::from_str("TIV").unwrap(), LanguageCode::Tiv); + assert_eq!(LanguageCode::from_str("TKL").unwrap(), LanguageCode::Tkl); + assert_eq!(LanguageCode::from_str("TLH").unwrap(), LanguageCode::Tlh); + assert_eq!(LanguageCode::from_str("TLI").unwrap(), LanguageCode::Tli); + assert_eq!(LanguageCode::from_str("TMH").unwrap(), LanguageCode::Tmh); + assert_eq!(LanguageCode::from_str("TOG").unwrap(), LanguageCode::Tog); + assert_eq!(LanguageCode::from_str("TON").unwrap(), LanguageCode::Ton); + assert_eq!(LanguageCode::from_str("TPI").unwrap(), LanguageCode::Tpi); + assert_eq!(LanguageCode::from_str("TSI").unwrap(), LanguageCode::Tsi); + assert_eq!(LanguageCode::from_str("TSN").unwrap(), LanguageCode::Tsn); + assert_eq!(LanguageCode::from_str("TSO").unwrap(), LanguageCode::Tso); + assert_eq!(LanguageCode::from_str("TUK").unwrap(), LanguageCode::Tuk); + assert_eq!(LanguageCode::from_str("TUM").unwrap(), LanguageCode::Tum); + assert_eq!(LanguageCode::from_str("TUP").unwrap(), LanguageCode::Tup); + assert_eq!(LanguageCode::from_str("TUR").unwrap(), LanguageCode::Tur); + assert_eq!(LanguageCode::from_str("TUT").unwrap(), LanguageCode::Tut); + assert_eq!(LanguageCode::from_str("TVL").unwrap(), LanguageCode::Tvl); + assert_eq!(LanguageCode::from_str("TWI").unwrap(), LanguageCode::Twi); + assert_eq!(LanguageCode::from_str("TYV").unwrap(), LanguageCode::Tyv); + assert_eq!(LanguageCode::from_str("UDM").unwrap(), LanguageCode::Udm); + assert_eq!(LanguageCode::from_str("UGA").unwrap(), LanguageCode::Uga); + assert_eq!(LanguageCode::from_str("UIG").unwrap(), LanguageCode::Uig); + assert_eq!(LanguageCode::from_str("UKR").unwrap(), LanguageCode::Ukr); + assert_eq!(LanguageCode::from_str("UMB").unwrap(), LanguageCode::Umb); + assert_eq!(LanguageCode::from_str("UND").unwrap(), LanguageCode::Und); + assert_eq!(LanguageCode::from_str("URD").unwrap(), LanguageCode::Urd); + assert_eq!(LanguageCode::from_str("UZB").unwrap(), LanguageCode::Uzb); + assert_eq!(LanguageCode::from_str("VAI").unwrap(), LanguageCode::Vai); + assert_eq!(LanguageCode::from_str("VEN").unwrap(), LanguageCode::Ven); + assert_eq!(LanguageCode::from_str("VIE").unwrap(), LanguageCode::Vie); + assert_eq!(LanguageCode::from_str("VOL").unwrap(), LanguageCode::Vol); + assert_eq!(LanguageCode::from_str("VOT").unwrap(), LanguageCode::Vot); + assert_eq!(LanguageCode::from_str("WAK").unwrap(), LanguageCode::Wak); + assert_eq!(LanguageCode::from_str("WAL").unwrap(), LanguageCode::Wal); + assert_eq!(LanguageCode::from_str("WAR").unwrap(), LanguageCode::War); + assert_eq!(LanguageCode::from_str("WAS").unwrap(), LanguageCode::Was); + assert_eq!(LanguageCode::from_str("WEL").unwrap(), LanguageCode::Wel); + assert_eq!(LanguageCode::from_str("WEN").unwrap(), LanguageCode::Wen); + assert_eq!(LanguageCode::from_str("WLN").unwrap(), LanguageCode::Wln); + assert_eq!(LanguageCode::from_str("WOL").unwrap(), LanguageCode::Wol); + assert_eq!(LanguageCode::from_str("XAL").unwrap(), LanguageCode::Xal); + assert_eq!(LanguageCode::from_str("XHO").unwrap(), LanguageCode::Xho); + assert_eq!(LanguageCode::from_str("YAO").unwrap(), LanguageCode::Yao); + assert_eq!(LanguageCode::from_str("YAP").unwrap(), LanguageCode::Yap); + assert_eq!(LanguageCode::from_str("YID").unwrap(), LanguageCode::Yid); + assert_eq!(LanguageCode::from_str("YOR").unwrap(), LanguageCode::Yor); + assert_eq!(LanguageCode::from_str("YPK").unwrap(), LanguageCode::Ypk); + assert_eq!(LanguageCode::from_str("ZAP").unwrap(), LanguageCode::Zap); + assert_eq!(LanguageCode::from_str("ZBL").unwrap(), LanguageCode::Zbl); + assert_eq!(LanguageCode::from_str("ZEN").unwrap(), LanguageCode::Zen); + assert_eq!(LanguageCode::from_str("ZGH").unwrap(), LanguageCode::Zgh); + assert_eq!(LanguageCode::from_str("ZHA").unwrap(), LanguageCode::Zha); + assert_eq!(LanguageCode::from_str("ZND").unwrap(), LanguageCode::Znd); + assert_eq!(LanguageCode::from_str("ZUL").unwrap(), LanguageCode::Zul); + assert_eq!(LanguageCode::from_str("ZUN").unwrap(), LanguageCode::Zun); + assert_eq!(LanguageCode::from_str("ZXX").unwrap(), LanguageCode::Zxx); + assert_eq!(LanguageCode::from_str("ZZA").unwrap(), LanguageCode::Zza); + + assert!(LanguageRelation::from_str("ESP").is_err()); + assert!(LanguageRelation::from_str("ZZZ").is_err()); + } +} + +#[cfg(feature = "backend")] +mod conversions { + use super::*; + use crate::model::tests::db::setup_test_db; + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn languagecode_graphql_roundtrip() { + assert_graphql_enum_roundtrip(LanguageCode::Eng); + } + + #[test] + fn languagerelation_graphql_roundtrip() { + assert_graphql_enum_roundtrip(LanguageRelation::Original); + } + + #[test] + fn languagecode_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<LanguageCode, crate::schema::sql_types::LanguageCode>( + pool.as_ref(), + "'eng'::language_code", + LanguageCode::Eng, + ); + } + + #[test] + fn languagerelation_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<LanguageRelation, crate::schema::sql_types::LanguageRelation>( + pool.as_ref(), + "'original'::language_relation", + LanguageRelation::Original, + ); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let language: Language = Default::default(); + assert_eq!(language.pk(), language.language_id); + } + + #[test] + fn history_entry_serializes_model() { + let language: Language = Default::default(); + let user_id = "123456".to_string(); + let new_language_history = language.new_history_entry(&user_id); + assert_eq!(new_language_history.language_id, language.language_id); + assert_eq!(new_language_history.user_id, user_id); + assert_eq!( + new_language_history.data, + serde_json::Value::String(serde_json::to_string(&language).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::language::policy::LanguagePolicy; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("language-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let new_language = NewLanguage { + work_id: work.work_id, + language_code: LanguageCode::Eng, + language_relation: LanguageRelation::Original, + }; + + let language = Language::create(pool.as_ref(), &new_language).expect("Failed to create"); + let patch = PatchLanguage { + language_id: language.language_id, + work_id: language.work_id, + language_code: LanguageCode::Spa, + language_relation: LanguageRelation::TranslatedFrom, + }; + + assert!(LanguagePolicy::can_create(&ctx, &new_language, ()).is_ok()); + assert!(LanguagePolicy::can_update(&ctx, &language, &patch, ()).is_ok()); + assert!(LanguagePolicy::can_delete(&ctx, &language).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let language = make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Eng, + LanguageRelation::Original, + ); + let patch = PatchLanguage { + language_id: language.language_id, + work_id: language.work_id, + language_code: LanguageCode::Spa, + language_relation: LanguageRelation::TranslatedFrom, + }; + + let user = test_user_with_role("language-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_language = NewLanguage { + work_id: work.work_id, + language_code: LanguageCode::Eng, + language_relation: LanguageRelation::Original, + }; + + assert!(LanguagePolicy::can_create(&ctx, &new_language, ()).is_err()); + assert!(LanguagePolicy::can_update(&ctx, &language, &patch, ()).is_err()); + assert!(LanguagePolicy::can_delete(&ctx, &language).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::graphql::types::inputs::{Direction, LanguageOrderBy}; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context, + }; + use crate::model::Crud; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let new_language = NewLanguage { + work_id: work.work_id, + language_code: LanguageCode::Eng, + language_relation: LanguageRelation::Original, + }; + + let language = Language::create(pool.as_ref(), &new_language).expect("Failed to create"); + let fetched = + Language::from_id(pool.as_ref(), &language.language_id).expect("Failed to fetch"); + assert_eq!(language.language_id, fetched.language_id); + + let patch = PatchLanguage { + language_id: language.language_id, + work_id: language.work_id, + language_code: LanguageCode::Spa, + language_relation: LanguageRelation::TranslatedFrom, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = language.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.language_code, patch.language_code); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Language::from_id(pool.as_ref(), &deleted.language_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Eng, + LanguageRelation::Original, + ); + make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Spa, + LanguageRelation::TranslatedFrom, + ); + + let order = LanguageOrderBy { + field: LanguageField::LanguageId, + direction: Direction::Asc, + }; + + let first = Language::all( + pool.as_ref(), + 1, + 0, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch languages"); + let second = Language::all( + pool.as_ref(), + 1, + 1, + None, + LanguageOrderBy { + field: LanguageField::LanguageId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch languages"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].language_id, second[0].language_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Eng, + LanguageRelation::Original, + ); + make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Spa, + LanguageRelation::TranslatedFrom, + ); + + let count = Language::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count languages"); + assert_eq!(count, 2); + } + + #[test] + fn crud_count_filters_by_language_code() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Eng, + LanguageRelation::Original, + ); + make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Spa, + LanguageRelation::TranslatedFrom, + ); + + let count = Language::count( + pool.as_ref(), + None, + vec![], + vec![LanguageCode::Eng], + vec![], + None, + None, + ) + .expect("Failed to count languages by code"); + assert_eq!(count, 1); + } + + #[test] + fn crud_count_filters_by_language_relation() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Eng, + LanguageRelation::Original, + ); + make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Spa, + LanguageRelation::TranslatedFrom, + ); + + let count = Language::count( + pool.as_ref(), + None, + vec![], + vec![], + vec![LanguageRelation::Original], + None, + None, + ) + .expect("Failed to count languages by relation"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_filter_param_limits_language_codes() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let matches = make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Eng, + LanguageRelation::Original, + ); + make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Spa, + LanguageRelation::Original, + ); + + let filtered = Language::all( + pool.as_ref(), + 10, + 0, + None, + LanguageOrderBy { + field: LanguageField::LanguageId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![LanguageCode::Eng], + vec![], + None, + None, + ) + .expect("Failed to filter languages by code"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].language_id, matches.language_id); + } + + #[test] + fn crud_filter_param_limits_language_relations() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let matches = make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Eng, + LanguageRelation::Original, + ); + make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Spa, + LanguageRelation::TranslatedFrom, + ); + + let filtered = Language::all( + pool.as_ref(), + 10, + 0, + None, + LanguageOrderBy { + field: LanguageField::LanguageId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![LanguageRelation::Original], + None, + None, + ) + .expect("Failed to filter languages by relation"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].language_id, matches.language_id); + } + + #[test] + fn crud_filter_parent_work_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + + let matches = make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Eng, + LanguageRelation::Original, + ); + make_language( + pool.as_ref(), + other_work.work_id, + LanguageCode::Spa, + LanguageRelation::TranslatedFrom, + ); + + let filtered = Language::all( + pool.as_ref(), + 10, + 0, + None, + LanguageOrderBy { + field: LanguageField::LanguageId, + direction: Direction::Asc, + }, + vec![], + Some(work.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter languages by work"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].language_id, matches.language_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let matches = make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Eng, + LanguageRelation::Original, + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let other_work = create_work(pool.as_ref(), &other_imprint); + make_language( + pool.as_ref(), + other_work.work_id, + LanguageCode::Spa, + LanguageRelation::TranslatedFrom, + ); + + let filtered = Language::all( + pool.as_ref(), + 10, + 0, + None, + LanguageOrderBy { + field: LanguageField::LanguageId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter languages by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].language_id, matches.language_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let first = make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Eng, + LanguageRelation::Original, + ); + let second = make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Spa, + LanguageRelation::TranslatedFrom, + ); + let mut ids = [first.language_id, second.language_id]; + ids.sort(); + + let asc = Language::all( + pool.as_ref(), + 2, + 0, + None, + LanguageOrderBy { + field: LanguageField::LanguageId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order languages (asc)"); + + let desc = Language::all( + pool.as_ref(), + 2, + 0, + None, + LanguageOrderBy { + field: LanguageField::LanguageId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order languages (desc)"); + + assert_eq!(asc[0].language_id, ids[0]); + assert_eq!(desc[0].language_id, ids[1]); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Eng, + LanguageRelation::Original, + ); + make_language( + pool.as_ref(), + work.work_id, + LanguageCode::Spa, + LanguageRelation::TranslatedFrom, + ); + + let fields: Vec<fn() -> LanguageField> = vec![ + || LanguageField::LanguageId, + || LanguageField::WorkId, + || LanguageField::LanguageCode, + || LanguageField::LanguageRelation, + || LanguageField::CreatedAt, + || LanguageField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Language::all( + pool.as_ref(), + 10, + 0, + None, + LanguageOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order languages"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/locale/mod.rs b/thoth-api/src/model/locale/mod.rs new file mode 100644 index 00000000..42ff21fa --- /dev/null +++ b/thoth-api/src/model/locale/mod.rs @@ -0,0 +1,2510 @@ +use serde::{Deserialize, Serialize}; +use strum::Display; +use strum::EnumIter; +use strum::EnumString; + +use crate::model::language::LanguageCode; + +#[cfg_attr( + feature = "backend", + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), + graphql(description = "BCP-47 code representing locale"), + ExistingTypePath = "crate::schema::sql_types::LocaleCode" +)] +#[derive( + Debug, + Copy, + Clone, + Default, + PartialEq, + Eq, + Deserialize, + Serialize, + EnumString, + Display, + EnumIter, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "UPPERCASE")] +pub enum LocaleCode { + #[default] + #[cfg_attr(feature = "backend", graphql(description = "English"))] + En, + #[cfg_attr(feature = "backend", graphql(description = "Afrikaans (af)"))] + Af, + #[cfg_attr( + feature = "backend", + graphql(description = "Afrikaans (Namibia) (af-NA)") + )] + AfNa, + #[cfg_attr( + feature = "backend", + graphql(description = "Afrikaans (South Africa) (af-ZA)") + )] + AfZa, + #[cfg_attr(feature = "backend", graphql(description = "Aghem (agq)"))] + Agq, + #[cfg_attr( + feature = "backend", + graphql(description = "Aghem (Cameroon) (agq-CM)") + )] + AgqCm, + #[cfg_attr(feature = "backend", graphql(description = "Akan (ak)"))] + Ak, + #[cfg_attr(feature = "backend", graphql(description = "Akan (Ghana) (ak-GH)"))] + AkGh, + #[cfg_attr(feature = "backend", graphql(description = "Albanian (sq)"))] + Sq, + #[cfg_attr( + feature = "backend", + graphql(description = "Albanian (Albania) (sq-AL)") + )] + SqAl, + #[cfg_attr(feature = "backend", graphql(description = "Amharic (am)"))] + Am, + #[cfg_attr( + feature = "backend", + graphql(description = "Amharic (Ethiopia) (am-ET)") + )] + AmEt, + #[cfg_attr( + feature = "backend", + graphql(description = "Antigua and Barbuda Creole English") + )] + Aig, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (ar)"))] + Ar, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Algeria) (ar-DZ)"))] + ArDz, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Bahrain) (ar-BH)"))] + ArBh, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Egypt) (ar-EG)"))] + ArEg, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Iraq) (ar-IQ)"))] + ArIq, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Jordan) (ar-JO)"))] + ArJo, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Kuwait) (ar-KW)"))] + ArKw, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Lebanon) (ar-LB)"))] + ArLb, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Libya) (ar-LY)"))] + ArLy, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Morocco) (ar-MA)"))] + ArMa, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Oman) (ar-OM)"))] + ArOm, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Qatar) (ar-QA)"))] + ArQa, + #[cfg_attr( + feature = "backend", + graphql(description = "Arabic (Saudi Arabia) (ar-SA)") + )] + ArSa, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Sudan) (ar-SD)"))] + ArSd, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Syria) (ar-SY)"))] + ArSy, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Tunisia) (ar-TN)"))] + ArTn, + #[cfg_attr( + feature = "backend", + graphql(description = "Arabic (United Arab Emirates) (ar-AE)") + )] + ArAe, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (World) (ar-001)"))] + Ar001, + #[cfg_attr(feature = "backend", graphql(description = "Arabic (Yemen) (ar-YE)"))] + ArYe, + #[cfg_attr(feature = "backend", graphql(description = "Armenian (hy)"))] + Hy, + #[cfg_attr( + feature = "backend", + graphql(description = "Armenian (Armenia) (hy-AM)") + )] + HyAm, + #[cfg_attr(feature = "backend", graphql(description = "Assamese (as)"))] + As, + #[cfg_attr(feature = "backend", graphql(description = "Assamese (India) (as-IN)"))] + AsIn, + #[cfg_attr(feature = "backend", graphql(description = "Asturian (ast)"))] + Ast, + #[cfg_attr( + feature = "backend", + graphql(description = "Asturian (Spain) (ast-ES)") + )] + AstEs, + #[cfg_attr(feature = "backend", graphql(description = "Asu (asa)"))] + Asa, + #[cfg_attr(feature = "backend", graphql(description = "Asu (Tanzania) (asa-TZ)"))] + AsaTz, + #[cfg_attr(feature = "backend", graphql(description = "Azerbaijani (az)"))] + Az, + #[cfg_attr( + feature = "backend", + graphql(description = "Azerbaijani (Cyrillic) (az-Cyrl)") + )] + AzCyrl, + #[cfg_attr( + feature = "backend", + graphql(description = "Azerbaijani (Cyrillic, Azerbaijan) (az-Cyrl-AZ)") + )] + AzCyrlAz, + #[cfg_attr( + feature = "backend", + graphql(description = "Azerbaijani (Latin) (az-Latn)") + )] + AzLatn, + #[cfg_attr( + feature = "backend", + graphql(description = "Azerbaijani (Latin, Azerbaijan) (az-Latn-AZ)") + )] + AzLatnAz, + #[cfg_attr(feature = "backend", graphql(description = "Bafia (ksf)"))] + Ksf, + #[cfg_attr( + feature = "backend", + graphql(description = "Bafia (Cameroon) (ksf-CM)") + )] + KsfCm, + #[cfg_attr(feature = "backend", graphql(description = "Bahamas Creole English"))] + Bah, + #[cfg_attr(feature = "backend", graphql(description = "Bambara (bm)"))] + Bm, + #[cfg_attr(feature = "backend", graphql(description = "Bambara (Mali) (bm-ML)"))] + BmMl, + #[cfg_attr(feature = "backend", graphql(description = "Basaa (bas)"))] + Bas, + #[cfg_attr( + feature = "backend", + graphql(description = "Basaa (Cameroon) (bas-CM)") + )] + BasCm, + #[cfg_attr(feature = "backend", graphql(description = "Basque (eu)"))] + Eu, + #[cfg_attr(feature = "backend", graphql(description = "Basque (Spain) (eu-ES)"))] + EuEs, + #[cfg_attr(feature = "backend", graphql(description = "Belarusian (be)"))] + Be, + #[cfg_attr( + feature = "backend", + graphql(description = "Belarusian (Belarus) (be-BY)") + )] + BeBy, + #[cfg_attr(feature = "backend", graphql(description = "Bemba (bem)"))] + Bem, + #[cfg_attr(feature = "backend", graphql(description = "Bemba (Zambia) (bem-ZM)"))] + BemZm, + #[cfg_attr(feature = "backend", graphql(description = "Bena (bez)"))] + Bez, + #[cfg_attr(feature = "backend", graphql(description = "Bena (Tanzania) (bez-TZ)"))] + BezTz, + #[cfg_attr(feature = "backend", graphql(description = "Bengali (bn)"))] + Bn, + #[cfg_attr( + feature = "backend", + graphql(description = "Bengali (Bangladesh) (bn-BD)") + )] + BnBd, + #[cfg_attr(feature = "backend", graphql(description = "Bengali (India) (bn-IN)"))] + BnIn, + #[cfg_attr(feature = "backend", graphql(description = "Bodo (brx)"))] + Brx, + #[cfg_attr(feature = "backend", graphql(description = "Bodo (India) (brx-IN)"))] + BrxIn, + #[cfg_attr(feature = "backend", graphql(description = "Bosnian (bs)"))] + Bs, + #[cfg_attr( + feature = "backend", + graphql(description = "Bosnian (Bosnia and Herzegovina) (bs-BA)") + )] + BsBa, + #[cfg_attr(feature = "backend", graphql(description = "Breton (br)"))] + Br, + #[cfg_attr(feature = "backend", graphql(description = "Breton (France) (br-FR)"))] + BrFr, + #[cfg_attr(feature = "backend", graphql(description = "Bulgarian (bg)"))] + Bg, + #[cfg_attr( + feature = "backend", + graphql(description = "Bulgarian (Bulgaria) (bg-BG)") + )] + BgBg, + #[cfg_attr(feature = "backend", graphql(description = "Burmese (my)"))] + My, + #[cfg_attr( + feature = "backend", + graphql(description = "Burmese (Myanmar [Burma]) (my-MM)") + )] + MyMm, + #[cfg_attr(feature = "backend", graphql(description = "Catalan (ca)"))] + Ca, + #[cfg_attr(feature = "backend", graphql(description = "Catalan (Spain) (ca-ES)"))] + CaEs, + #[cfg_attr(feature = "backend", graphql(description = "Central Kurdish (ckb)"))] + Ckb, + #[cfg_attr(feature = "backend", graphql(description = "Northern Kurdish (kmr)"))] + Kmr, + #[cfg_attr(feature = "backend", graphql(description = "Southern Kurdish (sdh)"))] + Sdh, + #[cfg_attr( + feature = "backend", + graphql(description = "Central Morocco Tamazight (tzm)") + )] + Tzm, + #[cfg_attr( + feature = "backend", + graphql(description = "Central Morocco Tamazight (Latin) (tzm-Latn)") + )] + TzmLatn, + #[cfg_attr( + feature = "backend", + graphql(description = "Central Morocco Tamazight (Latin, Morocco) (tzm-Latn-MA) ") + )] + TzmLatnMa, + #[cfg_attr(feature = "backend", graphql(description = "Cherokee (chr)"))] + Chr, + #[cfg_attr( + feature = "backend", + graphql(description = "Cherokee (United States) (chr-US)") + )] + ChrUs, + #[cfg_attr(feature = "backend", graphql(description = "Chiga (cgg)"))] + Cgg, + #[cfg_attr(feature = "backend", graphql(description = "Chiga (Uganda) (cgg-UG)"))] + CggUg, + #[cfg_attr(feature = "backend", graphql(description = "Chinese (zh)"))] + Zh, + #[cfg_attr( + feature = "backend", + graphql(description = "Chinese (Simplified) (zh-Hans)") + )] + ZhHans, + #[cfg_attr( + feature = "backend", + graphql(description = "Chinese (Simplified, China) (zh-CN)") + )] + ZhCn, + #[cfg_attr( + feature = "backend", + graphql(description = "Chinese (Simplified, China) (zh-Hans-CN)") + )] + ZhHansCn, + #[cfg_attr( + feature = "backend", + graphql(description = "Chinese (Simplified, Hong Kong SAR China) (zh-Hans-HK)") + )] + ZhHansHk, + #[cfg_attr( + feature = "backend", + graphql(description = "Chinese (Simplified, Macau SAR China) (zh-Hans-MO) ") + )] + ZhHansMo, + #[cfg_attr( + feature = "backend", + graphql(description = "Chinese (Simplified, Singapore) (zh-Hans-SG)") + )] + ZhHansSg, + #[cfg_attr( + feature = "backend", + graphql(description = "Chinese (Traditional) (zh-Hant)") + )] + ZhHant, + #[cfg_attr( + feature = "backend", + graphql(description = "Chinese (Traditional, Hong Kong SAR China) (zh-Hant-HK) ") + )] + ZhHantHk, + #[cfg_attr( + feature = "backend", + graphql(description = "Chinese (Traditional, Macau SAR China) (zh-Hant-MO) ") + )] + ZhHantMo, + #[cfg_attr( + feature = "backend", + graphql(description = "Chinese (Traditional, Taiwan) (zh-Hant-TW)") + )] + ZhHantTw, + #[cfg_attr(feature = "backend", graphql(description = "Congo Swahili (swc)"))] + Swc, + #[cfg_attr( + feature = "backend", + graphql(description = "Congo Swahili (Congo - Kinshasa) (swc-CD)") + )] + SwcCd, + #[cfg_attr(feature = "backend", graphql(description = "Cornish (kw)"))] + Kw, + #[cfg_attr( + feature = "backend", + graphql(description = "Cornish (United Kingdom) (kw-GB)") + )] + KwGb, + #[cfg_attr(feature = "backend", graphql(description = "Croatian (hr)"))] + Hr, + #[cfg_attr( + feature = "backend", + graphql(description = "Croatian (Croatia) (hr-HR)") + )] + HrHr, + #[cfg_attr(feature = "backend", graphql(description = "Czech (cs)"))] + Cs, + #[cfg_attr( + feature = "backend", + graphql(description = "Czech (Czech Republic) (cs-CZ)") + )] + CsCz, + #[cfg_attr(feature = "backend", graphql(description = "Danish (da)"))] + Da, + #[cfg_attr(feature = "backend", graphql(description = "Danish (Denmark) (da-DK)"))] + DaDk, + #[cfg_attr(feature = "backend", graphql(description = "Duala (dua)"))] + Dua, + #[cfg_attr( + feature = "backend", + graphql(description = "Duala (Cameroon) (dua-CM)") + )] + DuaCm, + #[cfg_attr(feature = "backend", graphql(description = "Dhivehi (Maldives)"))] + Dv, + #[cfg_attr(feature = "backend", graphql(description = "Dutch (nl)"))] + Nl, + #[cfg_attr(feature = "backend", graphql(description = "Dutch (Aruba) (nl-AW)"))] + NlAw, + #[cfg_attr(feature = "backend", graphql(description = "Dutch (Belgium) (nl-BE)"))] + NlBe, + #[cfg_attr(feature = "backend", graphql(description = "Dutch (Curaçao) (nl-CW)"))] + NlCw, + #[cfg_attr( + feature = "backend", + graphql(description = "Dutch (Netherlands) (nl-NL)") + )] + NlNl, + #[cfg_attr( + feature = "backend", + graphql(description = "Dutch (Sint Maarten) (nl-SX)") + )] + NlSx, + #[cfg_attr(feature = "backend", graphql(description = "Embu (ebu)"))] + Ebu, + #[cfg_attr(feature = "backend", graphql(description = "Embu (Kenya) (ebu-KE)"))] + EbuKe, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Anguilla) (en-AI)") + )] + EnAi, + #[cfg_attr( + feature = "backend", + graphql(description = "English (American Samoa) (en-AS)") + )] + EnAs, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Australia) (en-AU)") + )] + EnAu, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Austria) (en-AT)") + )] + EnAt, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Barbados) (en-BB)") + )] + EnBb, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Belgium) (en-BE)") + )] + EnBe, + #[cfg_attr(feature = "backend", graphql(description = "English (Belize) (en-BZ)"))] + EnBz, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Bermuda) (en-BM)") + )] + EnBm, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Botswana) (en-BW)") + )] + EnBw, + #[cfg_attr( + feature = "backend", + graphql(description = "English (British Indian Ocean Territory) (en-IO)") + )] + EnIo, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Burundi) (en-BI)") + )] + EnBi, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Cameroon) (en-CM)") + )] + EnCm, + #[cfg_attr(feature = "backend", graphql(description = "English (Canada) (en-CA)"))] + EnCa, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Cayman Islands) (en-KY)") + )] + EnKy, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Christmas Island) (en-CX)") + )] + EnCx, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Cocos [Keeling] Islands) (en-CC)") + )] + EnCc, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Cook Islands) (en-CK)") + )] + EnCk, + #[cfg_attr(feature = "backend", graphql(description = "English (Cyprus) (en-CY)"))] + EnCy, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Denmark) (en-DK)") + )] + EnDk, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Diego Garcia) (en-DG)") + )] + EnDg, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Dominica) (en-DM)") + )] + EnDm, + #[cfg_attr(feature = "backend", graphql(description = "English (Egypt) (en-EG)"))] + EnEg, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Eritrea) (en-ER)") + )] + EnEr, + #[cfg_attr(feature = "backend", graphql(description = "English (Europe) (en-EU)"))] + EnEu, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Falkland Islands) (en-FK)") + )] + EnFk, + #[cfg_attr(feature = "backend", graphql(description = "English (Fiji) (en-FJ)"))] + EnFj, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Finland) (en-FI)") + )] + EnFi, + #[cfg_attr(feature = "backend", graphql(description = "English (Gambia) (en-GM)"))] + EnGm, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Germany) (en-DE)") + )] + EnDe, + #[cfg_attr(feature = "backend", graphql(description = "English (Ghana) (en-GH)"))] + EnGh, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Gibraltar) (en-GI)") + )] + EnGi, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Grenada) (en-GD)") + )] + EnGd, + #[cfg_attr(feature = "backend", graphql(description = "English (Guam) (en-GU)"))] + EnGu, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Guernsey) (en-GG)") + )] + EnGg, + #[cfg_attr(feature = "backend", graphql(description = "English (Guyana) (en-GY)"))] + EnGy, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Hong Kong SAR China) (en-HK)") + )] + EnHk, + #[cfg_attr(feature = "backend", graphql(description = "English (India) (en-IN)"))] + EnIn, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Ireland) (en-IE)") + )] + EnIe, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Isle of Man) (en-IM)") + )] + EnIm, + #[cfg_attr(feature = "backend", graphql(description = "English (Israel) (en-IL)"))] + EnIl, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Jamaica) (en-JM)") + )] + EnJm, + #[cfg_attr(feature = "backend", graphql(description = "English (Jersey) (en-JE)"))] + EnJe, + #[cfg_attr(feature = "backend", graphql(description = "English (Kenya) (en-KE)"))] + EnKe, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Kiribati) (en-KI)") + )] + EnKi, + #[cfg_attr(feature = "backend", graphql(description = "English (Kuwait) (en-KW)"))] + EnKw, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Lesotho) (en-LS)") + )] + EnLs, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Macao SAR China) (en-MO)") + )] + EnMo, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Madagascar) (en-MG)") + )] + EnMg, + #[cfg_attr(feature = "backend", graphql(description = "English (Malawi) (en-MW)"))] + EnMw, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Malaysia) (en-MY)") + )] + EnMy, + #[cfg_attr(feature = "backend", graphql(description = "English (Malta) (en-MT)"))] + EnMt, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Marshall Islands) (en-MH)") + )] + EnMh, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Mauritius) (en-MU)") + )] + EnMu, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Micronesia) (en-FM)") + )] + EnFm, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Montserrat) (en-MS)") + )] + EnMs, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Namibia) (en-NA)") + )] + EnNa, + #[cfg_attr(feature = "backend", graphql(description = "English (Nauru) (en-NR)"))] + EnNr, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Netherlands) (en-NL)") + )] + EnNl, + #[cfg_attr( + feature = "backend", + graphql(description = "English (New Zealand) (en-NZ)") + )] + EnNz, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Nigeria) (en-NG)") + )] + EnNg, + #[cfg_attr(feature = "backend", graphql(description = "English (Niue) (en-NU)"))] + EnNu, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Norfolk Island) (en-NF)") + )] + EnNf, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Northern Mariana Islands) (en-MP)") + )] + EnMp, + #[cfg_attr(feature = "backend", graphql(description = "English (Norway) (en-NO)"))] + EnNo, + #[cfg_attr(feature = "backend", graphql(description = "English (Panama) (en-PA)"))] + EnPa, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Pakistan) (en-PK)") + )] + EnPk, + #[cfg_attr(feature = "backend", graphql(description = "English (Palau) (en-PW)"))] + EnPw, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Papua New Guinea) (en-PG)") + )] + EnPg, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Philippines) (en-PH)") + )] + EnPh, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Pitcairn Islands) (en-PN)") + )] + EnPn, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Puerto Rico) (en-PR)") + )] + EnPr, + #[cfg_attr(feature = "backend", graphql(description = "English (Rwanda) (en-RW)"))] + EnRw, + #[cfg_attr(feature = "backend", graphql(description = "English (Samoa) (en-WS)"))] + EnWs, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Saudi Arabia) (en-SA)") + )] + EnSa, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Seychelles) (en-SC)") + )] + EnSc, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Sierra Leone) (en-SL)") + )] + EnSl, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Singapore) (en-SG)") + )] + EnSg, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Sint Maarten) (en-SX)") + )] + EnSx, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Slovenia) (en-SI)") + )] + EnSi, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Solomon Islands) (en-SB)") + )] + EnSb, + #[cfg_attr( + feature = "backend", + graphql(description = "English (South Sudan) (en-SS)") + )] + EnSs, + #[cfg_attr( + feature = "backend", + graphql(description = "English (St Helena) (en-SH)") + )] + EnSh, + #[cfg_attr( + feature = "backend", + graphql(description = "English (St Kitts & Nevis) (en-KN)") + )] + EnKn, + #[cfg_attr( + feature = "backend", + graphql(description = "English (St Lucia) (en-LC)") + )] + EnLc, + #[cfg_attr( + feature = "backend", + graphql(description = "Vincentian Creole English") + )] + Svc, + #[cfg_attr( + feature = "backend", + graphql(description = "Virgin Islands Creole English") + )] + Vic, + #[cfg_attr(feature = "backend", graphql(description = "English (Sudan) (en-SD)"))] + EnSd, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Swaziland) (en-SZ)") + )] + EnSz, + #[cfg_attr(feature = "backend", graphql(description = "English (Sweden) (en-SE)"))] + EnSe, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Switzerland) (en-CH)") + )] + EnCh, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Tanzania) (en-TZ)") + )] + EnTz, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Tokelau) (en-TK)") + )] + EnTk, + #[cfg_attr(feature = "backend", graphql(description = "English (Tonga) (en-TO)"))] + EnTo, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Trinidad and Tobago) (en-TT)") + )] + EnTt, + #[cfg_attr(feature = "backend", graphql(description = "English (Tuvalu) (en-TV)"))] + EnTv, + #[cfg_attr( + feature = "backend", + graphql(description = "English (South Africa) (en-ZA)") + )] + EnZa, + #[cfg_attr(feature = "backend", graphql(description = "English (U.A.E.) (en-AE)"))] + EnAe, + #[cfg_attr( + feature = "backend", + graphql(description = "English (U.S. Minor Outlying Islands) (en-UM)") + )] + EnUm, + #[cfg_attr( + feature = "backend", + graphql(description = "English (U.S. Virgin Islands) (en-VI)") + )] + EnVi, + #[cfg_attr( + feature = "backend", + graphql(description = "English (U.S., Computer) (en-US-POSIX)") + )] + EnUsPosix, + #[cfg_attr(feature = "backend", graphql(description = "English (Uganda) (en-UG)"))] + EnUg, + #[cfg_attr( + feature = "backend", + graphql(description = "English (United Kingdom) (en-GB)") + )] + EnGb, + #[cfg_attr( + feature = "backend", + graphql(description = "English (United States) (en-US)") + )] + EnUs, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Vanuatu) (en-VU)") + )] + EnVu, + #[cfg_attr(feature = "backend", graphql(description = "English (Zambia) (en-ZM)"))] + EnZm, + #[cfg_attr( + feature = "backend", + graphql(description = "English (Zimbabwe) (en-ZW)") + )] + EnZw, + #[cfg_attr(feature = "backend", graphql(description = "Esperanto (eo)"))] + Eo, + #[cfg_attr(feature = "backend", graphql(description = "Estonian (et)"))] + Et, + #[cfg_attr( + feature = "backend", + graphql(description = "Estonian (Estonia) (et-EE)") + )] + EtEe, + #[cfg_attr(feature = "backend", graphql(description = "Ewe (ee)"))] + Ee, + #[cfg_attr(feature = "backend", graphql(description = "Ewe (Ghana) (ee-GH)"))] + EeGh, + #[cfg_attr(feature = "backend", graphql(description = "Ewe (Togo) (ee-TG)"))] + EeTg, + #[cfg_attr(feature = "backend", graphql(description = "Ewondo (ewo)"))] + Ewo, + #[cfg_attr( + feature = "backend", + graphql(description = "Ewondo (Cameroon) (ewo-CM)") + )] + EwoCm, + #[cfg_attr(feature = "backend", graphql(description = "Faroese (fo)"))] + Fo, + #[cfg_attr( + feature = "backend", + graphql(description = "Faroese (Faroe Islands) (fo-FO)") + )] + FoFo, + #[cfg_attr(feature = "backend", graphql(description = "Filipino (fil)"))] + Fil, + #[cfg_attr( + feature = "backend", + graphql(description = "Filipino (Philippines) (fil-PH)") + )] + FilPh, + #[cfg_attr(feature = "backend", graphql(description = "Finnish (fi)"))] + Fi, + #[cfg_attr( + feature = "backend", + graphql(description = "Finnish (Finland) (fi-FI)") + )] + FiFi, + #[cfg_attr(feature = "backend", graphql(description = "French (fr)"))] + Fr, + #[cfg_attr(feature = "backend", graphql(description = "French (Belgium) (fr-BE)"))] + FrBe, + #[cfg_attr(feature = "backend", graphql(description = "French (Benin) (fr-BJ)"))] + FrBj, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Burkina Faso) (fr-BF)") + )] + FrBf, + #[cfg_attr(feature = "backend", graphql(description = "French (Burundi) (fr-BI)"))] + FrBi, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Cameroon) (fr-CM)") + )] + FrCm, + #[cfg_attr(feature = "backend", graphql(description = "French (Canada) (fr-CA)"))] + FrCa, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Central African Republic) (fr-CF)") + )] + FrCf, + #[cfg_attr(feature = "backend", graphql(description = "French (Chad) (fr-TD)"))] + FrTd, + #[cfg_attr(feature = "backend", graphql(description = "French (Comoros) (fr-KM)"))] + FrKm, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Congo - Brazzaville) (fr-CG)") + )] + FrCg, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Congo - Kinshasa) (fr-CD)") + )] + FrCd, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Côte d'Ivoire) (fr-CI)") + )] + FrCi, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Djibouti) (fr-DJ)") + )] + FrDj, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Equatorial Guinea) (fr-GQ)") + )] + FrGq, + #[cfg_attr(feature = "backend", graphql(description = "French (France) (fr-FR)"))] + FrFr, + #[cfg_attr( + feature = "backend", + graphql(description = "French (French Guiana) (fr-GF)") + )] + FrGf, + #[cfg_attr(feature = "backend", graphql(description = "French (Gabon) (fr-GA)"))] + FrGa, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Guadeloupe) (fr-GP)") + )] + FrGp, + #[cfg_attr(feature = "backend", graphql(description = "French (Guinea) (fr-GN)"))] + FrGn, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Luxembourg) (fr-LU)") + )] + FrLu, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Madagascar) (fr-MG)") + )] + FrMg, + #[cfg_attr(feature = "backend", graphql(description = "French (Mali) (fr-ML)"))] + FrMl, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Martinique) (fr-MQ)") + )] + FrMq, + #[cfg_attr(feature = "backend", graphql(description = "French (Mayotte) (fr-YT)"))] + FrYt, + #[cfg_attr(feature = "backend", graphql(description = "French (Monaco) (fr-MC)"))] + FrMc, + #[cfg_attr(feature = "backend", graphql(description = "French (Niger) (fr-NE)"))] + FrNe, + #[cfg_attr(feature = "backend", graphql(description = "French (Rwanda) (fr-RW)"))] + FrRw, + #[cfg_attr(feature = "backend", graphql(description = "French (Réunion) (fr-RE)"))] + FrRe, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Saint Barthélemy) (fr-BL)") + )] + FrBl, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Saint Martin) (fr-MF)") + )] + FrMf, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Mauritius) (fr-MU)") + )] + FrMu, + #[cfg_attr(feature = "backend", graphql(description = "French (Senegal) (fr-SN)"))] + FrSn, + #[cfg_attr( + feature = "backend", + graphql(description = "French (Switzerland) (fr-CH)") + )] + FrCh, + #[cfg_attr(feature = "backend", graphql(description = "French (Togo) (fr-TG)"))] + FrTg, + #[cfg_attr(feature = "backend", graphql(description = "Fulah (ff)"))] + Ff, + #[cfg_attr(feature = "backend", graphql(description = "Fulah (Senegal) (ff-SN)"))] + FfSn, + #[cfg_attr(feature = "backend", graphql(description = "Galician (gl)"))] + Gl, + #[cfg_attr(feature = "backend", graphql(description = "Galician (Spain) (gl-ES)"))] + GlEs, + #[cfg_attr(feature = "backend", graphql(description = "Laotian (Laos) (lao)"))] + Lao, + #[cfg_attr(feature = "backend", graphql(description = "Ganda (lg)"))] + Lg, + #[cfg_attr(feature = "backend", graphql(description = "Ganda (Uganda) (lg-UG)"))] + LgUg, + #[cfg_attr(feature = "backend", graphql(description = "Georgian (ka)"))] + Ka, + #[cfg_attr( + feature = "backend", + graphql(description = "Georgian (Georgia) (ka-GE)") + )] + KaGe, + #[cfg_attr(feature = "backend", graphql(description = "German (de)"))] + De, + #[cfg_attr(feature = "backend", graphql(description = "German (Austria) (de-AT)"))] + DeAt, + #[cfg_attr(feature = "backend", graphql(description = "German (Belgium) (de-BE)"))] + DeBe, + #[cfg_attr(feature = "backend", graphql(description = "German (Germany) (de-DE)"))] + DeDe, + #[cfg_attr( + feature = "backend", + graphql(description = "German (Liechtenstein) (de-LI)") + )] + DeLi, + #[cfg_attr( + feature = "backend", + graphql(description = "German (Luxembourg) (de-LU)") + )] + DeLu, + #[cfg_attr( + feature = "backend", + graphql(description = "German (Switzerland) (de-CH)") + )] + DeCh, + #[cfg_attr(feature = "backend", graphql(description = "Greek (el)"))] + El, + #[cfg_attr(feature = "backend", graphql(description = "Greek (Cyprus) (el-CY)"))] + ElCy, + #[cfg_attr(feature = "backend", graphql(description = "Greek (Greece) (el-GR)"))] + ElGr, + #[cfg_attr(feature = "backend", graphql(description = "Gujarati (gu)"))] + Gu, + #[cfg_attr(feature = "backend", graphql(description = "Gujarati (India) (gu-IN)"))] + GuIn, + #[cfg_attr(feature = "backend", graphql(description = "Gusii (guz)"))] + Guz, + #[cfg_attr(feature = "backend", graphql(description = "Gusii (Kenya) (guz-KE)"))] + GuzKe, + #[cfg_attr(feature = "backend", graphql(description = "Hausa (ha)"))] + Ha, + #[cfg_attr(feature = "backend", graphql(description = "Hausa (Latin) (ha-Latn)"))] + HaLatn, + #[cfg_attr( + feature = "backend", + graphql(description = "Hausa (Latin, Ghana) (ha-Latn-GH)") + )] + HaLatnGh, + #[cfg_attr( + feature = "backend", + graphql(description = "Hausa (Latin, Niger) (ha-Latn-NE)") + )] + HaLatnNe, + #[cfg_attr( + feature = "backend", + graphql(description = "Hausa (Latin, Nigeria) (ha-Latn-NG)") + )] + HaLatnNg, + #[cfg_attr(feature = "backend", graphql(description = "Hawaiian (haw)"))] + Haw, + #[cfg_attr( + feature = "backend", + graphql(description = "Hawaiian (United States) (haw-US)") + )] + HawUs, + #[cfg_attr(feature = "backend", graphql(description = "Hebrew (he)"))] + He, + #[cfg_attr(feature = "backend", graphql(description = "Hebrew (Israel) (he-IL)"))] + HeIl, + #[cfg_attr(feature = "backend", graphql(description = "Hindi (hi)"))] + Hi, + #[cfg_attr(feature = "backend", graphql(description = "Hindi (India) (hi-IN)"))] + HiIn, + #[cfg_attr(feature = "backend", graphql(description = "Hungarian (hu)"))] + Hu, + #[cfg_attr( + feature = "backend", + graphql(description = "Hungarian (Hungary) (hu-HU)") + )] + HuHu, + #[cfg_attr(feature = "backend", graphql(description = "Icelandic (is)"))] + Is, + #[cfg_attr( + feature = "backend", + graphql(description = "Icelandic (Iceland) (is-IS)") + )] + IsIs, + #[cfg_attr(feature = "backend", graphql(description = "Igbo (ig)"))] + Ig, + #[cfg_attr(feature = "backend", graphql(description = "Igbo (Nigeria) (ig-NG)"))] + IgNg, + #[cfg_attr(feature = "backend", graphql(description = "Inari Sami"))] + Smn, + #[cfg_attr(feature = "backend", graphql(description = "Inari Sami (Finland)"))] + SmnFi, + #[cfg_attr(feature = "backend", graphql(description = "Indonesian (id)"))] + Id, + #[cfg_attr( + feature = "backend", + graphql(description = "Indonesian (Indonesia) (id-ID)") + )] + IdId, + #[cfg_attr(feature = "backend", graphql(description = "Irish (ga)"))] + Ga, + #[cfg_attr(feature = "backend", graphql(description = "Irish (Ireland) (ga-IE)"))] + GaIe, + #[cfg_attr(feature = "backend", graphql(description = "Italian (it)"))] + It, + #[cfg_attr(feature = "backend", graphql(description = "Italian (Italy) (it-IT)"))] + ItIt, + #[cfg_attr( + feature = "backend", + graphql(description = "Italian (Switzerland) (it-CH)") + )] + ItCh, + #[cfg_attr(feature = "backend", graphql(description = "Japanese (ja)"))] + Ja, + #[cfg_attr(feature = "backend", graphql(description = "Japanese (Japan) (ja-JP)"))] + JaJp, + #[cfg_attr(feature = "backend", graphql(description = "Jola-Fonyi (dyo)"))] + Dyo, + #[cfg_attr( + feature = "backend", + graphql(description = "Jola-Fonyi (Senegal) (dyo-SN)") + )] + DyoSn, + #[cfg_attr(feature = "backend", graphql(description = "Kabuverdianu (kea)"))] + Kea, + #[cfg_attr( + feature = "backend", + graphql(description = "Kabuverdianu (Cape Verde) (kea-CV)") + )] + KeaCv, + #[cfg_attr(feature = "backend", graphql(description = "Kabyle (kab)"))] + Kab, + #[cfg_attr( + feature = "backend", + graphql(description = "Kabyle (Algeria) (kab-DZ)") + )] + KabDz, + #[cfg_attr(feature = "backend", graphql(description = "Kalaallisut (kl)"))] + Kl, + #[cfg_attr( + feature = "backend", + graphql(description = "Kalaallisut (Greenland) (kl-GL)") + )] + KlGl, + #[cfg_attr(feature = "backend", graphql(description = "Kalenjin (kln)"))] + Kln, + #[cfg_attr( + feature = "backend", + graphql(description = "Kalenjin (Kenya) (kln-KE)") + )] + KlnKe, + #[cfg_attr(feature = "backend", graphql(description = "Kamba (kam)"))] + Kam, + #[cfg_attr(feature = "backend", graphql(description = "Kamba (Kenya) (kam-KE)"))] + KamKe, + #[cfg_attr(feature = "backend", graphql(description = "Kannada (kn)"))] + Kn, + #[cfg_attr(feature = "backend", graphql(description = "Kannada (India) (kn-IN)"))] + KnIn, + #[cfg_attr(feature = "backend", graphql(description = "Kara-Kalpak (kaa)"))] + Kaa, + #[cfg_attr(feature = "backend", graphql(description = "Kazakh (kk)"))] + Kk, + #[cfg_attr( + feature = "backend", + graphql(description = "Kazakh (Cyrillic) (kk-Cyrl)") + )] + KkCyrl, + #[cfg_attr( + feature = "backend", + graphql(description = "Kazakh (Cyrillic, Kazakhstan) (kk-Cyrl-KZ)") + )] + KkCyrlKz, + #[cfg_attr(feature = "backend", graphql(description = "Khmer (km)"))] + Km, + #[cfg_attr(feature = "backend", graphql(description = "Khmer (Cambodia) (km-KH)"))] + KmKh, + #[cfg_attr(feature = "backend", graphql(description = "Kikuyu (ki)"))] + Ki, + #[cfg_attr(feature = "backend", graphql(description = "Kikuyu (Kenya) (ki-KE)"))] + KiKe, + #[cfg_attr(feature = "backend", graphql(description = "Kinyarwanda (rw)"))] + Rw, + #[cfg_attr( + feature = "backend", + graphql(description = "Kinyarwanda (Rwanda) (rw-RW)") + )] + RwRw, + #[cfg_attr(feature = "backend", graphql(description = "Konkani (kok)"))] + Kok, + #[cfg_attr(feature = "backend", graphql(description = "Konkani (India) (kok-IN)"))] + KokIn, + #[cfg_attr(feature = "backend", graphql(description = "Korean (ko)"))] + Ko, + #[cfg_attr( + feature = "backend", + graphql(description = "Korean (South Korea) (ko-KR)") + )] + KoKr, + #[cfg_attr(feature = "backend", graphql(description = "Koyra Chiini (khq)"))] + Khq, + #[cfg_attr( + feature = "backend", + graphql(description = "Koyra Chiini (Mali) (khq-ML)") + )] + KhqMl, + #[cfg_attr(feature = "backend", graphql(description = "Koyraboro Senni (ses)"))] + Ses, + #[cfg_attr( + feature = "backend", + graphql(description = "Koyraboro Senni (Mali) (ses-ML)") + )] + SesMl, + #[cfg_attr(feature = "backend", graphql(description = "Kwasio (nmg)"))] + Nmg, + #[cfg_attr( + feature = "backend", + graphql(description = "Kwasio (Cameroon) (nmg-CM)") + )] + NmgCm, + #[cfg_attr(feature = "backend", graphql(description = "Kyrgyz (ky)"))] + Ky, + #[cfg_attr(feature = "backend", graphql(description = "Langi (lag)"))] + Lag, + #[cfg_attr( + feature = "backend", + graphql(description = "Langi (Tanzania) (lag-TZ)") + )] + LagTz, + #[cfg_attr(feature = "backend", graphql(description = "Latvian (lv)"))] + Lv, + #[cfg_attr(feature = "backend", graphql(description = "Latvian (Latvia) (lv-LV)"))] + LvLv, + #[cfg_attr(feature = "backend", graphql(description = "Liberian English"))] + Lir, + #[cfg_attr(feature = "backend", graphql(description = "Lingala (ln)"))] + Ln, + #[cfg_attr( + feature = "backend", + graphql(description = "Lingala (Congo - Brazzaville) (ln-CG)") + )] + LnCg, + #[cfg_attr( + feature = "backend", + graphql(description = "Lingala (Congo - Kinshasa) (ln-CD)") + )] + LnCd, + #[cfg_attr(feature = "backend", graphql(description = "Lithuanian (lt)"))] + Lt, + #[cfg_attr( + feature = "backend", + graphql(description = "Lithuanian (Lithuania) (lt-LT)") + )] + LtLt, + #[cfg_attr(feature = "backend", graphql(description = "Luba-Katanga (lu)"))] + Lu, + #[cfg_attr( + feature = "backend", + graphql(description = "Luba-Katanga (Congo - Kinshasa) (lu-CD)") + )] + LuCd, + #[cfg_attr(feature = "backend", graphql(description = "Luo (luo)"))] + Luo, + #[cfg_attr(feature = "backend", graphql(description = "Luo (Kenya) (luo-KE)"))] + LuoKe, + #[cfg_attr(feature = "backend", graphql(description = "Luyia (luy)"))] + Luy, + #[cfg_attr(feature = "backend", graphql(description = "Luyia (Kenya) (luy-KE)"))] + LuyKe, + #[cfg_attr(feature = "backend", graphql(description = "Macedonian (mk)"))] + Mk, + #[cfg_attr( + feature = "backend", + graphql(description = "Macedonian (Macedonia) (mk-MK)") + )] + MkMk, + #[cfg_attr(feature = "backend", graphql(description = "Machame (jmc)"))] + Jmc, + #[cfg_attr( + feature = "backend", + graphql(description = "Machame (Tanzania) (jmc-TZ)") + )] + JmcTz, + #[cfg_attr(feature = "backend", graphql(description = "Makhuwa-Meetto (mgh)"))] + Mgh, + #[cfg_attr( + feature = "backend", + graphql(description = "Makhuwa-Meetto (Mozambique) (mgh-MZ)") + )] + MghMz, + #[cfg_attr(feature = "backend", graphql(description = "Makonde (kde)"))] + Kde, + #[cfg_attr( + feature = "backend", + graphql(description = "Makonde (Tanzania) (kde-TZ)") + )] + KdeTz, + #[cfg_attr(feature = "backend", graphql(description = "Malagasy (mg)"))] + Mg, + #[cfg_attr( + feature = "backend", + graphql(description = "Malagasy (Madagascar) (mg-MG)") + )] + MgMg, + #[cfg_attr(feature = "backend", graphql(description = "Malay (ms)"))] + Ms, + #[cfg_attr(feature = "backend", graphql(description = "Malay (Brunei) (ms-BN)"))] + MsBn, + #[cfg_attr(feature = "backend", graphql(description = "Malay (Malaysia) (ms-MY)"))] + MsMy, + #[cfg_attr(feature = "backend", graphql(description = "Malayalam (ml)"))] + Ml, + #[cfg_attr( + feature = "backend", + graphql(description = "Malayalam (India) (ml-IN)") + )] + MlIn, + #[cfg_attr(feature = "backend", graphql(description = "Maltese (mt)"))] + Mt, + #[cfg_attr(feature = "backend", graphql(description = "Maltese (Malta) (mt-MT)"))] + MtMt, + #[cfg_attr(feature = "backend", graphql(description = "Manx (gv)"))] + Gv, + #[cfg_attr( + feature = "backend", + graphql(description = "Manx (United Kingdom) (gv-GB)") + )] + GvGb, + #[cfg_attr(feature = "backend", graphql(description = "Marathi (mr)"))] + Mr, + #[cfg_attr(feature = "backend", graphql(description = "Marathi (India) (mr-IN)"))] + MrIn, + #[cfg_attr(feature = "backend", graphql(description = "Masai (mas)"))] + Mas, + #[cfg_attr(feature = "backend", graphql(description = "Masai (Kenya) (mas-KE)"))] + MasKe, + #[cfg_attr( + feature = "backend", + graphql(description = "Masai (Tanzania) (mas-TZ)") + )] + MasTz, + #[cfg_attr(feature = "backend", graphql(description = "Meru (mer)"))] + Mer, + #[cfg_attr(feature = "backend", graphql(description = "Meru (Kenya) (mer-KE)"))] + MerKe, + #[cfg_attr(feature = "backend", graphql(description = "Mongolian (mn)"))] + Mn, + #[cfg_attr(feature = "backend", graphql(description = "Morisyen (mfe)"))] + Mfe, + #[cfg_attr( + feature = "backend", + graphql(description = "Morisyen (Mauritius) (mfe-MU)") + )] + MfeMu, + #[cfg_attr(feature = "backend", graphql(description = "Mundang (mua)"))] + Mua, + #[cfg_attr( + feature = "backend", + graphql(description = "Mundang (Cameroon) (mua-CM)") + )] + MuaCm, + #[cfg_attr(feature = "backend", graphql(description = "Nama (naq)"))] + Naq, + #[cfg_attr(feature = "backend", graphql(description = "Nama (Namibia) (naq-NA)"))] + NaqNa, + #[cfg_attr(feature = "backend", graphql(description = "Nepali (ne)"))] + Ne, + #[cfg_attr(feature = "backend", graphql(description = "Nepali (India) (ne-IN)"))] + NeIn, + #[cfg_attr(feature = "backend", graphql(description = "Nepali (Nepal) (ne-NP)"))] + NeNp, + #[cfg_attr(feature = "backend", graphql(description = "Northern Sami"))] + Se, + #[cfg_attr(feature = "backend", graphql(description = "Northern Sami (Finland)"))] + SeFi, + #[cfg_attr(feature = "backend", graphql(description = "Northern Sami (Norway)"))] + SeNo, + #[cfg_attr(feature = "backend", graphql(description = "Northern Sami (Sweden)"))] + SeSe, + #[cfg_attr(feature = "backend", graphql(description = "North Ndebele (nd)"))] + Nd, + #[cfg_attr( + feature = "backend", + graphql(description = "North Ndebele (Zimbabwe) (nd-ZW)") + )] + NdZw, + #[cfg_attr(feature = "backend", graphql(description = "Norwegian Bokmål (nb)"))] + Nb, + #[cfg_attr( + feature = "backend", + graphql(description = "Norwegian Bokmål (Norway) (nb-NO)") + )] + NbNo, + #[cfg_attr(feature = "backend", graphql(description = "Norwegian Nynorsk (nn)"))] + Nn, + #[cfg_attr( + feature = "backend", + graphql(description = "Norwegian Nynorsk (Norway) (nn-NO)") + )] + NnNo, + #[cfg_attr(feature = "backend", graphql(description = "Nuer (nus)"))] + Nus, + #[cfg_attr(feature = "backend", graphql(description = "Nuer (Sudan) (nus-SD)"))] + NusSd, + #[cfg_attr(feature = "backend", graphql(description = "Nyankole (nyn)"))] + Nyn, + #[cfg_attr( + feature = "backend", + graphql(description = "Nyankole (Uganda) (nyn-UG)") + )] + NynUg, + #[cfg_attr(feature = "backend", graphql(description = "Oriya (or)"))] + Or, + #[cfg_attr(feature = "backend", graphql(description = "Oriya (India) (or-IN)"))] + OrIn, + #[cfg_attr(feature = "backend", graphql(description = "Oromo (om)"))] + Om, + #[cfg_attr(feature = "backend", graphql(description = "Oromo (Ethiopia) (om-ET)"))] + OmEt, + #[cfg_attr(feature = "backend", graphql(description = "Oromo (Kenya) (om-KE)"))] + OmKe, + #[cfg_attr(feature = "backend", graphql(description = "Pashto (ps)"))] + Ps, + #[cfg_attr( + feature = "backend", + graphql(description = "Pashto (Afghanistan) (ps-AF)") + )] + PsAf, + #[cfg_attr(feature = "backend", graphql(description = "Persian (fa)"))] + Fa, + #[cfg_attr( + feature = "backend", + graphql(description = "Persian (Afghanistan) (fa-AF)") + )] + FaAf, + #[cfg_attr(feature = "backend", graphql(description = "Persian (Iran) (fa-IR)"))] + FaIr, + #[cfg_attr(feature = "backend", graphql(description = "Polish (pl)"))] + Pl, + #[cfg_attr(feature = "backend", graphql(description = "Polish (Poland) (pl-PL)"))] + PlPl, + #[cfg_attr(feature = "backend", graphql(description = "Portuguese (pt)"))] + Pt, + #[cfg_attr( + feature = "backend", + graphql(description = "Portuguese (Angola) (pt-AO)") + )] + PtAo, + #[cfg_attr( + feature = "backend", + graphql(description = "Portuguese (Brazil) (pt-BR)") + )] + PtBr, + #[cfg_attr( + feature = "backend", + graphql(description = "Portuguese (Guinea-Bissau) (pt-GW)") + )] + PtGw, + #[cfg_attr( + feature = "backend", + graphql(description = "Portuguese (Mozambique) (pt-MZ)") + )] + PtMz, + #[cfg_attr( + feature = "backend", + graphql(description = "Portuguese (Portugal) (pt-PT)") + )] + PtPt, + #[cfg_attr( + feature = "backend", + graphql(description = "Portuguese (São Tomé and Príncipe) (pt-ST)") + )] + PtSt, + #[cfg_attr(feature = "backend", graphql(description = "Punjabi (pa)"))] + Pa, + #[cfg_attr( + feature = "backend", + graphql(description = "Punjabi (Arabic) (pa-Arab)") + )] + PaArab, + #[cfg_attr( + feature = "backend", + graphql(description = "Punjabi (Arabic, Pakistan) (pa-Arab-PK)") + )] + PaArabPk, + #[cfg_attr( + feature = "backend", + graphql(description = "Punjabi (Gurmukhi) (pa-Guru)") + )] + PaGuru, + #[cfg_attr( + feature = "backend", + graphql(description = "Punjabi (Gurmukhi, India) (pa-Guru-IN)") + )] + PaGuruIn, + #[cfg_attr(feature = "backend", graphql(description = "Romanian (ro)"))] + Ro, + #[cfg_attr( + feature = "backend", + graphql(description = "Romanian (Moldova) (ro-MD)") + )] + RoMd, + #[cfg_attr( + feature = "backend", + graphql(description = "Romanian (Romania) (ro-RO)") + )] + RoRo, + #[cfg_attr(feature = "backend", graphql(description = "Romansh (rm)"))] + Rm, + #[cfg_attr( + feature = "backend", + graphql(description = "Romansh (Switzerland) (rm-CH)") + )] + RmCh, + #[cfg_attr(feature = "backend", graphql(description = "Rombo (rof)"))] + Rof, + #[cfg_attr( + feature = "backend", + graphql(description = "Rombo (Tanzania) (rof-TZ)") + )] + RofTz, + #[cfg_attr(feature = "backend", graphql(description = "Rundi (rn)"))] + Rn, + #[cfg_attr(feature = "backend", graphql(description = "Rundi (Burundi) (rn-BI)"))] + RnBi, + #[cfg_attr(feature = "backend", graphql(description = "Russian (ru)"))] + Ru, + #[cfg_attr( + feature = "backend", + graphql(description = "Russian (Moldova) (ru-MD)") + )] + RuMd, + #[cfg_attr(feature = "backend", graphql(description = "Russian (Russia) (ru-RU)"))] + RuRu, + #[cfg_attr( + feature = "backend", + graphql(description = "Russian (Ukraine) (ru-UA)") + )] + RuUa, + #[cfg_attr(feature = "backend", graphql(description = "Rwa (rwk)"))] + Rwk, + #[cfg_attr(feature = "backend", graphql(description = "Rwa (Tanzania) (rwk-TZ)"))] + RwkTz, + #[cfg_attr(feature = "backend", graphql(description = "Samburu (saq)"))] + Saq, + #[cfg_attr(feature = "backend", graphql(description = "Samburu (Kenya) (saq-KE)"))] + SaqKe, + #[cfg_attr(feature = "backend", graphql(description = "Sango (sg)"))] + Sg, + #[cfg_attr( + feature = "backend", + graphql(description = "Sango (Central African Republic) (sg-CF)") + )] + SgCf, + #[cfg_attr(feature = "backend", graphql(description = "Sangu (sbp)"))] + Sbp, + #[cfg_attr( + feature = "backend", + graphql(description = "Sangu (Tanzania) (sbp-TZ)") + )] + SbpTz, + #[cfg_attr(feature = "backend", graphql(description = "Sanskrit (sa)"))] + Sa, + #[cfg_attr(feature = "backend", graphql(description = "Scottish Gaelic (gd)"))] + Gd, + #[cfg_attr( + feature = "backend", + graphql(description = "Scottish Gaelic (United Kingdom)") + )] + GdGb, + #[cfg_attr(feature = "backend", graphql(description = "Sena (seh)"))] + Seh, + #[cfg_attr( + feature = "backend", + graphql(description = "Sena (Mozambique) (seh-MZ)") + )] + SehMz, + #[cfg_attr(feature = "backend", graphql(description = "Serbian (sr)"))] + Sr, + #[cfg_attr( + feature = "backend", + graphql(description = "Serbian (Cyrillic) (sr-Cyrl)") + )] + SrCyrl, + #[cfg_attr( + feature = "backend", + graphql(description = "Serbian (Cyrillic, Bosnia and Herzegovina)(sr-Cyrl-BA) ") + )] + SrCyrlBa, + #[cfg_attr( + feature = "backend", + graphql(description = "Serbian (Cyrillic, Montenegro) (sr-Cyrl-ME)") + )] + SrCyrlMe, + #[cfg_attr( + feature = "backend", + graphql(description = "Serbian (Cyrillic, Serbia) (sr-Cyrl-RS)") + )] + SrCyrlRs, + #[cfg_attr( + feature = "backend", + graphql(description = "Serbian (Latin) (sr-Latn)") + )] + SrLatn, + #[cfg_attr( + feature = "backend", + graphql(description = "Serbian (Latin, Bosnia and Herzegovina) (sr-Latn-BA) ") + )] + SrLatnBa, + #[cfg_attr( + feature = "backend", + graphql(description = "Serbian (Latin, Montenegro) (sr-Latn-ME)") + )] + SrLatnMe, + #[cfg_attr( + feature = "backend", + graphql(description = "Serbian (Latin, Serbia) (sr-Latn-RS)") + )] + SrLatnRs, + #[cfg_attr(feature = "backend", graphql(description = "Shambala (ksb)"))] + Ksb, + #[cfg_attr( + feature = "backend", + graphql(description = "Shambala (Tanzania) (ksb-TZ)") + )] + KsbTz, + #[cfg_attr(feature = "backend", graphql(description = "Shona (sn)"))] + Sn, + #[cfg_attr(feature = "backend", graphql(description = "Shona (Zimbabwe) (sn-ZW)"))] + SnZw, + #[cfg_attr(feature = "backend", graphql(description = "Sichuan Yi (ii)"))] + Ii, + #[cfg_attr( + feature = "backend", + graphql(description = "Sichuan Yi (China) (ii-CN)") + )] + IiCn, + #[cfg_attr(feature = "backend", graphql(description = "Sinhala (si)"))] + Si, + #[cfg_attr( + feature = "backend", + graphql(description = "Sinhala (Sri Lanka) (si-LK)") + )] + SiLk, + #[cfg_attr(feature = "backend", graphql(description = "Slovak (sk)"))] + Sk, + #[cfg_attr( + feature = "backend", + graphql(description = "Slovak (Slovakia) (sk-SK)") + )] + SkSk, + #[cfg_attr(feature = "backend", graphql(description = "Slovenian (sl)"))] + Sl, + #[cfg_attr( + feature = "backend", + graphql(description = "Slovenian (Slovenia) (sl-SI)") + )] + SlSi, + #[cfg_attr(feature = "backend", graphql(description = "Soga (xog)"))] + Xog, + #[cfg_attr(feature = "backend", graphql(description = "Soga (Uganda) (xog-UG)"))] + XogUg, + #[cfg_attr(feature = "backend", graphql(description = "Somali (so)"))] + So, + #[cfg_attr( + feature = "backend", + graphql(description = "Somali (Djibouti) (so-DJ)") + )] + SoDj, + #[cfg_attr( + feature = "backend", + graphql(description = "Somali (Ethiopia) (so-ET)") + )] + SoEt, + #[cfg_attr(feature = "backend", graphql(description = "Somali (Kenya) (so-KE)"))] + SoKe, + #[cfg_attr(feature = "backend", graphql(description = "Somali (Somalia) (so-SO)"))] + SoSo, + #[cfg_attr(feature = "backend", graphql(description = "Spanish (es)"))] + Es, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Argentina) (es-AR)") + )] + EsAr, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Bolivia) (es-BO)") + )] + EsBo, + #[cfg_attr(feature = "backend", graphql(description = "Spanish (Chile) (es-CL)"))] + EsCl, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Colombia) (es-CO)") + )] + EsCo, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Costa Rica) (es-CR)") + )] + EsCr, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Dominican Republic) (es-DO)") + )] + EsDo, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Ecuador) (es-EC)") + )] + EsEc, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (El Salvador) (es-SV)") + )] + EsSv, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Equatorial Guinea) (es-GQ)") + )] + EsGq, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Guatemala) (es-GT)") + )] + EsGt, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Honduras) (es-HN)") + )] + EsHn, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Latin America) (es-419)") + )] + Es419, + #[cfg_attr(feature = "backend", graphql(description = "Spanish (Mexico) (es-MX)"))] + EsMx, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Nicaragua) (es-NI)") + )] + EsNi, + #[cfg_attr(feature = "backend", graphql(description = "Spanish (Panama) (es-PA)"))] + EsPa, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Paraguay) (es-PY)") + )] + EsPy, + #[cfg_attr(feature = "backend", graphql(description = "Spanish (Peru) (es-PE)"))] + EsPe, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Puerto Rico) (es-PR)") + )] + EsPr, + #[cfg_attr(feature = "backend", graphql(description = "Spanish (Spain) (es-ES)"))] + EsEs, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (United States) (es-US)") + )] + EsUs, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Uruguay) (es-UY)") + )] + EsUy, + #[cfg_attr( + feature = "backend", + graphql(description = "Spanish (Venezuela) (es-VE)") + )] + EsVe, + #[cfg_attr(feature = "backend", graphql(description = "Swahili (sw)"))] + Sw, + #[cfg_attr(feature = "backend", graphql(description = "Swahili (Kenya) (sw-KE)"))] + SwKe, + #[cfg_attr( + feature = "backend", + graphql(description = "Swahili (Tanzania) (sw-TZ)") + )] + SwTz, + #[cfg_attr(feature = "backend", graphql(description = "Swedish (sv)"))] + Sv, + #[cfg_attr( + feature = "backend", + graphql(description = "Swedish (Finland) (sv-FI)") + )] + SvFi, + #[cfg_attr(feature = "backend", graphql(description = "Swedish (Sweden) (sv-SE)"))] + SvSe, + #[cfg_attr(feature = "backend", graphql(description = "Swiss German (gsw)"))] + Gsw, + #[cfg_attr( + feature = "backend", + graphql(description = "Swiss German (Switzerland) (gsw-CH)") + )] + GswCh, + #[cfg_attr(feature = "backend", graphql(description = "Tachelhit (shi)"))] + Shi, + #[cfg_attr( + feature = "backend", + graphql(description = "Tachelhit (Latin) (shi-Latn)") + )] + ShiLatn, + #[cfg_attr( + feature = "backend", + graphql(description = "Tachelhit (Latin, Morocco) (shi-Latn-MA)") + )] + ShiLatnMa, + #[cfg_attr( + feature = "backend", + graphql(description = "Tachelhit (Tifinagh) (shi-Tfng)") + )] + ShiTfng, + #[cfg_attr( + feature = "backend", + graphql(description = "Tachelhit (Tifinagh, Morocco) (shi-Tfng-MA)") + )] + ShiTfngMa, + #[cfg_attr(feature = "backend", graphql(description = "Taita (dav)"))] + Dav, + #[cfg_attr(feature = "backend", graphql(description = "Taita (Kenya) (dav-KE)"))] + DavKe, + #[cfg_attr(feature = "backend", graphql(description = "Tajik (tg)"))] + Tg, + #[cfg_attr(feature = "backend", graphql(description = "Tamil (ta)"))] + Ta, + #[cfg_attr(feature = "backend", graphql(description = "Tamil (India) (ta-IN)"))] + TaIn, + #[cfg_attr( + feature = "backend", + graphql(description = "Tamil (Sri Lanka) (ta-LK)") + )] + TaLk, + #[cfg_attr(feature = "backend", graphql(description = "Tasawaq (twq)"))] + Twq, + #[cfg_attr(feature = "backend", graphql(description = "Tasawaq (Niger) (twq-NE)"))] + TwqNe, + #[cfg_attr(feature = "backend", graphql(description = "Te Reo Māori (mi)"))] + Mi, + #[cfg_attr(feature = "backend", graphql(description = "Telugu (te)"))] + Te, + #[cfg_attr(feature = "backend", graphql(description = "Telugu (India) (te-IN)"))] + TeIn, + #[cfg_attr(feature = "backend", graphql(description = "Teso (teo)"))] + Teo, + #[cfg_attr(feature = "backend", graphql(description = "Teso (Kenya) (teo-KE)"))] + TeoKe, + #[cfg_attr(feature = "backend", graphql(description = "Teso (Uganda) (teo-UG)"))] + TeoUg, + #[cfg_attr(feature = "backend", graphql(description = "Thai (th)"))] + Th, + #[cfg_attr(feature = "backend", graphql(description = "Thai (Thailand) (th-TH)"))] + ThTh, + #[cfg_attr(feature = "backend", graphql(description = "Tibetan (bo)"))] + Bo, + #[cfg_attr(feature = "backend", graphql(description = "Tibetan (China) (bo-CN)"))] + BoCn, + #[cfg_attr(feature = "backend", graphql(description = "Tibetan (India) (bo-IN)"))] + BoIn, + #[cfg_attr(feature = "backend", graphql(description = "Tigrinya (ti)"))] + Ti, + #[cfg_attr( + feature = "backend", + graphql(description = "Tigrinya (Eritrea) (ti-ER)") + )] + TiEr, + #[cfg_attr( + feature = "backend", + graphql(description = "Tigrinya (Ethiopia) (ti-ET)") + )] + TiEt, + #[cfg_attr(feature = "backend", graphql(description = "Tongan (to)"))] + To, + #[cfg_attr(feature = "backend", graphql(description = "Tongan (Tonga) (to-TO)"))] + ToTo, + #[cfg_attr(feature = "backend", graphql(description = "Turkish (tr)"))] + Tr, + #[cfg_attr(feature = "backend", graphql(description = "Turkmen (tk)"))] + Tk, + #[cfg_attr(feature = "backend", graphql(description = "Turkish (Turkey) (tr-TR)"))] + TrTr, + #[cfg_attr( + feature = "backend", + graphql(description = "Turks And Caicos Creole English") + )] + Tch, + #[cfg_attr(feature = "backend", graphql(description = "Ukrainian (uk)"))] + Uk, + #[cfg_attr( + feature = "backend", + graphql(description = "Ukrainian (Ukraine) (uk-UA)") + )] + UkUa, + #[cfg_attr(feature = "backend", graphql(description = "Urdu (ur)"))] + Ur, + #[cfg_attr(feature = "backend", graphql(description = "Urdu (India) (ur-IN)"))] + UrIn, + #[cfg_attr(feature = "backend", graphql(description = "Urdu (Pakistan) (ur-PK)"))] + UrPk, + #[cfg_attr(feature = "backend", graphql(description = "Uyghur"))] + Ug, + #[cfg_attr(feature = "backend", graphql(description = "Uyghur (China)"))] + UgCn, + #[cfg_attr(feature = "backend", graphql(description = "Uzbek (uz)"))] + Uz, + #[cfg_attr(feature = "backend", graphql(description = "Uzbek (Arabic) (uz-Arab)"))] + UzArab, + #[cfg_attr( + feature = "backend", + graphql(description = "Uzbek (Arabic, Afghanistan) (uz-Arab-AF)") + )] + UzArabAf, + #[cfg_attr( + feature = "backend", + graphql(description = "Uzbek (Cyrillic) (uz-Cyrl)") + )] + UzCyrl, + #[cfg_attr( + feature = "backend", + graphql(description = "Uzbek (Cyrillic, Uzbekistan) (uz-Cyrl-UZ)") + )] + UzCyrlUz, + #[cfg_attr(feature = "backend", graphql(description = "Uzbek (Latin) (uz-Latn)"))] + UzLatn, + #[cfg_attr( + feature = "backend", + graphql(description = "Uzbek (Latin, Uzbekistan) (uz-Latn-UZ)") + )] + UzLatnUz, + #[cfg_attr(feature = "backend", graphql(description = "Vai (vai)"))] + Vai, + #[cfg_attr(feature = "backend", graphql(description = "Vai (Latin) (vai-Latn)"))] + VaiLatn, + #[cfg_attr( + feature = "backend", + graphql(description = "Vai (Latin, Liberia) (vai-Latn-LR)") + )] + VaiLatnLr, + #[cfg_attr(feature = "backend", graphql(description = "Vai (Vai) (vai-Vaii)"))] + VaiVaii, + #[cfg_attr( + feature = "backend", + graphql(description = "Vai (Vai, Liberia) (vai-Vaii-LR)") + )] + VaiVaiiLr, + #[cfg_attr(feature = "backend", graphql(description = "Valencian (val)"))] + Val, + #[cfg_attr( + feature = "backend", + graphql(description = "Valencian (Spain) (val-ES)") + )] + ValEs, + #[cfg_attr( + feature = "backend", + graphql(description = "Valencian (Spain Catalan) (ca-ES-valencia)") + )] + CaEsValencia, + #[cfg_attr(feature = "backend", graphql(description = "Vietnamese (vi)"))] + Vi, + #[cfg_attr( + feature = "backend", + graphql(description = "Vietnamese (Vietnam) (vi-VN)") + )] + ViVn, + #[cfg_attr(feature = "backend", graphql(description = "Vunjo (vun)"))] + Vun, + #[cfg_attr( + feature = "backend", + graphql(description = "Vunjo (Tanzania) (vun-TZ)") + )] + VunTz, + #[cfg_attr(feature = "backend", graphql(description = "Welsh (cy)"))] + Cy, + #[cfg_attr( + feature = "backend", + graphql(description = "Welsh (United Kingdom) (cy-GB)") + )] + CyGb, + #[cfg_attr(feature = "backend", graphql(description = "Wolof (wo)"))] + Wo, + #[cfg_attr(feature = "backend", graphql(description = "Xhosa (xh)"))] + Xh, + #[cfg_attr(feature = "backend", graphql(description = "Yangben (yav)"))] + Yav, + #[cfg_attr( + feature = "backend", + graphql(description = "Yangben (Cameroon) (yav-CM)") + )] + YavCm, + #[cfg_attr(feature = "backend", graphql(description = "Yoruba (yo)"))] + Yo, + #[cfg_attr(feature = "backend", graphql(description = "Yoruba (Nigeria) (yo-NG)"))] + YoNg, + #[cfg_attr(feature = "backend", graphql(description = "Zarma (dje)"))] + Dje, + #[cfg_attr(feature = "backend", graphql(description = "Zarma (Niger) (dje-NE)"))] + DjeNe, + #[cfg_attr(feature = "backend", graphql(description = "Zulu (zu)"))] + Zu, + #[cfg_attr( + feature = "backend", + graphql(description = "Zulu (South Africa) (zu-ZA)") + )] + ZuZa, +} + +impl From<LocaleCode> for LanguageCode { + /// Converts a LocaleCode (BCP-47) to its corresponding LanguageCode (ISO 639-2/B) + fn from(locale: LocaleCode) -> Self { + // Direct mapping via enum pattern matching where feasible + match locale { + // English and related creoles/variants + LocaleCode::En + | LocaleCode::EnAi + | LocaleCode::EnAs + | LocaleCode::EnAu + | LocaleCode::EnAt + | LocaleCode::EnBb + | LocaleCode::EnBe + | LocaleCode::EnBz + | LocaleCode::EnBm + | LocaleCode::EnBw + | LocaleCode::EnIo + | LocaleCode::EnBi + | LocaleCode::EnCm + | LocaleCode::EnCa + | LocaleCode::EnKy + | LocaleCode::EnCx + | LocaleCode::EnCc + | LocaleCode::EnCk + | LocaleCode::EnCy + | LocaleCode::EnDk + | LocaleCode::EnDg + | LocaleCode::EnDm + | LocaleCode::EnEg + | LocaleCode::EnEr + | LocaleCode::EnEu + | LocaleCode::EnFk + | LocaleCode::EnFj + | LocaleCode::EnFi + | LocaleCode::EnGm + | LocaleCode::EnDe + | LocaleCode::EnGh + | LocaleCode::EnGi + | LocaleCode::EnGd + | LocaleCode::EnGu + | LocaleCode::EnGg + | LocaleCode::EnGy + | LocaleCode::EnHk + | LocaleCode::EnIn + | LocaleCode::EnIe + | LocaleCode::EnIm + | LocaleCode::EnIl + | LocaleCode::EnJm + | LocaleCode::EnJe + | LocaleCode::EnKe + | LocaleCode::EnKi + | LocaleCode::EnKw + | LocaleCode::EnLs + | LocaleCode::EnMo + | LocaleCode::EnMg + | LocaleCode::EnMw + | LocaleCode::EnMy + | LocaleCode::EnMt + | LocaleCode::EnMh + | LocaleCode::EnMu + | LocaleCode::EnFm + | LocaleCode::EnMs + | LocaleCode::EnNa + | LocaleCode::EnNr + | LocaleCode::EnNl + | LocaleCode::EnNz + | LocaleCode::EnNg + | LocaleCode::EnNu + | LocaleCode::EnNf + | LocaleCode::EnMp + | LocaleCode::EnNo + | LocaleCode::EnPa + | LocaleCode::EnPk + | LocaleCode::EnPw + | LocaleCode::EnPg + | LocaleCode::EnPh + | LocaleCode::EnPn + | LocaleCode::EnPr + | LocaleCode::EnRw + | LocaleCode::EnWs + | LocaleCode::EnSa + | LocaleCode::EnSc + | LocaleCode::EnSl + | LocaleCode::EnSg + | LocaleCode::EnSx + | LocaleCode::EnSi + | LocaleCode::EnSb + | LocaleCode::EnSs + | LocaleCode::EnSh + | LocaleCode::EnKn + | LocaleCode::EnLc + | LocaleCode::Svc + | LocaleCode::Vic + | LocaleCode::EnSd + | LocaleCode::EnSz + | LocaleCode::EnSe + | LocaleCode::EnCh + | LocaleCode::EnTz + | LocaleCode::EnTk + | LocaleCode::EnTo + | LocaleCode::EnTt + | LocaleCode::EnTv + | LocaleCode::EnZa + | LocaleCode::EnAe + | LocaleCode::EnUm + | LocaleCode::EnVi + | LocaleCode::EnUsPosix + | LocaleCode::EnUg + | LocaleCode::EnGb + | LocaleCode::EnUs + | LocaleCode::EnVu + | LocaleCode::EnZm + | LocaleCode::EnZw + | LocaleCode::Aig + | LocaleCode::Bah + | LocaleCode::Lir + | LocaleCode::Tch => LanguageCode::Eng, + + // French variants + LocaleCode::Fr + | LocaleCode::FrBe + | LocaleCode::FrBj + | LocaleCode::FrBf + | LocaleCode::FrBi + | LocaleCode::FrCm + | LocaleCode::FrCa + | LocaleCode::FrCf + | LocaleCode::FrTd + | LocaleCode::FrKm + | LocaleCode::FrCg + | LocaleCode::FrCd + | LocaleCode::FrCi + | LocaleCode::FrDj + | LocaleCode::FrGq + | LocaleCode::FrFr + | LocaleCode::FrGf + | LocaleCode::FrGa + | LocaleCode::FrGp + | LocaleCode::FrGn + | LocaleCode::FrLu + | LocaleCode::FrMg + | LocaleCode::FrMl + | LocaleCode::FrMq + | LocaleCode::FrYt + | LocaleCode::FrMc + | LocaleCode::FrNe + | LocaleCode::FrRw + | LocaleCode::FrRe + | LocaleCode::FrBl + | LocaleCode::FrMf + | LocaleCode::FrMu + | LocaleCode::FrSn + | LocaleCode::FrCh + | LocaleCode::FrTg => LanguageCode::Fre, + + // Spanish variants + LocaleCode::Es + | LocaleCode::EsAr + | LocaleCode::EsBo + | LocaleCode::EsCl + | LocaleCode::EsCo + | LocaleCode::EsCr + | LocaleCode::EsDo + | LocaleCode::EsEc + | LocaleCode::EsSv + | LocaleCode::EsGq + | LocaleCode::EsGt + | LocaleCode::EsHn + | LocaleCode::Es419 + | LocaleCode::EsMx + | LocaleCode::EsNi + | LocaleCode::EsPa + | LocaleCode::EsPy + | LocaleCode::EsPe + | LocaleCode::EsPr + | LocaleCode::EsEs + | LocaleCode::EsUs + | LocaleCode::EsUy + | LocaleCode::EsVe => LanguageCode::Spa, + + // Major single locales + LocaleCode::De + | LocaleCode::DeAt + | LocaleCode::DeBe + | LocaleCode::DeDe + | LocaleCode::DeLi + | LocaleCode::DeLu + | LocaleCode::DeCh => LanguageCode::Ger, + LocaleCode::It | LocaleCode::ItIt | LocaleCode::ItCh => LanguageCode::Ita, + LocaleCode::Pt + | LocaleCode::PtAo + | LocaleCode::PtBr + | LocaleCode::PtGw + | LocaleCode::PtMz + | LocaleCode::PtPt + | LocaleCode::PtSt => LanguageCode::Por, + LocaleCode::Ru | LocaleCode::RuMd | LocaleCode::RuRu | LocaleCode::RuUa => { + LanguageCode::Rus + } + LocaleCode::Zh + | LocaleCode::ZhHans + | LocaleCode::ZhCn + | LocaleCode::ZhHansCn + | LocaleCode::ZhHansHk + | LocaleCode::ZhHansMo + | LocaleCode::ZhHansSg + | LocaleCode::ZhHant + | LocaleCode::ZhHantHk + | LocaleCode::ZhHantMo + | LocaleCode::ZhHantTw => LanguageCode::Chi, + LocaleCode::Ja | LocaleCode::JaJp => LanguageCode::Jpn, + LocaleCode::Ko | LocaleCode::KoKr => LanguageCode::Kor, + LocaleCode::Ar + | LocaleCode::ArDz + | LocaleCode::ArBh + | LocaleCode::ArEg + | LocaleCode::ArIq + | LocaleCode::ArJo + | LocaleCode::ArKw + | LocaleCode::ArLb + | LocaleCode::ArLy + | LocaleCode::ArMa + | LocaleCode::ArOm + | LocaleCode::ArQa + | LocaleCode::ArSa + | LocaleCode::ArSd + | LocaleCode::ArSy + | LocaleCode::ArTn + | LocaleCode::ArAe + | LocaleCode::Ar001 + | LocaleCode::ArYe => LanguageCode::Ara, + LocaleCode::Hi | LocaleCode::HiIn => LanguageCode::Hin, + LocaleCode::Nl + | LocaleCode::NlAw + | LocaleCode::NlBe + | LocaleCode::NlCw + | LocaleCode::NlNl + | LocaleCode::NlSx => LanguageCode::Dut, + LocaleCode::Sv | LocaleCode::SvFi | LocaleCode::SvSe => LanguageCode::Swe, + LocaleCode::Pl | LocaleCode::PlPl => LanguageCode::Pol, + + // A few additional straightforward mappings + LocaleCode::Af | LocaleCode::AfNa | LocaleCode::AfZa => LanguageCode::Afr, + LocaleCode::Sq | LocaleCode::SqAl => LanguageCode::Alb, + LocaleCode::Am | LocaleCode::AmEt => LanguageCode::Amh, + // Extended straightforward mappings by language families + LocaleCode::Cy | LocaleCode::CyGb => LanguageCode::Wel, + LocaleCode::Ga | LocaleCode::GaIe => LanguageCode::Gle, + LocaleCode::Eu | LocaleCode::EuEs => LanguageCode::Baq, + LocaleCode::Is | LocaleCode::IsIs => LanguageCode::Ice, + LocaleCode::Ka | LocaleCode::KaGe => LanguageCode::Geo, + LocaleCode::Hy | LocaleCode::HyAm => LanguageCode::Arm, + LocaleCode::Bo | LocaleCode::BoCn | LocaleCode::BoIn => LanguageCode::Tib, + LocaleCode::Si | LocaleCode::SiLk => LanguageCode::Sin, + LocaleCode::Fa | LocaleCode::FaAf | LocaleCode::FaIr => LanguageCode::Per, + LocaleCode::Ro | LocaleCode::RoMd | LocaleCode::RoRo => LanguageCode::Rum, + LocaleCode::Sk | LocaleCode::SkSk => LanguageCode::Slo, + LocaleCode::Mk | LocaleCode::MkMk => LanguageCode::Mac, + LocaleCode::Ms | LocaleCode::MsBn | LocaleCode::MsMy => LanguageCode::May, + LocaleCode::Mi => LanguageCode::Mao, + LocaleCode::Ca + | LocaleCode::CaEs + | LocaleCode::Val + | LocaleCode::ValEs + | LocaleCode::CaEsValencia => LanguageCode::Cat, + LocaleCode::Et | LocaleCode::EtEe => LanguageCode::Est, + LocaleCode::Lg | LocaleCode::LgUg => LanguageCode::Lug, + LocaleCode::Ee | LocaleCode::EeGh | LocaleCode::EeTg => LanguageCode::Ewe, + LocaleCode::Fo | LocaleCode::FoFo => LanguageCode::Fao, + LocaleCode::Fil | LocaleCode::FilPh => LanguageCode::Fil, + LocaleCode::Fi | LocaleCode::FiFi => LanguageCode::Fin, + LocaleCode::Gd | LocaleCode::GdGb => LanguageCode::Gla, + LocaleCode::Ha + | LocaleCode::HaLatn + | LocaleCode::HaLatnGh + | LocaleCode::HaLatnNe + | LocaleCode::HaLatnNg => LanguageCode::Hau, + LocaleCode::He | LocaleCode::HeIl => LanguageCode::Heb, + LocaleCode::Hu | LocaleCode::HuHu => LanguageCode::Hun, + LocaleCode::Ig | LocaleCode::IgNg => LanguageCode::Ibo, + LocaleCode::Id | LocaleCode::IdId => LanguageCode::Ind, + LocaleCode::Kk | LocaleCode::KkCyrl | LocaleCode::KkCyrlKz => LanguageCode::Kaz, + LocaleCode::Km | LocaleCode::KmKh => LanguageCode::Khm, + LocaleCode::Ki | LocaleCode::KiKe => LanguageCode::Kik, + LocaleCode::Rw | LocaleCode::RwRw => LanguageCode::Kin, + LocaleCode::Kok | LocaleCode::KokIn => LanguageCode::Kok, + LocaleCode::Ky => LanguageCode::Kir, + LocaleCode::Lv | LocaleCode::LvLv => LanguageCode::Lav, + LocaleCode::Ln | LocaleCode::LnCg | LocaleCode::LnCd => LanguageCode::Lin, + LocaleCode::Lt | LocaleCode::LtLt => LanguageCode::Lit, + LocaleCode::Lu | LocaleCode::LuCd => LanguageCode::Lub, + LocaleCode::Luo | LocaleCode::LuoKe => LanguageCode::Luo, + LocaleCode::Mg | LocaleCode::MgMg => LanguageCode::Mlg, + LocaleCode::Ml | LocaleCode::MlIn => LanguageCode::Mal, + LocaleCode::Mt | LocaleCode::MtMt => LanguageCode::Mlt, + LocaleCode::Gv | LocaleCode::GvGb => LanguageCode::Glv, + LocaleCode::Mr | LocaleCode::MrIn => LanguageCode::Mar, + LocaleCode::Mas | LocaleCode::MasKe | LocaleCode::MasTz => LanguageCode::Mas, + LocaleCode::Mn => LanguageCode::Mon, + LocaleCode::Ne | LocaleCode::NeIn | LocaleCode::NeNp => LanguageCode::Nep, + LocaleCode::Se | LocaleCode::SeFi | LocaleCode::SeNo | LocaleCode::SeSe => { + LanguageCode::Sme + } + LocaleCode::Nd | LocaleCode::NdZw => LanguageCode::Nde, + LocaleCode::Nb | LocaleCode::NbNo => LanguageCode::Nob, + LocaleCode::Nn | LocaleCode::NnNo => LanguageCode::Nno, + LocaleCode::Or | LocaleCode::OrIn => LanguageCode::Ori, + LocaleCode::Om | LocaleCode::OmEt | LocaleCode::OmKe => LanguageCode::Orm, + LocaleCode::Ps | LocaleCode::PsAf => LanguageCode::Pus, + LocaleCode::Pa + | LocaleCode::PaArab + | LocaleCode::PaArabPk + | LocaleCode::PaGuru + | LocaleCode::PaGuruIn => LanguageCode::Pan, + LocaleCode::Rm | LocaleCode::RmCh => LanguageCode::Roh, + LocaleCode::Rn | LocaleCode::RnBi => LanguageCode::Run, + LocaleCode::Sg | LocaleCode::SgCf => LanguageCode::Sag, + LocaleCode::Sa => LanguageCode::San, + LocaleCode::Sr + | LocaleCode::SrCyrl + | LocaleCode::SrCyrlBa + | LocaleCode::SrCyrlMe + | LocaleCode::SrCyrlRs + | LocaleCode::SrLatn + | LocaleCode::SrLatnBa + | LocaleCode::SrLatnMe + | LocaleCode::SrLatnRs => LanguageCode::Srp, + LocaleCode::Sn | LocaleCode::SnZw => LanguageCode::Sna, + LocaleCode::Ii | LocaleCode::IiCn => LanguageCode::Iii, + LocaleCode::Sl | LocaleCode::SlSi => LanguageCode::Slv, + LocaleCode::So + | LocaleCode::SoDj + | LocaleCode::SoEt + | LocaleCode::SoKe + | LocaleCode::SoSo => LanguageCode::Som, + LocaleCode::Sw + | LocaleCode::SwKe + | LocaleCode::SwTz + | LocaleCode::Swc + | LocaleCode::SwcCd => LanguageCode::Swa, + LocaleCode::Ta | LocaleCode::TaIn | LocaleCode::TaLk => LanguageCode::Tam, + LocaleCode::Tg => LanguageCode::Tgk, + LocaleCode::Te | LocaleCode::TeIn => LanguageCode::Tel, + LocaleCode::Th | LocaleCode::ThTh => LanguageCode::Tha, + LocaleCode::Ti | LocaleCode::TiEr | LocaleCode::TiEt => LanguageCode::Tir, + LocaleCode::To | LocaleCode::ToTo => LanguageCode::Ton, + LocaleCode::Tr | LocaleCode::TrTr => LanguageCode::Tur, + LocaleCode::Tk => LanguageCode::Tuk, + LocaleCode::Uk | LocaleCode::UkUa => LanguageCode::Ukr, + LocaleCode::Ur | LocaleCode::UrIn | LocaleCode::UrPk => LanguageCode::Urd, + LocaleCode::Ug | LocaleCode::UgCn => LanguageCode::Uig, + LocaleCode::Uz + | LocaleCode::UzArab + | LocaleCode::UzArabAf + | LocaleCode::UzCyrl + | LocaleCode::UzCyrlUz + | LocaleCode::UzLatn + | LocaleCode::UzLatnUz => LanguageCode::Uzb, + LocaleCode::Vai + | LocaleCode::VaiLatn + | LocaleCode::VaiLatnLr + | LocaleCode::VaiVaii + | LocaleCode::VaiVaiiLr => LanguageCode::Vai, + LocaleCode::Vi | LocaleCode::ViVn => LanguageCode::Vie, + LocaleCode::Wo => LanguageCode::Wol, + LocaleCode::Xh => LanguageCode::Xho, + LocaleCode::Yo | LocaleCode::YoNg => LanguageCode::Yor, + LocaleCode::Zu | LocaleCode::ZuZa => LanguageCode::Zul, + LocaleCode::Kw | LocaleCode::KwGb => LanguageCode::Cor, + LocaleCode::Hr | LocaleCode::HrHr => LanguageCode::Hrv, + LocaleCode::Cs | LocaleCode::CsCz => LanguageCode::Cze, + LocaleCode::Da | LocaleCode::DaDk => LanguageCode::Dan, + LocaleCode::El | LocaleCode::ElCy | LocaleCode::ElGr => LanguageCode::Gre, + LocaleCode::Gu | LocaleCode::GuIn => LanguageCode::Guj, + LocaleCode::Haw | LocaleCode::HawUs => LanguageCode::Haw, + LocaleCode::Smn | LocaleCode::SmnFi => LanguageCode::Smn, + LocaleCode::Gl | LocaleCode::GlEs => LanguageCode::Glg, + LocaleCode::Eo => LanguageCode::Epo, + LocaleCode::Ewo | LocaleCode::EwoCm => LanguageCode::Ewo, + LocaleCode::Ff | LocaleCode::FfSn => LanguageCode::Ful, + LocaleCode::Kab | LocaleCode::KabDz => LanguageCode::Kab, + LocaleCode::Kl | LocaleCode::KlGl => LanguageCode::Kal, + LocaleCode::Kaa => LanguageCode::Kaa, + LocaleCode::Kam | LocaleCode::KamKe => LanguageCode::Kam, + LocaleCode::Kn | LocaleCode::KnIn => LanguageCode::Kan, + // Specific codes for languages where we have a dedicated or macro ISO 639-2/B mapping + LocaleCode::Ak | LocaleCode::AkGh => LanguageCode::Aka, + LocaleCode::As | LocaleCode::AsIn => LanguageCode::Asm, + LocaleCode::Ast | LocaleCode::AstEs => LanguageCode::Ast, + LocaleCode::Az + | LocaleCode::AzCyrl + | LocaleCode::AzCyrlAz + | LocaleCode::AzLatn + | LocaleCode::AzLatnAz => LanguageCode::Aze, + LocaleCode::Bm | LocaleCode::BmMl => LanguageCode::Bam, + LocaleCode::Bas | LocaleCode::BasCm => LanguageCode::Bas, + LocaleCode::Be | LocaleCode::BeBy => LanguageCode::Bel, + LocaleCode::Bem | LocaleCode::BemZm => LanguageCode::Bem, + LocaleCode::Bn | LocaleCode::BnBd | LocaleCode::BnIn => LanguageCode::Ben, + LocaleCode::Brx | LocaleCode::BrxIn => LanguageCode::Sit, + LocaleCode::Bs | LocaleCode::BsBa => LanguageCode::Bos, + LocaleCode::Br | LocaleCode::BrFr => LanguageCode::Bre, + LocaleCode::Bg | LocaleCode::BgBg => LanguageCode::Bul, + LocaleCode::My | LocaleCode::MyMm => LanguageCode::Bur, + LocaleCode::Ckb | LocaleCode::Kmr | LocaleCode::Sdh => LanguageCode::Kur, + LocaleCode::Tzm | LocaleCode::TzmLatn | LocaleCode::TzmLatnMa => LanguageCode::Ber, + LocaleCode::Chr | LocaleCode::ChrUs => LanguageCode::Chr, + LocaleCode::Dv => LanguageCode::Div, + LocaleCode::Lao => LanguageCode::Lao, + LocaleCode::Dyo | LocaleCode::DyoSn => LanguageCode::Nic, + LocaleCode::Kea | LocaleCode::KeaCv => LanguageCode::Cpp, + LocaleCode::Kln | LocaleCode::KlnKe => LanguageCode::Ssa, + LocaleCode::Khq | LocaleCode::KhqMl => LanguageCode::Son, + LocaleCode::Ses | LocaleCode::SesMl => LanguageCode::Son, + LocaleCode::Mfe | LocaleCode::MfeMu => LanguageCode::Cpf, + LocaleCode::Mua | LocaleCode::MuaCm => LanguageCode::Nic, + LocaleCode::Naq | LocaleCode::NaqNa => LanguageCode::Khi, + LocaleCode::Nus | LocaleCode::NusSd => LanguageCode::Ssa, + LocaleCode::Twq | LocaleCode::TwqNe => LanguageCode::Son, + LocaleCode::Teo | LocaleCode::TeoKe | LocaleCode::TeoUg => LanguageCode::Ssa, + LocaleCode::Dje | LocaleCode::DjeNe => LanguageCode::Son, + LocaleCode::Gsw | LocaleCode::GswCh => LanguageCode::Gsw, + LocaleCode::Shi + | LocaleCode::ShiLatn + | LocaleCode::ShiLatnMa + | LocaleCode::ShiTfng + | LocaleCode::ShiTfngMa => LanguageCode::Ber, + // Bantu cluster: languages without specific ISO 639-2/B codes in our enum. + // We map these to the macro-language Bnt (Bantu languages). + LocaleCode::Agq + | LocaleCode::AgqCm + | LocaleCode::Asa + | LocaleCode::AsaTz + | LocaleCode::Ksf + | LocaleCode::KsfCm + | LocaleCode::Bez + | LocaleCode::BezTz + | LocaleCode::Cgg + | LocaleCode::CggUg + | LocaleCode::Dua + | LocaleCode::DuaCm + | LocaleCode::Ebu + | LocaleCode::EbuKe + | LocaleCode::Guz + | LocaleCode::GuzKe + | LocaleCode::Nmg + | LocaleCode::NmgCm + | LocaleCode::Lag + | LocaleCode::LagTz + | LocaleCode::Luy + | LocaleCode::LuyKe + | LocaleCode::Jmc + | LocaleCode::JmcTz + | LocaleCode::Mgh + | LocaleCode::MghMz + | LocaleCode::Kde + | LocaleCode::KdeTz + | LocaleCode::Mer + | LocaleCode::MerKe + | LocaleCode::Nyn + | LocaleCode::NynUg + | LocaleCode::Rof + | LocaleCode::RofTz + | LocaleCode::Rwk + | LocaleCode::RwkTz + | LocaleCode::Saq + | LocaleCode::SaqKe + | LocaleCode::Sbp + | LocaleCode::SbpTz + | LocaleCode::Seh + | LocaleCode::SehMz + | LocaleCode::Ksb + | LocaleCode::KsbTz + | LocaleCode::Xog + | LocaleCode::XogUg + | LocaleCode::Dav + | LocaleCode::DavKe + | LocaleCode::Vun + | LocaleCode::VunTz + | LocaleCode::Yav + | LocaleCode::YavCm => LanguageCode::Bnt, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/locale/tests.rs b/thoth-api/src/model/locale/tests.rs new file mode 100644 index 00000000..c39bed45 --- /dev/null +++ b/thoth-api/src/model/locale/tests.rs @@ -0,0 +1,128 @@ +use super::*; + +mod conversions { + use super::*; + #[cfg(feature = "backend")] + use crate::model::tests::db::setup_test_db; + #[cfg(feature = "backend")] + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + use strum::IntoEnumIterator; + + #[test] + fn locale_to_language_code_maps_basic_english() { + let lang: LanguageCode = LocaleCode::En.into(); + assert_eq!(lang, LanguageCode::Eng); + assert_eq!(lang.to_string().to_lowercase(), "eng"); + } + + #[test] + fn locale_to_language_code_maps_regional_variants() { + // English variants should all map to Eng (eng) + let lang: LanguageCode = LocaleCode::EnUs.into(); + assert_eq!(lang, LanguageCode::Eng); + let lang: LanguageCode = LocaleCode::EnGb.into(); + assert_eq!(lang, LanguageCode::Eng); + let lang: LanguageCode = LocaleCode::EnCa.into(); + assert_eq!(lang, LanguageCode::Eng); + let lang: LanguageCode = LocaleCode::EnAu.into(); + assert_eq!(lang, LanguageCode::Eng); + + // French variants should all map to Fre (fre) - ISO 639-2/B + let lang: LanguageCode = LocaleCode::Fr.into(); + assert_eq!(lang, LanguageCode::Fre); + let lang: LanguageCode = LocaleCode::FrFr.into(); + assert_eq!(lang, LanguageCode::Fre); + let lang: LanguageCode = LocaleCode::FrCa.into(); + assert_eq!(lang, LanguageCode::Fre); + let lang: LanguageCode = LocaleCode::FrBe.into(); + assert_eq!(lang, LanguageCode::Fre); + + // Spanish variants should all map to Spa (spa) + let lang: LanguageCode = LocaleCode::Es.into(); + assert_eq!(lang, LanguageCode::Spa); + let lang: LanguageCode = LocaleCode::EsEs.into(); + assert_eq!(lang, LanguageCode::Spa); + let lang: LanguageCode = LocaleCode::EsMx.into(); + assert_eq!(lang, LanguageCode::Spa); + let lang: LanguageCode = LocaleCode::EsAr.into(); + assert_eq!(lang, LanguageCode::Spa); + } + + #[test] + fn locale_to_language_code_maps_major_languages() { + // Test a variety of major world languages (ISO 639-2/B codes) + let lang: LanguageCode = LocaleCode::De.into(); + assert_eq!(lang, LanguageCode::Ger); // German + let lang: LanguageCode = LocaleCode::It.into(); + assert_eq!(lang, LanguageCode::Ita); // Italian + let lang: LanguageCode = LocaleCode::Pt.into(); + assert_eq!(lang, LanguageCode::Por); // Portuguese + let lang: LanguageCode = LocaleCode::Ru.into(); + assert_eq!(lang, LanguageCode::Rus); // Russian + let lang: LanguageCode = LocaleCode::Zh.into(); + assert_eq!(lang, LanguageCode::Chi); // Chinese + let lang: LanguageCode = LocaleCode::Ja.into(); + assert_eq!(lang, LanguageCode::Jpn); // Japanese + let lang: LanguageCode = LocaleCode::Ko.into(); + assert_eq!(lang, LanguageCode::Kor); // Korean + let lang: LanguageCode = LocaleCode::Ar.into(); + assert_eq!(lang, LanguageCode::Ara); // Arabic + let lang: LanguageCode = LocaleCode::Hi.into(); + assert_eq!(lang, LanguageCode::Hin); // Hindi + let lang: LanguageCode = LocaleCode::Nl.into(); + assert_eq!(lang, LanguageCode::Dut); // Dutch + let lang: LanguageCode = LocaleCode::Sv.into(); + assert_eq!(lang, LanguageCode::Swe); // Swedish + let lang: LanguageCode = LocaleCode::Pl.into(); + assert_eq!(lang, LanguageCode::Pol); // Polish + } + + #[test] + fn locale_to_language_code_maps_less_common_languages() { + // Test some less common languages (ISO 639-2/B codes) + let lang: LanguageCode = LocaleCode::Cy.into(); + assert_eq!(lang, LanguageCode::Wel); // Welsh + let lang: LanguageCode = LocaleCode::Ga.into(); + assert_eq!(lang, LanguageCode::Gle); // Irish + let lang: LanguageCode = LocaleCode::Eu.into(); + assert_eq!(lang, LanguageCode::Baq); // Basque + let lang: LanguageCode = LocaleCode::Is.into(); + assert_eq!(lang, LanguageCode::Ice); // Icelandic + let lang: LanguageCode = LocaleCode::Ka.into(); + assert_eq!(lang, LanguageCode::Geo); // Georgian + let lang: LanguageCode = LocaleCode::Hy.into(); + assert_eq!(lang, LanguageCode::Arm); // Armenian + let lang: LanguageCode = LocaleCode::Bo.into(); + assert_eq!(lang, LanguageCode::Tib); // Tibetan + let lang: LanguageCode = LocaleCode::Si.into(); + assert_eq!(lang, LanguageCode::Sin); // Sinhala + } + + #[test] + fn locale_to_language_code_maps_all_variants() { + for locale in LocaleCode::iter() { + let lang: LanguageCode = locale.into(); + let code = lang.to_string(); + assert_eq!(code.len(), 3); + assert!(code.chars().all(|c| c.is_ascii_uppercase())); + } + } + + #[cfg(feature = "backend")] + #[test] + fn localecode_graphql_roundtrip() { + assert_graphql_enum_roundtrip(LocaleCode::En); + } + + #[cfg(feature = "backend")] + #[test] + fn localecode_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<LocaleCode, crate::schema::sql_types::LocaleCode>( + pool.as_ref(), + "'en'::locale_code", + LocaleCode::En, + ); + } +} diff --git a/thoth-api/src/model/location/crud.rs b/thoth-api/src/model/location/crud.rs index 39739c9b..a91e6723 100644 --- a/thoth-api/src/model/location/crud.rs +++ b/thoth-api/src/model/location/crud.rs @@ -2,8 +2,6 @@ use super::{ Location, LocationField, LocationHistory, LocationOrderBy, LocationPlatform, NewLocation, NewLocationHistory, PatchLocation, }; -use crate::db_insert; -use crate::graphql::utils::Direction; use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{location, location_history}; use diesel::{Connection, ExpressionMethods, QueryDsl, RunQueryDsl}; @@ -17,6 +15,7 @@ impl Crud for Location { type FilterParameter1 = LocationPlatform; type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.location_id @@ -34,6 +33,7 @@ impl Crud for Location { location_platforms: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Location>> { use crate::schema::location::dsl::*; let mut connection = db.get()?; @@ -46,38 +46,30 @@ impl Crud for Location { .into_boxed(); query = match order.field { - LocationField::LocationId => match order.direction { - Direction::Asc => query.order(location_id.asc()), - Direction::Desc => query.order(location_id.desc()), - }, - LocationField::PublicationId => match order.direction { - Direction::Asc => query.order(publication_id.asc()), - Direction::Desc => query.order(publication_id.desc()), - }, - LocationField::LandingPage => match order.direction { - Direction::Asc => query.order(landing_page.asc()), - Direction::Desc => query.order(landing_page.desc()), - }, - LocationField::FullTextUrl => match order.direction { - Direction::Asc => query.order(full_text_url.asc()), - Direction::Desc => query.order(full_text_url.desc()), - }, - LocationField::LocationPlatform => match order.direction { - Direction::Asc => query.order(location_platform.asc()), - Direction::Desc => query.order(location_platform.desc()), - }, - LocationField::Canonical => match order.direction { - Direction::Asc => query.order(canonical.asc()), - Direction::Desc => query.order(canonical.desc()), - }, - LocationField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - LocationField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + LocationField::LocationId => { + apply_directional_order!(query, order.direction, order, location_id) + } + LocationField::PublicationId => { + apply_directional_order!(query, order.direction, order, publication_id) + } + LocationField::LandingPage => { + apply_directional_order!(query, order.direction, order, landing_page) + } + LocationField::FullTextUrl => { + apply_directional_order!(query, order.direction, order, full_text_url) + } + LocationField::LocationPlatform => { + apply_directional_order!(query, order.direction, order, location_platform) + } + LocationField::Canonical => { + apply_directional_order!(query, order.direction, order, canonical) + } + LocationField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + LocationField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if !publishers.is_empty() { query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); @@ -102,6 +94,7 @@ impl Crud for Location { location_platforms: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::location::dsl::*; let mut connection = db.get()?; @@ -120,10 +113,6 @@ impl Crud for Location { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::publication::Publication::from_id(db, &self.publication_id)?.publisher_id(db) - } - // `crud_methods!` cannot be used for update(), because we need to execute multiple statements // in the same transaction for changing a non-canonical location to canonical. // These functions recreate the `crud_methods!` logic. @@ -144,13 +133,12 @@ impl Crud for Location { }) } - fn update( + fn update<C: crate::policy::PolicyContext>( &self, - db: &crate::db::PgPool, + ctx: &C, data: &PatchLocation, - account_id: &Uuid, ) -> ThothResult<Self> { - let mut connection = db.get()?; + let mut connection = ctx.db().get()?; connection .transaction(|connection| { if data.canonical == self.canonical { @@ -165,7 +153,7 @@ impl Crud for Location { } else { // Update the existing canonical location to non-canonical let mut old_canonical_location = - PatchLocation::from(self.get_canonical_location(db)?); + PatchLocation::from(self.get_canonical_location(ctx.db())?); old_canonical_location.canonical = false; diesel::update(location::table.find(old_canonical_location.location_id)) .set(old_canonical_location) @@ -177,7 +165,7 @@ impl Crud for Location { } }) .and_then(|location| { - self.new_history_entry(account_id) + self.new_history_entry(ctx.user_id()?) .insert(&mut connection) .map(|_| location) }) @@ -193,13 +181,17 @@ impl Crud for Location { } } +publisher_id_impls!(Location, NewLocation, PatchLocation, |s, db| { + crate::model::publication::Publication::from_id(db, &s.publication_id)?.publisher_id(db) +}); + impl HistoryEntry for Location { type NewHistoryEntity = NewLocationHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { location_id: self.location_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -299,27 +291,3 @@ fn location_canonical_record_complete( } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_location_pk() { - let location: Location = Default::default(); - assert_eq!(location.pk(), location.location_id); - } - - #[test] - fn test_new_location_history_from_location() { - let location: Location = Default::default(); - let account_id: Uuid = Default::default(); - let new_location_history = location.new_history_entry(&account_id); - assert_eq!(new_location_history.location_id, location.location_id); - assert_eq!(new_location_history.account_id, account_id); - assert_eq!( - new_location_history.data, - serde_json::Value::String(serde_json::to_string(&location).unwrap()) - ); - } -} diff --git a/thoth-api/src/model/location/mod.rs b/thoth-api/src/model/location/mod.rs index c96a26f3..8dff1152 100644 --- a/thoth-api/src/model/location/mod.rs +++ b/thoth-api/src/model/location/mod.rs @@ -3,7 +3,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::types::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::location; @@ -12,7 +12,7 @@ use crate::schema::location_history; #[cfg_attr( feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), graphql(description = "Platform where a publication is hosted or can be acquired"), ExistingTypePath = "crate::schema::sql_types::LocationPlatform" )] @@ -169,7 +169,7 @@ pub enum LocationField { UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Location { @@ -185,7 +185,7 @@ pub struct Location { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new location (such as a web shop or distribution platform) where a publication can be acquired or viewed"), diesel(table_name = location) )] @@ -199,7 +199,7 @@ pub struct NewLocation { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing location (such as a web shop or distribution platform) where a publication can be acquired or viewed"), diesel(table_name = location, treat_none_as_null = true) )] @@ -212,23 +212,23 @@ pub struct PatchLocation { pub canonical: bool, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct LocationHistory { pub location_history_id: Uuid, pub location_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = location_history) )] pub struct NewLocationHistory { pub location_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -264,144 +264,11 @@ impl From<Location> for PatchLocation { } } -#[test] -fn test_location_to_patch_location() { - let location = Location { - location_id: Uuid::parse_str("00000000-0000-0000-AAAA-000000000001").unwrap(), - publication_id: Uuid::parse_str("00000000-0000-0000-AAAA-000000000002").unwrap(), - landing_page: Some("https://www.book.com/pb_landing".to_string()), - full_text_url: Some("https://example.com/full_text.pdf".to_string()), - location_platform: LocationPlatform::PublisherWebsite, - created_at: Default::default(), - updated_at: Default::default(), - canonical: true, - }; - - let patch_location = PatchLocation::from(location.clone()); - - assert_eq!(patch_location.location_id, location.location_id); - assert_eq!(patch_location.publication_id, location.publication_id); - assert_eq!(patch_location.landing_page, location.landing_page); - assert_eq!(patch_location.full_text_url, location.full_text_url); - assert_eq!(patch_location.location_platform, location.location_platform); - assert_eq!(patch_location.canonical, location.canonical); -} - -#[test] -fn test_locationplatform_default() { - let locationplatform: LocationPlatform = Default::default(); - assert_eq!(locationplatform, LocationPlatform::Other); -} - -#[test] -fn test_locationplatform_display() { - assert_eq!(format!("{}", LocationPlatform::ProjectMuse), "Project MUSE"); - assert_eq!(format!("{}", LocationPlatform::Oapen), "OAPEN"); - assert_eq!(format!("{}", LocationPlatform::Doab), "DOAB"); - assert_eq!(format!("{}", LocationPlatform::Jstor), "JSTOR"); - assert_eq!(format!("{}", LocationPlatform::EbscoHost), "EBSCO Host"); - assert_eq!(format!("{}", LocationPlatform::OclcKb), "OCLC KB"); - assert_eq!(format!("{}", LocationPlatform::ProquestKb), "ProQuest KB"); - assert_eq!( - format!("{}", LocationPlatform::ProquestExlibris), - "ProQuest ExLibris" - ); - assert_eq!(format!("{}", LocationPlatform::EbscoKb), "EBSCO KB"); - assert_eq!(format!("{}", LocationPlatform::JiscKb), "JISC KB"); - assert_eq!(format!("{}", LocationPlatform::GoogleBooks), "Google Books"); - assert_eq!( - format!("{}", LocationPlatform::InternetArchive), - "Internet Archive" - ); - assert_eq!(format!("{}", LocationPlatform::ScienceOpen), "ScienceOpen"); - assert_eq!(format!("{}", LocationPlatform::ScieloBooks), "SciELO Books"); - assert_eq!(format!("{}", LocationPlatform::Zenodo), "Zenodo"); - assert_eq!( - format!("{}", LocationPlatform::PublisherWebsite), - "Publisher Website" - ); - assert_eq!(format!("{}", LocationPlatform::Thoth), "Thoth"); - assert_eq!(format!("{}", LocationPlatform::Other), "Other"); -} - -#[test] -fn test_locationplatform_fromstr() { - use std::str::FromStr; - assert_eq!( - LocationPlatform::from_str("Project MUSE").unwrap(), - LocationPlatform::ProjectMuse - ); - assert_eq!( - LocationPlatform::from_str("OAPEN").unwrap(), - LocationPlatform::Oapen - ); - assert_eq!( - LocationPlatform::from_str("DOAB").unwrap(), - LocationPlatform::Doab - ); - assert_eq!( - LocationPlatform::from_str("JSTOR").unwrap(), - LocationPlatform::Jstor - ); - assert_eq!( - LocationPlatform::from_str("EBSCO Host").unwrap(), - LocationPlatform::EbscoHost - ); - assert_eq!( - LocationPlatform::from_str("OCLC KB").unwrap(), - LocationPlatform::OclcKb - ); - assert_eq!( - LocationPlatform::from_str("ProQuest KB").unwrap(), - LocationPlatform::ProquestKb - ); - assert_eq!( - LocationPlatform::from_str("ProQuest ExLibris").unwrap(), - LocationPlatform::ProquestExlibris - ); - assert_eq!( - LocationPlatform::from_str("EBSCO KB").unwrap(), - LocationPlatform::EbscoKb - ); - assert_eq!( - LocationPlatform::from_str("JISC KB").unwrap(), - LocationPlatform::JiscKb - ); - assert_eq!( - LocationPlatform::from_str("Google Books").unwrap(), - LocationPlatform::GoogleBooks - ); - assert_eq!( - LocationPlatform::from_str("Internet Archive").unwrap(), - LocationPlatform::InternetArchive - ); - assert_eq!( - LocationPlatform::from_str("ScienceOpen").unwrap(), - LocationPlatform::ScienceOpen - ); - assert_eq!( - LocationPlatform::from_str("SciELO Books").unwrap(), - LocationPlatform::ScieloBooks - ); - assert_eq!( - LocationPlatform::from_str("Zenodo").unwrap(), - LocationPlatform::Zenodo - ); - assert_eq!( - LocationPlatform::from_str("Publisher Website").unwrap(), - LocationPlatform::PublisherWebsite - ); - assert_eq!( - LocationPlatform::from_str("Thoth").unwrap(), - LocationPlatform::Thoth - ); - assert_eq!( - LocationPlatform::from_str("Other").unwrap(), - LocationPlatform::Other - ); - assert!(LocationPlatform::from_str("Amazon").is_err()); - assert!(LocationPlatform::from_str("Twitter").is_err()); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::LocationPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/location/policy.rs b/thoth-api/src/model/location/policy.rs new file mode 100644 index 00000000..151799a6 --- /dev/null +++ b/thoth-api/src/model/location/policy.rs @@ -0,0 +1,97 @@ +use diesel::dsl::exists; +use diesel::prelude::*; +use diesel::select; +use uuid::Uuid; + +use super::{Location, LocationPlatform, NewLocation, PatchLocation}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy, UserAccess}; +use crate::schema::location; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Location`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +/// - enforcing any additional business rules (e.g. Thoth platform restrictions) +pub struct LocationPolicy; + +fn has_canonical_thoth_location( + db: &crate::db::PgPool, + publication_id: &Uuid, +) -> ThothResult<bool> { + let mut connection = db.get()?; + let query = location::table + .filter(location::publication_id.eq(publication_id)) + .filter(location::location_platform.eq(LocationPlatform::Thoth)) + .filter(location::canonical.eq(true)); + + let result: bool = select(exists(query)).get_result(&mut connection)?; + Ok(result) +} + +impl CreatePolicy<NewLocation> for LocationPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewLocation, _params: ()) -> ThothResult<()> { + let user = ctx.require_publisher_for(data)?; + + // Only superusers can create new locations where Location Platform is Thoth. + if !user.is_superuser() && data.location_platform == LocationPlatform::Thoth { + return Err(ThothError::ThothLocationError); + } + + // Canonical locations must be complete; non-canonical locations must satisfy rules. + if data.canonical { + data.canonical_record_complete(ctx.db())?; + } else { + data.can_be_non_canonical(ctx.db())?; + } + + Ok(()) + } +} + +impl UpdatePolicy<Location, PatchLocation> for LocationPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Location, + patch: &PatchLocation, + _params: (), + ) -> ThothResult<()> { + let user = ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + // Only superusers can edit locations where Location Platform is Thoth. + if !user.is_superuser() && current.location_platform == LocationPlatform::Thoth { + return Err(ThothError::ThothLocationError); + } + + // Only superusers can update the canonical location when a Thoth Location Platform + // canonical location already exists for the publication. + if patch.canonical + && has_canonical_thoth_location(ctx.db(), &patch.publication_id)? + && !user.is_superuser() + { + return Err(ThothError::ThothUpdateCanonicalError); + } + + // If setting canonical to true, require record completeness. + if patch.canonical { + patch.canonical_record_complete(ctx.db())?; + } + + Ok(()) + } +} + +impl DeletePolicy<Location> for LocationPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, location: &Location) -> ThothResult<()> { + let user = ctx.require_publisher_for(location)?; + + // Thoth platform locations are superuser-restricted. + if !user.is_superuser() && location.location_platform == LocationPlatform::Thoth { + return Err(ThothError::ThothLocationError); + } + + Ok(()) + } +} diff --git a/thoth-api/src/model/location/tests.rs b/thoth-api/src/model/location/tests.rs new file mode 100644 index 00000000..c87a1ee4 --- /dev/null +++ b/thoth-api/src/model/location/tests.rs @@ -0,0 +1,1201 @@ +use super::*; + +mod defaults { + use super::*; + + #[test] + fn locationplatform_default_is_other() { + let locationplatform: LocationPlatform = Default::default(); + assert_eq!(locationplatform, LocationPlatform::Other); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn locationplatform_display_formats_expected_strings() { + assert_eq!(format!("{}", LocationPlatform::ProjectMuse), "Project MUSE"); + assert_eq!(format!("{}", LocationPlatform::Oapen), "OAPEN"); + assert_eq!(format!("{}", LocationPlatform::Doab), "DOAB"); + assert_eq!(format!("{}", LocationPlatform::Jstor), "JSTOR"); + assert_eq!(format!("{}", LocationPlatform::EbscoHost), "EBSCO Host"); + assert_eq!(format!("{}", LocationPlatform::OclcKb), "OCLC KB"); + assert_eq!(format!("{}", LocationPlatform::ProquestKb), "ProQuest KB"); + assert_eq!( + format!("{}", LocationPlatform::ProquestExlibris), + "ProQuest ExLibris" + ); + assert_eq!(format!("{}", LocationPlatform::EbscoKb), "EBSCO KB"); + assert_eq!(format!("{}", LocationPlatform::JiscKb), "JISC KB"); + assert_eq!(format!("{}", LocationPlatform::GoogleBooks), "Google Books"); + assert_eq!( + format!("{}", LocationPlatform::InternetArchive), + "Internet Archive" + ); + assert_eq!(format!("{}", LocationPlatform::ScienceOpen), "ScienceOpen"); + assert_eq!(format!("{}", LocationPlatform::ScieloBooks), "SciELO Books"); + assert_eq!(format!("{}", LocationPlatform::Zenodo), "Zenodo"); + assert_eq!( + format!("{}", LocationPlatform::PublisherWebsite), + "Publisher Website" + ); + assert_eq!(format!("{}", LocationPlatform::Thoth), "Thoth"); + assert_eq!(format!("{}", LocationPlatform::Other), "Other"); + } + + #[test] + fn locationplatform_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + LocationPlatform::from_str("Project MUSE").unwrap(), + LocationPlatform::ProjectMuse + ); + assert_eq!( + LocationPlatform::from_str("OAPEN").unwrap(), + LocationPlatform::Oapen + ); + assert_eq!( + LocationPlatform::from_str("DOAB").unwrap(), + LocationPlatform::Doab + ); + assert_eq!( + LocationPlatform::from_str("JSTOR").unwrap(), + LocationPlatform::Jstor + ); + assert_eq!( + LocationPlatform::from_str("EBSCO Host").unwrap(), + LocationPlatform::EbscoHost + ); + assert_eq!( + LocationPlatform::from_str("OCLC KB").unwrap(), + LocationPlatform::OclcKb + ); + assert_eq!( + LocationPlatform::from_str("ProQuest KB").unwrap(), + LocationPlatform::ProquestKb + ); + assert_eq!( + LocationPlatform::from_str("ProQuest ExLibris").unwrap(), + LocationPlatform::ProquestExlibris + ); + assert_eq!( + LocationPlatform::from_str("EBSCO KB").unwrap(), + LocationPlatform::EbscoKb + ); + assert_eq!( + LocationPlatform::from_str("JISC KB").unwrap(), + LocationPlatform::JiscKb + ); + assert_eq!( + LocationPlatform::from_str("Google Books").unwrap(), + LocationPlatform::GoogleBooks + ); + assert_eq!( + LocationPlatform::from_str("Internet Archive").unwrap(), + LocationPlatform::InternetArchive + ); + assert_eq!( + LocationPlatform::from_str("ScienceOpen").unwrap(), + LocationPlatform::ScienceOpen + ); + assert_eq!( + LocationPlatform::from_str("SciELO Books").unwrap(), + LocationPlatform::ScieloBooks + ); + assert_eq!( + LocationPlatform::from_str("Zenodo").unwrap(), + LocationPlatform::Zenodo + ); + assert_eq!( + LocationPlatform::from_str("Publisher Website").unwrap(), + LocationPlatform::PublisherWebsite + ); + assert_eq!( + LocationPlatform::from_str("Thoth").unwrap(), + LocationPlatform::Thoth + ); + assert_eq!( + LocationPlatform::from_str("Other").unwrap(), + LocationPlatform::Other + ); + assert!(LocationPlatform::from_str("Amazon").is_err()); + assert!(LocationPlatform::from_str("Twitter").is_err()); + } +} + +mod conversions { + use super::*; + #[cfg(feature = "backend")] + use crate::model::tests::db::setup_test_db; + #[cfg(feature = "backend")] + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn location_into_patch_location_copies_fields() { + let location = Location { + location_id: Uuid::parse_str("00000000-0000-0000-AAAA-000000000001").unwrap(), + publication_id: Uuid::parse_str("00000000-0000-0000-AAAA-000000000002").unwrap(), + landing_page: Some("https://www.book.com/pb_landing".to_string()), + full_text_url: Some("https://example.com/full_text.pdf".to_string()), + location_platform: LocationPlatform::PublisherWebsite, + created_at: Default::default(), + updated_at: Default::default(), + canonical: true, + }; + + let patch_location = PatchLocation::from(location.clone()); + + assert_eq!(patch_location.location_id, location.location_id); + assert_eq!(patch_location.publication_id, location.publication_id); + assert_eq!(patch_location.landing_page, location.landing_page); + assert_eq!(patch_location.full_text_url, location.full_text_url); + assert_eq!(patch_location.location_platform, location.location_platform); + assert_eq!(patch_location.canonical, location.canonical); + } + + #[cfg(feature = "backend")] + #[test] + fn locationplatform_graphql_roundtrip() { + assert_graphql_enum_roundtrip(LocationPlatform::Other); + } + + #[cfg(feature = "backend")] + #[test] + fn locationplatform_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<LocationPlatform, crate::schema::sql_types::LocationPlatform>( + pool.as_ref(), + "'Other'::location_platform", + LocationPlatform::Other, + ); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let location: Location = Default::default(); + assert_eq!(location.pk(), location.location_id); + } + + #[test] + fn history_entry_serializes_model() { + let location: Location = Default::default(); + let user_id = "123456".to_string(); + let new_location_history = location.new_history_entry(&user_id); + assert_eq!(new_location_history.location_id, location.location_id); + assert_eq!(new_location_history.user_id, user_id); + assert_eq!( + new_location_history.data, + serde_json::Value::String(serde_json::to_string(&location).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::location::policy::LocationPolicy; + use crate::model::publication::{NewPublication, Publication, PublicationType}; + use crate::model::tests::db::{ + create_imprint, create_publication, create_publisher, create_work, setup_test_db, + test_context_with_user, test_superuser, test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + use thoth_errors::ThothError; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("location-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + let new_location = NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/landing".to_string()), + full_text_url: None, + location_platform: LocationPlatform::PublisherWebsite, + canonical: true, + }; + + let location = Location::create(pool.as_ref(), &new_location).expect("Failed to create"); + let patch = PatchLocation { + location_id: location.location_id, + publication_id: location.publication_id, + landing_page: Some("https://example.com/updated".to_string()), + full_text_url: None, + location_platform: location.location_platform, + canonical: location.canonical, + }; + + assert!(LocationPolicy::can_create(&ctx, &new_location, ()).is_ok()); + assert!(LocationPolicy::can_update(&ctx, &location, &patch, ()).is_ok()); + assert!(LocationPolicy::can_delete(&ctx, &location).is_ok()); + } + + #[test] + fn crud_policy_rejects_canonical_update_without_complete_record() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("location-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = Publication::create( + pool.as_ref(), + &NewPublication { + publication_type: PublicationType::Pdf, + work_id: work.work_id, + isbn: None, + width_mm: None, + width_in: None, + height_mm: None, + height_in: None, + depth_mm: None, + depth_in: None, + weight_g: None, + weight_oz: None, + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + }, + ) + .expect("Failed to create publication"); + + let location = Location::create( + pool.as_ref(), + &NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/landing".to_string()), + full_text_url: None, + location_platform: LocationPlatform::PublisherWebsite, + canonical: false, + }, + ) + .expect("Failed to create location"); + + let patch = PatchLocation { + location_id: location.location_id, + publication_id: location.publication_id, + landing_page: location.landing_page.clone(), + full_text_url: None, + location_platform: location.location_platform, + canonical: true, + }; + + let result = LocationPolicy::can_update(&ctx, &location, &patch, ()); + assert!(matches!(result, Err(ThothError::LocationUrlError))); + } + + #[test] + fn crud_policy_allows_update_when_not_canonical() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("location-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let location = Location::create( + pool.as_ref(), + &NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/landing".to_string()), + full_text_url: None, + location_platform: LocationPlatform::PublisherWebsite, + canonical: true, + }, + ) + .expect("Failed to create location"); + + let patch = PatchLocation { + location_id: location.location_id, + publication_id: location.publication_id, + landing_page: location.landing_page.clone(), + full_text_url: location.full_text_url.clone(), + location_platform: location.location_platform, + canonical: false, + }; + + assert!(LocationPolicy::can_update(&ctx, &location, &patch, ()).is_ok()); + } + + #[test] + fn crud_policy_allows_non_canonical_when_canonical_exists() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("location-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + Location::create( + pool.as_ref(), + &NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/landing".to_string()), + full_text_url: None, + location_platform: LocationPlatform::PublisherWebsite, + canonical: true, + }, + ) + .expect("Failed to create canonical location"); + + let new_location = NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/other".to_string()), + full_text_url: None, + location_platform: LocationPlatform::PublisherWebsite, + canonical: false, + }; + + assert!(LocationPolicy::can_create(&ctx, &new_location, ()).is_ok()); + } + + #[test] + fn crud_policy_rejects_non_canonical_without_canonical() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("location-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let new_location = NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/landing".to_string()), + full_text_url: None, + location_platform: LocationPlatform::PublisherWebsite, + canonical: false, + }; + + let result = LocationPolicy::can_create(&ctx, &new_location, ()); + assert!(matches!(result, Err(ThothError::CanonicalLocationError))); + } + + #[test] + fn crud_policy_rejects_non_superuser_for_thoth_platform() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("location-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + let new_location = NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/landing".to_string()), + full_text_url: Some("https://example.com/full".to_string()), + location_platform: LocationPlatform::Thoth, + canonical: true, + }; + + assert!(LocationPolicy::can_create(&ctx, &new_location, ()).is_err()); + + let superuser = test_superuser("location-superuser"); + let super_ctx = test_context_with_user(pool.clone(), superuser); + assert!(LocationPolicy::can_create(&super_ctx, &new_location, ()).is_ok()); + } + + #[test] + fn crud_policy_rejects_non_superuser_for_thoth_update_and_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("location-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + let location = Location::create( + pool.as_ref(), + &NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/landing".to_string()), + full_text_url: Some("https://example.com/full".to_string()), + location_platform: LocationPlatform::Thoth, + canonical: true, + }, + ) + .expect("Failed to create location"); + + let patch = PatchLocation { + location_id: location.location_id, + publication_id: location.publication_id, + landing_page: Some("https://example.com/updated".to_string()), + full_text_url: Some("https://example.com/full.pdf".to_string()), + location_platform: location.location_platform, + canonical: location.canonical, + }; + + let update_result = LocationPolicy::can_update(&ctx, &location, &patch, ()); + assert!(matches!(update_result, Err(ThothError::ThothLocationError))); + + let delete_result = LocationPolicy::can_delete(&ctx, &location); + assert!(matches!(delete_result, Err(ThothError::ThothLocationError))); + } + + #[test] + fn crud_policy_rejects_non_superuser_thoth_canonical_update() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("location-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + Location::create( + pool.as_ref(), + &NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/landing".to_string()), + full_text_url: Some("https://example.com/full".to_string()), + location_platform: LocationPlatform::Thoth, + canonical: true, + }, + ) + .expect("Failed to create canonical thoth location"); + + let location = Location::create( + pool.as_ref(), + &NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/other".to_string()), + full_text_url: None, + location_platform: LocationPlatform::PublisherWebsite, + canonical: false, + }, + ) + .expect("Failed to create location"); + + let patch = PatchLocation { + location_id: location.location_id, + publication_id: location.publication_id, + landing_page: location.landing_page.clone(), + full_text_url: location.full_text_url.clone(), + location_platform: location.location_platform, + canonical: true, + }; + + let result = LocationPolicy::can_update(&ctx, &location, &patch, ()); + assert!(matches!(result, Err(ThothError::ThothUpdateCanonicalError))); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + use uuid::Uuid; + + use crate::model::publication::{NewPublication, Publication, PublicationType}; + use crate::model::tests::db::{ + create_imprint, create_publication, create_publisher, create_work, setup_test_db, + test_context, + }; + use crate::model::Crud; + use thoth_errors::ThothError; + + fn make_location( + pool: &crate::db::PgPool, + publication_id: Uuid, + location_platform: LocationPlatform, + canonical: bool, + landing_page: Option<String>, + ) -> Location { + let new_location = NewLocation { + publication_id, + landing_page, + full_text_url: None, + location_platform, + canonical, + }; + + Location::create(pool, &new_location).expect("Failed to create location") + } + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let new_location = NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/landing".to_string()), + full_text_url: None, + location_platform: LocationPlatform::PublisherWebsite, + canonical: true, + }; + + let location = Location::create(pool.as_ref(), &new_location).expect("Failed to create"); + let fetched = + Location::from_id(pool.as_ref(), &location.location_id).expect("Failed to fetch"); + assert_eq!(location.location_id, fetched.location_id); + + let patch = PatchLocation { + location_id: location.location_id, + publication_id: location.publication_id, + landing_page: Some("https://example.com/updated".to_string()), + full_text_url: Some("https://example.com/full.pdf".to_string()), + location_platform: LocationPlatform::Other, + canonical: true, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = location.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.landing_page, patch.landing_page); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Location::from_id(pool.as_ref(), &deleted.location_id).is_err()); + } + + #[test] + fn crud_update_rejects_changing_canonical_to_non_canonical() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let location = make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::PublisherWebsite, + true, + Some("https://example.com/landing".to_string()), + ); + let patch = PatchLocation { + location_id: location.location_id, + publication_id: location.publication_id, + landing_page: location.landing_page.clone(), + full_text_url: location.full_text_url.clone(), + location_platform: location.location_platform, + canonical: false, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let result = location.update(&ctx, &patch); + assert!(matches!(result, Err(ThothError::CanonicalLocationError))); + } + + #[test] + fn crud_update_promotes_non_canonical_to_canonical() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let canonical = make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::PublisherWebsite, + true, + Some("https://example.com/canonical".to_string()), + ); + let non_canonical = make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::Other, + false, + Some("https://example.com/other".to_string()), + ); + + let patch = PatchLocation { + location_id: non_canonical.location_id, + publication_id: non_canonical.publication_id, + landing_page: non_canonical.landing_page.clone(), + full_text_url: non_canonical.full_text_url.clone(), + location_platform: non_canonical.location_platform, + canonical: true, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = non_canonical + .update(&ctx, &patch) + .expect("Failed to promote non-canonical location"); + + assert!(updated.canonical); + + let refreshed = Location::from_id(pool.as_ref(), &canonical.location_id) + .expect("Failed to fetch canonical location"); + assert!(!refreshed.canonical); + + let canonical_lookup = non_canonical + .get_canonical_location(pool.as_ref()) + .expect("Failed to load canonical location"); + assert_eq!(canonical_lookup.location_id, updated.location_id); + } + + #[test] + fn crud_new_location_can_be_non_canonical_requires_existing_canonical() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let new_location = NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/landing".to_string()), + full_text_url: None, + location_platform: LocationPlatform::PublisherWebsite, + canonical: false, + }; + + let result = new_location.can_be_non_canonical(pool.as_ref()); + assert!(matches!(result, Err(ThothError::CanonicalLocationError))); + } + + #[test] + fn crud_new_location_can_be_non_canonical_allows_when_canonical_exists() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + Location::create( + pool.as_ref(), + &NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/landing".to_string()), + full_text_url: Some("https://example.com/full.pdf".to_string()), + location_platform: LocationPlatform::PublisherWebsite, + canonical: true, + }, + ) + .expect("Failed to create canonical location"); + + let new_location = NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/other".to_string()), + full_text_url: None, + location_platform: LocationPlatform::Other, + canonical: false, + }; + + assert!(new_location.can_be_non_canonical(pool.as_ref()).is_ok()); + } + + #[test] + fn crud_canonical_record_complete_requires_urls_for_digital_publications() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let publication = Publication::create( + pool.as_ref(), + &NewPublication { + publication_type: PublicationType::Pdf, + work_id: work.work_id, + isbn: None, + width_mm: None, + width_in: None, + height_mm: None, + height_in: None, + depth_mm: None, + depth_in: None, + weight_g: None, + weight_oz: None, + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + }, + ) + .expect("Failed to create publication"); + + let new_location = NewLocation { + publication_id: publication.publication_id, + landing_page: Some("https://example.com/landing".to_string()), + full_text_url: None, + location_platform: LocationPlatform::PublisherWebsite, + canonical: true, + }; + + let result = new_location.canonical_record_complete(pool.as_ref()); + assert!(matches!(result, Err(ThothError::LocationUrlError))); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::PublisherWebsite, + true, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::Other, + false, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + + let order = LocationOrderBy { + field: LocationField::LocationId, + direction: Direction::Asc, + }; + + let first = Location::all( + pool.as_ref(), + 1, + 0, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch locations"); + let second = Location::all( + pool.as_ref(), + 1, + 1, + None, + LocationOrderBy { + field: LocationField::LocationId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch locations"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].location_id, second[0].location_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::PublisherWebsite, + true, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::Other, + false, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + + let count = Location::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count locations"); + assert_eq!(count, 2); + } + + #[test] + fn crud_count_filters_by_platform() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::PublisherWebsite, + true, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::Other, + false, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + + let count = Location::count( + pool.as_ref(), + None, + vec![], + vec![LocationPlatform::PublisherWebsite], + vec![], + None, + None, + ) + .expect("Failed to count locations by platform"); + assert_eq!(count, 1); + } + + #[test] + fn crud_filter_param_limits_location_platforms() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let matches = make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::PublisherWebsite, + true, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::Other, + false, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + + let filtered = Location::all( + pool.as_ref(), + 10, + 0, + None, + LocationOrderBy { + field: LocationField::LocationId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![LocationPlatform::PublisherWebsite], + vec![], + None, + None, + ) + .expect("Failed to filter locations by platform"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].location_id, matches.location_id); + } + + #[test] + fn crud_filter_parent_publication_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + let other_work = create_work(pool.as_ref(), &imprint); + let other_publication = create_publication(pool.as_ref(), &other_work); + + let matches = make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::PublisherWebsite, + true, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + make_location( + pool.as_ref(), + other_publication.publication_id, + LocationPlatform::Other, + false, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + + let filtered = Location::all( + pool.as_ref(), + 10, + 0, + None, + LocationOrderBy { + field: LocationField::LocationId, + direction: Direction::Asc, + }, + vec![], + Some(publication.publication_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter locations by publication"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].location_id, matches.location_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + let matches = make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::PublisherWebsite, + true, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let other_work = create_work(pool.as_ref(), &other_imprint); + let other_publication = create_publication(pool.as_ref(), &other_work); + make_location( + pool.as_ref(), + other_publication.publication_id, + LocationPlatform::Other, + false, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + + let filtered = Location::all( + pool.as_ref(), + 10, + 0, + None, + LocationOrderBy { + field: LocationField::LocationId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter locations by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].location_id, matches.location_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let first = make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::PublisherWebsite, + true, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + let second = make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::Other, + false, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + let mut ids = [first.location_id, second.location_id]; + ids.sort(); + + let asc = Location::all( + pool.as_ref(), + 2, + 0, + None, + LocationOrderBy { + field: LocationField::LocationId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order locations (asc)"); + + let desc = Location::all( + pool.as_ref(), + 2, + 0, + None, + LocationOrderBy { + field: LocationField::LocationId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order locations (desc)"); + + assert_eq!(asc[0].location_id, ids[0]); + assert_eq!(desc[0].location_id, ids[1]); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::PublisherWebsite, + true, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + make_location( + pool.as_ref(), + publication.publication_id, + LocationPlatform::Other, + false, + Some(format!("https://example.com/{}", Uuid::new_v4())), + ); + + let fields: Vec<fn() -> LocationField> = vec![ + || LocationField::LocationId, + || LocationField::PublicationId, + || LocationField::LandingPage, + || LocationField::FullTextUrl, + || LocationField::LocationPlatform, + || LocationField::Canonical, + || LocationField::CreatedAt, + || LocationField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Location::all( + pool.as_ref(), + 10, + 0, + None, + LocationOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order locations"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/mod.rs b/thoth-api/src/model/mod.rs index eea84ce6..907096b0 100644 --- a/thoth-api/src/model/mod.rs +++ b/thoth-api/src/model/mod.rs @@ -1,12 +1,10 @@ +use crate::policy::PolicyContext; use chrono::{DateTime, TimeZone, Utc}; use isbn::Isbn13; use serde::{Deserialize, Serialize}; use std::fmt; use std::str::FromStr; -use strum::Display; -use strum::EnumString; use thoth_errors::{ThothError, ThothResult}; -#[cfg(feature = "backend")] use uuid::Uuid; pub const DOI_DOMAIN: &str = "https://doi.org/"; @@ -15,45 +13,7 @@ pub const ROR_DOMAIN: &str = "https://ror.org/"; #[cfg_attr( feature = "backend", - derive(juniper::GraphQLEnum), - graphql(description = "Unit of measurement for physical Work dimensions (mm, cm or in)") -)] -#[derive( - Debug, Copy, Clone, Default, Serialize, Deserialize, PartialEq, Eq, EnumString, Display, -)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[strum(serialize_all = "lowercase")] -pub enum LengthUnit { - #[cfg_attr(feature = "backend", graphql(description = "Millimetres"))] - #[default] - Mm, - #[cfg_attr(feature = "backend", graphql(description = "Centimetres"))] - Cm, - #[cfg_attr(feature = "backend", graphql(description = "Inches"))] - In, -} - -#[cfg_attr( - feature = "backend", - derive(juniper::GraphQLEnum), - graphql(description = "Unit of measurement for physical Work weight (grams or ounces)") -)] -#[derive( - Debug, Copy, Clone, Default, Serialize, Deserialize, PartialEq, Eq, EnumString, Display, -)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[strum(serialize_all = "lowercase")] -pub enum WeightUnit { - #[cfg_attr(feature = "backend", graphql(description = "Grams"))] - #[default] - G, - #[cfg_attr(feature = "backend", graphql(description = "Ounces"))] - Oz, -} - -#[cfg_attr( - feature = "backend", - derive(DieselNewType, juniper::GraphQLScalar), + derive(diesel_derive_newtype::DieselNewType, juniper::GraphQLScalar), graphql( transparent, description = r#"Digital Object Identifier. Expressed as `^https:\/\/doi\.org\/10\.\d{4,9}\/[-._;()\/:a-zA-Z0-9<>+\[\]]+$`"# @@ -62,9 +22,29 @@ pub enum WeightUnit { #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct Doi(String); +impl Doi { + fn identifier(&self) -> &str { + self.0.strip_prefix(DOI_DOMAIN).unwrap_or(&self.0) + } + + pub fn prefix(&self) -> &str { + self.identifier() + .split_once('/') + .map(|(prefix, _)| prefix) + .unwrap_or("") + } + + pub fn suffix(&self) -> &str { + self.identifier() + .split_once('/') + .map(|(_, suffix)| suffix) + .unwrap_or("") + } +} + #[cfg_attr( feature = "backend", - derive(DieselNewType, juniper::GraphQLScalar), + derive(diesel_derive_newtype::DieselNewType, juniper::GraphQLScalar), graphql( transparent, description = "13-digit International Standard Book Number, with its parts separated by hyphens" @@ -75,7 +55,7 @@ pub struct Isbn(String); #[cfg_attr( feature = "backend", - derive(DieselNewType, juniper::GraphQLScalar), + derive(diesel_derive_newtype::DieselNewType, juniper::GraphQLScalar), graphql( transparent, description = r#"ORCID (Open Researcher and Contributor ID) identifier. Expressed as `^https:\/\/orcid\.org\/\d{4}-\d{4}-\d{4}-\d{3}[\dX]$`"# @@ -86,7 +66,7 @@ pub struct Orcid(String); #[cfg_attr( feature = "backend", - derive(DieselNewType, juniper::GraphQLScalar), + derive(diesel_derive_newtype::DieselNewType, juniper::GraphQLScalar), graphql( transparent, description = r#"ROR (Research Organization Registry) identifier. Expressed as `^https:\/\/ror\.org\/0[a-hjkmnp-z0-9]{6}\d{2}$`"# @@ -97,7 +77,7 @@ pub struct Ror(String); #[cfg_attr( feature = "backend", - derive(DieselNewType, juniper::GraphQLScalar), + derive(diesel_derive_newtype::DieselNewType, juniper::GraphQLScalar), graphql( transparent, description = "RFC 3339 combined date and time in UTC time zone (e.g. \"1999-12-31T23:59:00Z\")" @@ -175,13 +155,12 @@ impl FromStr for Doi { Err(ThothError::DoiEmptyError) } else if let Some(matches) = RE.captures(input) { // The 0th capture always corresponds to the entire match - if let Some(identifier) = matches.get(1) { - let standardised = format!("{}{}", DOI_DOMAIN, identifier.as_str()); - let doi: Doi = Doi(standardised); - Ok(doi) - } else { - Err(ThothError::DoiParseError(input.to_string())) - } + let identifier = matches + .get(1) + .ok_or_else(|| ThothError::DoiParseError(input.to_string()))?; + let standardised = format!("{}{}", DOI_DOMAIN, identifier.as_str()); + let doi: Doi = Doi(standardised); + Ok(doi) } else { Err(ThothError::DoiParseError(input.to_string())) } @@ -196,10 +175,12 @@ impl FromStr for Isbn { Err(ThothError::IsbnEmptyError) } else { match input.parse::<Isbn13>() { - Ok(parsed) => match parsed.hyphenate() { - Ok(hyphenated) => Ok(Isbn(hyphenated.to_string())), - Err(_) => Err(ThothError::IsbnParseError(input.to_string())), - }, + Ok(parsed) => { + let hyphenated = parsed + .hyphenate() + .map_err(|_| ThothError::IsbnParseError(input.to_string()))?; + Ok(Isbn(hyphenated.to_string())) + } Err(_) => Err(ThothError::IsbnParseError(input.to_string())), } } @@ -227,13 +208,12 @@ impl FromStr for Orcid { Err(ThothError::OrcidEmptyError) } else if let Some(matches) = RE.captures(input) { // The 0th capture always corresponds to the entire match - if let Some(identifier) = matches.get(1) { - let standardised = format!("{}{}", ORCID_DOMAIN, identifier.as_str()); - let orcid: Orcid = Orcid(standardised); - Ok(orcid) - } else { - Err(ThothError::OrcidParseError(input.to_string())) - } + let identifier = matches + .get(1) + .ok_or_else(|| ThothError::OrcidParseError(input.to_string()))?; + let standardised = format!("{}{}", ORCID_DOMAIN, identifier.as_str()); + let orcid: Orcid = Orcid(standardised); + Ok(orcid) } else { Err(ThothError::OrcidParseError(input.to_string())) } @@ -261,13 +241,12 @@ impl FromStr for Ror { Err(ThothError::RorEmptyError) } else if let Some(matches) = RE.captures(input) { // The 0th capture always corresponds to the entire match - if let Some(identifier) = matches.get(1) { - let standardised = format!("{}{}", ROR_DOMAIN, identifier.as_str()); - let ror: Ror = Ror(standardised); - Ok(ror) - } else { - Err(ThothError::RorParseError(input.to_string())) - } + let identifier = matches + .get(1) + .ok_or_else(|| ThothError::RorParseError(input.to_string()))?; + let standardised = format!("{}{}", ROR_DOMAIN, identifier.as_str()); + let ror: Ror = Ror(standardised); + Ok(ror) } else { Err(ThothError::RorParseError(input.to_string())) } @@ -286,10 +265,16 @@ impl Isbn { } } +impl Orcid { + pub fn to_hyphenless_string(&self) -> String { + self.to_string().replace('-', "") + } +} + #[cfg(feature = "backend")] #[allow(clippy::too_many_arguments)] /// Common functionality to perform basic CRUD actions on Thoth entities -pub trait Crud +pub(crate) trait Crud where Self: Sized, { @@ -306,6 +291,9 @@ where /// A third such structure, e.g. `TimeExpression` type FilterParameter3; + /// A fourth such structure, e.g. `TimeExpression` + type FilterParameter4; + /// Specify the entity's primary key fn pk(&self) -> Uuid; @@ -327,6 +315,7 @@ where filter_param_1: Vec<Self::FilterParameter1>, filter_param_2: Vec<Self::FilterParameter2>, filter_param_3: Option<Self::FilterParameter3>, + filter_param_4: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Self>>; /// Query the database to obtain the total number of entities satisfying the search criteria @@ -337,6 +326,7 @@ where filter_param_1: Vec<Self::FilterParameter1>, filter_param_2: Vec<Self::FilterParameter2>, filter_param_3: Option<Self::FilterParameter3>, + filter_param_4: Option<Self::FilterParameter4>, ) -> ThothResult<i32>; /// Query the database to obtain an instance of the entity given its ID @@ -346,18 +336,187 @@ where fn create(db: &crate::db::PgPool, data: &Self::NewEntity) -> ThothResult<Self>; /// Modify the record in the database and obtain the resulting instance - fn update( - &self, - db: &crate::db::PgPool, - data: &Self::PatchEntity, - account_id: &Uuid, - ) -> ThothResult<Self>; + fn update<C: PolicyContext>(&self, ctx: &C, data: &Self::PatchEntity) -> ThothResult<Self>; /// Delete the record from the database and obtain the deleted instance fn delete(self, db: &crate::db::PgPool) -> ThothResult<Self>; +} - /// Retrieve the ID of the publisher linked to this entity (if applicable) +#[cfg(feature = "backend")] +/// Retrieve the ID of the publisher linked to an entity or input type (if applicable). +/// +/// This trait also provides a default `zitadel_id` implementation derived from the publisher. +pub trait PublisherId +where + Self: Sized, +{ fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid>; + + /// Retrieve the ZITADEL organisation id for the linked publisher. + fn zitadel_id(&self, db: &crate::db::PgPool) -> ThothResult<String> { + use crate::model::publisher::Publisher; + + let publisher_id = self.publisher_id(db)?; + let publisher = Publisher::from_id(db, &publisher_id)?; + publisher.zitadel_id.ok_or(ThothError::Unauthorised) + } +} + +#[cfg(feature = "backend")] +/// Retrieve the IDs of the publishers linked to an entity or input type (if applicable). +/// +/// This is intended for entities that span more than one publisher scope, e.g. `WorkRelation`, +/// where authorisation must be checked against all referenced publishers. +pub trait PublisherIds +where + Self: Sized, +{ + fn publisher_ids(&self, db: &crate::db::PgPool) -> ThothResult<Vec<Uuid>>; + + /// Retrieve the ZITADEL organisation ids for the linked publishers. + fn zitadel_ids(&self, db: &crate::db::PgPool) -> ThothResult<Vec<String>> { + use crate::model::publisher::Publisher; + + let mut org_ids: Vec<String> = self + .publisher_ids(db)? + .into_iter() + .map(|publisher_id| { + let publisher = Publisher::from_id(db, &publisher_id)?; + publisher.zitadel_id.ok_or(ThothError::Unauthorised) + }) + .collect::<ThothResult<Vec<String>>>()?; + + org_ids.sort(); + org_ids.dedup(); + Ok(org_ids) + } +} + +/// Implements `PublisherId` for a main entity type, its `New*` type, and its `Patch*` type. +/// +/// Due to macro hygiene, the implementation body is written as a block that uses **explicit** +/// identifiers provided to the macro (e.g. `s` and `db`). The macro will bind those identifiers +/// to the method's `self` and `db` parameters before expanding the body. +/// +/// Example: +/// ```ignore +/// publisher_id_impls!( +/// Contribution, +/// NewContribution, +/// PatchContribution, +/// |s, db| { +/// Work::from_id(db, &s.work_id)?.publisher_id(db) +/// } +/// ); +/// ``` +#[cfg(feature = "backend")] +#[macro_export] +macro_rules! publisher_id_impls { + ( + $main_ty:ty, + $new_ty:ty, + $patch_ty:ty, + |$s:ident, $db:ident| $body:block $(,)? + ) => { + impl $crate::model::PublisherId for $main_ty { + fn publisher_id( + &self, + db: &$crate::db::PgPool, + ) -> $crate::model::ThothResult<uuid::Uuid> { + let $s = self; + let $db = db; + $body + } + } + + impl $crate::model::PublisherId for $new_ty { + fn publisher_id( + &self, + db: &$crate::db::PgPool, + ) -> $crate::model::ThothResult<uuid::Uuid> { + let $s = self; + let $db = db; + $body + } + } + + impl $crate::model::PublisherId for $patch_ty { + fn publisher_id( + &self, + db: &$crate::db::PgPool, + ) -> $crate::model::ThothResult<uuid::Uuid> { + let $s = self; + let $db = db; + $body + } + } + }; +} + +/// Implements `PublisherIds` for a main entity type, its `New*` type, and its `Patch*` type. +/// +/// The implementation body is written as a block that uses **explicit** identifiers provided to the +/// macro (e.g. `s` and `db`). The macro will bind those identifiers to the method's `self` and `db` +/// parameters before expanding the body. +/// +/// Example: +/// ```ignore +/// publisher_ids_impls!( +/// WorkRelation, +/// NewWorkRelation, +/// PatchWorkRelation, +/// |s, db| { +/// let a = Work::from_id(db, &s.relator_work_id)?.publisher_id(db)?; +/// let b = Work::from_id(db, &s.related_work_id)?.publisher_id(db)?; +/// let mut v = vec![a, b]; +/// v.sort(); +/// v.dedup(); +/// Ok(v) +/// } +/// ); +/// ``` +#[cfg(feature = "backend")] +#[macro_export] +macro_rules! publisher_ids_impls { + ( + $main_ty:ty, + $new_ty:ty, + $patch_ty:ty, + |$s:ident, $db:ident| $body:block $(,)? + ) => { + impl $crate::model::PublisherIds for $main_ty { + fn publisher_ids( + &self, + db: &$crate::db::PgPool, + ) -> $crate::model::ThothResult<Vec<uuid::Uuid>> { + let $s = self; + let $db = db; + $body + } + } + + impl $crate::model::PublisherIds for $new_ty { + fn publisher_ids( + &self, + db: &$crate::db::PgPool, + ) -> $crate::model::ThothResult<Vec<uuid::Uuid>> { + let $s = self; + let $db = db; + $body + } + } + + impl $crate::model::PublisherIds for $patch_ty { + fn publisher_ids( + &self, + db: &$crate::db::PgPool, + ) -> $crate::model::ThothResult<Vec<uuid::Uuid>> { + let $s = self; + let $db = db; + $body + } + } + }; } #[cfg(feature = "backend")] @@ -369,7 +528,7 @@ where /// The structure used to create a new history entity, e.g. `NewImprintHistory` for `Imprint` type NewHistoryEntity; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity; + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity; } #[cfg(feature = "backend")] @@ -383,6 +542,26 @@ where fn insert(&self, connection: &mut diesel::PgConnection) -> ThothResult<Self::MainEntity>; } +#[cfg(feature = "backend")] +/// Common functionality to correctly renumber all relevant database objects +/// on a request to change the ordinal of one of them +pub(crate) trait Reorder +where + Self: Sized + Clone, +{ + fn change_ordinal<C: PolicyContext>( + &self, + ctx: &C, + current_ordinal: i32, + new_ordinal: i32, + ) -> ThothResult<Self>; + + fn get_other_objects( + &self, + connection: &mut diesel::PgConnection, + ) -> ThothResult<Vec<(Uuid, i32)>>; +} + /// Declares function implementations for the `Crud` trait, reducing the boilerplate needed to define /// the CRUD functionality for each entity. /// @@ -431,22 +610,21 @@ macro_rules! crud_methods { /// Makes a database transaction that first updates the entity and then creates a new /// history entity record. - fn update( + fn update<C: $crate::policy::PolicyContext>( &self, - db: &$crate::db::PgPool, + ctx: &C, data: &Self::PatchEntity, - account_id: &Uuid, ) -> ThothResult<Self> { use diesel::{Connection, QueryDsl, RunQueryDsl}; - let mut connection = db.get()?; + let mut connection = ctx.db().get()?; connection.transaction(|connection| { diesel::update($entity_dsl.find(&self.pk())) .set(data) .get_result(connection) .map_err(Into::into) .and_then(|c| { - self.new_history_entry(&account_id) + self.new_history_entry(ctx.user_id()?) .insert(connection) .map(|_| c) }) @@ -466,6 +644,81 @@ macro_rules! crud_methods { }; } +/// Helper macro to apply an `order`/`order_by` clause based on `Direction`. +/// +/// This supports both plain ordering by a single expression and ordering by a +/// primary expression with a stable secondary tie-breaker. +/// +/// # Parameters +/// - `$query`: mutable Diesel query builder identifier +/// - `$direction`: a `Direction` value (`Asc` or `Desc`) +/// - `$method`: either `order` or `order_by` +/// - `$primary`: primary Diesel expression to sort by +/// - `$secondary`: optional secondary Diesel expression; always sorted ascending +/// +/// # Examples +/// ```ignore +/// apply_directional_order!(query, order.direction, order, dsl::publisher_name); +/// apply_directional_order!(query, order.direction, order_by, dsl::work_type, dsl::work_id); +/// ``` +#[cfg(feature = "backend")] +#[macro_export] +macro_rules! apply_directional_order { + ($query:ident, $direction:expr, $method:ident, $primary:expr) => { + match $direction { + $crate::graphql::types::inputs::Direction::Asc => $query.$method($primary.asc()), + $crate::graphql::types::inputs::Direction::Desc => $query.$method($primary.desc()), + } + }; + ($query:ident, $direction:expr, $method:ident, $primary:expr, $secondary:expr) => { + match $direction { + $crate::graphql::types::inputs::Direction::Asc => { + $query.$method(($primary.asc(), $secondary.asc())) + } + $crate::graphql::types::inputs::Direction::Desc => { + $query.$method(($primary.desc(), $secondary.asc())) + } + } + }; +} + +/// Helper macro to apply an optional `TimeExpression` filter to a Diesel query. +/// +/// This variant accepts a **converter** so you can adapt your internal timestamp +/// type to the database column's Rust type (e.g. `NaiveDate` for `DATE` columns, +/// or `DateTime<Utc>`/`Timestamp` for `TIMESTAMPTZ`). +/// +/// # Parameters +/// - `$query`: identifier bound to a mutable Diesel query builder (e.g. `query`) +/// - `$col`: Diesel column expression (e.g. `dsl::publication_date`) +/// - `$opt`: `Option<TimeExpression>` +/// - `$conv`: an expression that converts the internal timestamp into the correct +/// Rust type for `$col`. It will be invoked like `$conv(te.timestamp)`. +/// +/// # Examples +/// For a `TIMESTAMPTZ` column: +/// ```ignore +/// apply_time_filter!(query, dsl::updated_at_with_relations, updated_at_with_relations, |ts: Timestamp| ts.0); +/// ``` +/// +/// For a `DATE` column: +/// ```ignore +/// apply_time_filter!(query, dsl::publication_date, publication_date, |ts: Timestamp| ts.0.date_naive()); +/// ``` +#[cfg(feature = "backend")] +#[macro_export] +macro_rules! apply_time_filter { + ($query:ident, $col:expr, $opt:expr, $conv:expr) => { + if let Some(te) = $opt { + let __val = $conv(te.timestamp); + $query = match te.expression { + Expression::GreaterThan => $query.filter($col.gt(__val)), + Expression::LessThan => $query.filter($col.lt(__val)), + }; + } + }; +} + /// Declares an insert function implementation for any insertable. Useful together with the /// `DbInsert` trait. /// @@ -500,51 +753,86 @@ macro_rules! db_insert { }; } -pub trait Convert { - fn convert_length_from_to(&self, current_units: &LengthUnit, new_units: &LengthUnit) -> f64; - fn convert_weight_from_to(&self, current_units: &WeightUnit, new_units: &WeightUnit) -> f64; -} - -impl Convert for f64 { - fn convert_length_from_to(&self, current_units: &LengthUnit, new_units: &LengthUnit) -> f64 { - match (current_units, new_units) { - // If current units and new units are the same, no conversion is needed - (LengthUnit::Mm, LengthUnit::Mm) - | (LengthUnit::Cm, LengthUnit::Cm) - | (LengthUnit::In, LengthUnit::In) => *self, - // Return cm values rounded to max 1 decimal place (1 cm = 10 mm) - (LengthUnit::Mm, LengthUnit::Cm) => self.round() / 10.0, - // Return mm values rounded to nearest mm (1 cm = 10 mm) - (LengthUnit::Cm, LengthUnit::Mm) => (self * 10.0).round(), - // Return inch values rounded to 2 decimal places (1 inch = 25.4 mm) - (LengthUnit::Mm, LengthUnit::In) => { - let unrounded_inches = self / 25.4; - // To round to a non-integer scale, multiply by the appropriate factor, - // round to the nearest integer, then divide again by the same factor - (unrounded_inches * 100.0).round() / 100.0 - } - // Return mm values rounded to nearest mm (1 inch = 25.4 mm) - (LengthUnit::In, LengthUnit::Mm) => (self * 25.4).round(), - // We don't currently support conversion between cm and in as it is not required - _ => unimplemented!(), - } - } - - fn convert_weight_from_to(&self, current_units: &WeightUnit, new_units: &WeightUnit) -> f64 { - match (current_units, new_units) { - // If current units and new units are the same, no conversion is needed - (WeightUnit::G, WeightUnit::G) | (WeightUnit::Oz, WeightUnit::Oz) => *self, - // Return ounce values rounded to 4 decimal places (1 ounce = 28.349523125 grams) - (WeightUnit::G, WeightUnit::Oz) => { - let unrounded_ounces = self / 28.349523125; - // To round to a non-integer scale, multiply by the appropriate factor, - // round to the nearest integer, then divide again by the same factor - (unrounded_ounces * 10000.0).round() / 10000.0 - } - // Return gram values rounded to nearest gram (1 ounce = 28.349523125 grams) - (WeightUnit::Oz, WeightUnit::G) => (self * 28.349523125).round(), +/// Declares a change ordinal function implementation for any insertable which +/// has an ordinal field. Useful together with the `Reorder` trait. +/// +/// Example usage +/// ------------- +/// +/// ```ignore +/// use crate::db_change_ordinal; +/// use crate::model::Reorder; +/// use crate::schema::contribution; +/// +/// impl Reorder for Contribution { +/// db_change_ordinal!( +/// contribution::table, +/// contribution::contribution_ordinal, +/// "contribution_contribution_ordinal_work_id_uniq", +/// ); +/// } +/// ``` +/// +/// +#[cfg(feature = "backend")] +#[macro_export] +macro_rules! db_change_ordinal { + ($table_dsl:expr, + $ordinal_field:expr, + $constraint_name:literal) => { + fn change_ordinal<C: $crate::policy::PolicyContext>( + &self, + ctx: &C, + current_ordinal: i32, + new_ordinal: i32, + ) -> ThothResult<Self> { + let mut connection = ctx.db().get()?; + // Execute all updates within the same transaction, + // because if one fails, the others need to be reverted. + connection.transaction(|connection| { + if current_ordinal == new_ordinal { + // No change required. + return ThothResult::Ok(self.clone()); + } + + // Fetch all other objects in the same transactional snapshot + let mut other_objects = self.get_other_objects(connection)?; + // Ensure a deterministic order to avoid deadlocks + other_objects.sort_by_key(|(_, ordinal)| *ordinal); + + diesel::sql_query(format!("SET CONSTRAINTS {} DEFERRED", $constraint_name)) + .execute(connection)?; + for (id, ordinal) in other_objects { + if new_ordinal > current_ordinal { + if ordinal > current_ordinal && ordinal <= new_ordinal { + let updated_ordinal = ordinal - 1; + diesel::update($table_dsl.find(id)) + .set($ordinal_field.eq(&updated_ordinal)) + .execute(connection)?; + } + } else { + if ordinal >= new_ordinal && ordinal < current_ordinal { + let updated_ordinal = ordinal + 1; + diesel::update($table_dsl.find(id)) + .set($ordinal_field.eq(&updated_ordinal)) + .execute(connection)?; + } + } + } + diesel::update($table_dsl.find(&self.pk())) + .set($ordinal_field.eq(&new_ordinal)) + .get_result::<Self>(connection) + .map_err(Into::into) + .and_then(|t| { + // On success, create a new history table entry. + // Only record the original update, not the automatic reorderings. + self.new_history_entry(ctx.user_id()?) + .insert(connection) + .map(|_| t) + }) + }) } - } + }; } /// Assign the leading domain of an identifier @@ -585,509 +873,25 @@ impl IdentifierWithDomain for Orcid {} impl IdentifierWithDomain for Ror {} #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_doi_default() { - let doi: Doi = Default::default(); - assert_eq!(doi, Doi("".to_string())); - } - - #[test] - fn test_isbn_default() { - let isbn: Isbn = Default::default(); - assert_eq!(isbn, Isbn("".to_string())); - } - - #[test] - fn test_orcid_default() { - let orcid: Orcid = Default::default(); - assert_eq!(orcid, Orcid("".to_string())); - } - - #[test] - fn test_ror_default() { - let ror: Ror = Default::default(); - assert_eq!(ror, Ror("".to_string())); - } - - #[test] - fn test_timestamp_default() { - let stamp: Timestamp = Default::default(); - assert_eq!( - stamp, - Timestamp(TimeZone::timestamp_opt(&Utc, 0, 0).unwrap()) - ); - } - - #[test] - fn test_doi_display() { - let doi = Doi("https://doi.org/10.12345/Test-Suffix.01".to_string()); - assert_eq!(format!("{doi}"), "10.12345/Test-Suffix.01"); - } - - #[test] - fn test_isbn_display() { - let isbn = Isbn("978-3-16-148410-0".to_string()); - assert_eq!(format!("{isbn}"), "978-3-16-148410-0"); - } - - #[test] - fn test_orcid_display() { - let orcid = Orcid("https://orcid.org/0000-0002-1234-5678".to_string()); - assert_eq!(format!("{orcid}"), "0000-0002-1234-5678"); - } - - #[test] - fn test_ror_display() { - let ror = Ror("https://ror.org/0abcdef12".to_string()); - assert_eq!(format!("{ror}"), "0abcdef12"); - } - - #[test] - fn test_timestamp_display() { - let stamp: Timestamp = Default::default(); - assert_eq!(format!("{stamp}"), "1970-01-01 00:00:00"); - } - - #[test] - fn test_doi_fromstr() { - let standardised = Doi("https://doi.org/10.12345/Test-Suffix.01".to_string()); - assert_eq!( - Doi::from_str("https://doi.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("http://doi.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("doi.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("HTTPS://DOI.ORG/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("Https://DOI.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("https://www.doi.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("http://www.doi.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("www.doi.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("https://dx.doi.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("http://dx.doi.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("dx.doi.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("https://www.dx.doi.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("http://www.dx.doi.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert_eq!( - Doi::from_str("www.dx.doi.org/10.12345/Test-Suffix.01").unwrap(), - standardised - ); - assert!(Doi::from_str("htts://doi.org/10.12345/Test-Suffix.01").is_err()); - assert!(Doi::from_str("https://10.12345/Test-Suffix.01").is_err()); - assert!(Doi::from_str("https://test.org/10.12345/Test-Suffix.01").is_err()); - assert!(Doi::from_str("http://test.org/10.12345/Test-Suffix.01").is_err()); - assert!(Doi::from_str("test.org/10.12345/Test-Suffix.01").is_err()); - assert!(Doi::from_str("//doi.org/10.12345/Test-Suffix.01").is_err()); - assert!(Doi::from_str("https://doi-org/10.12345/Test-Suffix.01").is_err()); - assert!(Doi::from_str("10.https://doi.org/12345/Test-Suffix.01").is_err()); - assert!( - Doi::from_str("http://dx.doi.org/10.2990/1471-5457(2005)24[2:tmpwac]2.0.co;2").is_ok() - ); - assert!(Doi::from_str( - "https://doi.org/10.1002/(SICI)1098-2736(199908)36:6<637::AID-TEA4>3.0.CO;2-9" - ) - .is_ok()); - assert!(Doi::from_str( - "https://doi.org/10.1002/(sici)1096-8644(1996)23+<91::aid-ajpa4>3.0.co;2-c" - ) - .is_ok()); - } - - #[test] - fn test_isbn_fromstr() { - // Note the `isbn2` crate contains tests of valid/invalid ISBN values - - // this focuses on testing that a valid ISBN in any format is standardised - let standardised = Isbn("978-3-16-148410-0".to_string()); - assert_eq!(Isbn::from_str("978-3-16-148410-0").unwrap(), standardised); - assert_eq!(Isbn::from_str("9783161484100").unwrap(), standardised); - assert_eq!(Isbn::from_str("978 3 16 148410 0").unwrap(), standardised); - assert_eq!(Isbn::from_str("978 3 16-148410-0").unwrap(), standardised); - assert_eq!(Isbn::from_str("9-7-831614-8-4-100").unwrap(), standardised); - assert_eq!( - Isbn::from_str(" 97831 614 84 100 ").unwrap(), - standardised - ); - assert_eq!( - Isbn::from_str("---97--831614----8-4100--").unwrap(), - standardised - ); - assert!(Isbn::from_str("978-3-16-148410-1").is_err()); - assert!(Isbn::from_str("1234567890123").is_err()); - assert!(Isbn::from_str("0-684-84328-5").is_err()); - assert!(Isbn::from_str("abcdef").is_err()); - } - - #[test] - fn test_orcid_fromstr() { - let standardised = Orcid("https://orcid.org/0000-0002-1234-5678".to_string()); - assert_eq!( - Orcid::from_str("https://orcid.org/0000-0002-1234-5678").unwrap(), - standardised - ); - assert_eq!( - Orcid::from_str("http://orcid.org/0000-0002-1234-5678").unwrap(), - standardised - ); - assert_eq!( - Orcid::from_str("orcid.org/0000-0002-1234-5678").unwrap(), - standardised - ); - assert_eq!( - Orcid::from_str("0000-0002-1234-5678").unwrap(), - standardised - ); - assert_eq!( - Orcid::from_str("HTTPS://ORCID.ORG/0000-0002-1234-5678").unwrap(), - standardised - ); - assert_eq!( - Orcid::from_str("Https://ORCiD.org/0000-0002-1234-5678").unwrap(), - standardised - ); - assert_eq!( - Orcid::from_str("https://www.orcid.org/0000-0002-1234-5678").unwrap(), - standardised - ); - assert_eq!( - Orcid::from_str("http://www.orcid.org/0000-0002-1234-5678").unwrap(), - standardised - ); - assert_eq!( - Orcid::from_str("www.orcid.org/0000-0002-1234-5678").unwrap(), - standardised - ); - assert!(Orcid::from_str("htts://orcid.org/0000-0002-1234-5678").is_err()); - assert!(Orcid::from_str("https://0000-0002-1234-5678").is_err()); - assert!(Orcid::from_str("https://test.org/0000-0002-1234-5678").is_err()); - assert!(Orcid::from_str("http://test.org/0000-0002-1234-5678").is_err()); - assert!(Orcid::from_str("test.org/0000-0002-1234-5678").is_err()); - assert!(Orcid::from_str("//orcid.org/0000-0002-1234-5678").is_err()); - assert!(Orcid::from_str("https://orcid-org/0000-0002-1234-5678").is_err()); - assert!(Orcid::from_str("0000-0002-1234-5678https://orcid.org/").is_err()); - assert!(Orcid::from_str("0009-0002-1234-567X").is_ok()); - } - - #[test] - fn test_ror_fromstr() { - let standardised = Ror("https://ror.org/0abcdef12".to_string()); - assert_eq!( - Ror::from_str("https://ror.org/0abcdef12").unwrap(), - standardised - ); - assert_eq!( - Ror::from_str("http://ror.org/0abcdef12").unwrap(), - standardised - ); - assert_eq!(Ror::from_str("ror.org/0abcdef12").unwrap(), standardised); - assert_eq!(Ror::from_str("0abcdef12").unwrap(), standardised); - assert_eq!( - Ror::from_str("HTTPS://ROR.ORG/0abcdef12").unwrap(), - standardised - ); - assert_eq!( - Ror::from_str("Https://Ror.org/0abcdef12").unwrap(), - standardised - ); - assert_eq!( - Ror::from_str("https://www.ror.org/0abcdef12").unwrap(), - standardised - ); - // Testing shows that while leading http://ror and https://www.ror - // resolve successfully, leading www.ror and http://www.ror do not. - assert!(Ror::from_str("http://www.ror.org/0abcdef12").is_err()); - assert!(Ror::from_str("www.ror.org/0abcdef12").is_err()); - assert!(Ror::from_str("htts://ror.org/0abcdef12").is_err()); - assert!(Ror::from_str("https://0abcdef12").is_err()); - assert!(Ror::from_str("https://test.org/0abcdef12").is_err()); - assert!(Ror::from_str("http://test.org/0abcdef12").is_err()); - assert!(Ror::from_str("test.org/0abcdef12").is_err()); - assert!(Ror::from_str("//ror.org/0abcdef12").is_err()); - assert!(Ror::from_str("https://ror-org/0abcdef12").is_err()); - assert!(Ror::from_str("0abcdef12https://ror.org/").is_err()); - } - - #[test] - fn test_isbn_to_hyphenless_string() { - let hyphenless_isbn = Isbn("978-3-16-148410-0".to_string()).to_hyphenless_string(); - assert_eq!(hyphenless_isbn, "9783161484100"); - } - - #[test] - // Float equality comparison is fine here because the floats - // have already been rounded by the functions under test - #[allow(clippy::float_cmp)] - fn test_convert_length_from_to() { - use LengthUnit::*; - assert_eq!(123.456.convert_length_from_to(&Mm, &Cm), 12.3); - assert_eq!(123.456.convert_length_from_to(&Mm, &In), 4.86); - assert_eq!(123.456.convert_length_from_to(&Cm, &Mm), 1235.0); - assert_eq!(123.456.convert_length_from_to(&In, &Mm), 3136.0); - // Test some standard print sizes - assert_eq!(4.25.convert_length_from_to(&In, &Mm), 108.0); - assert_eq!(108.0.convert_length_from_to(&Mm, &In), 4.25); - assert_eq!(6.0.convert_length_from_to(&In, &Mm), 152.0); - assert_eq!(152.0.convert_length_from_to(&Mm, &In), 5.98); - assert_eq!(8.5.convert_length_from_to(&In, &Mm), 216.0); - assert_eq!(216.0.convert_length_from_to(&Mm, &In), 8.5); - // Test that converting and then converting back again - // returns a value within a reasonable margin of error - assert_eq!( - 5.06.convert_length_from_to(&In, &Mm) - .convert_length_from_to(&Mm, &In), - 5.08 - ); - assert_eq!( - 6.5.convert_length_from_to(&In, &Mm) - .convert_length_from_to(&Mm, &In), - 6.5 - ); - assert_eq!( - 7.44.convert_length_from_to(&In, &Mm) - .convert_length_from_to(&Mm, &In), - 7.44 - ); - assert_eq!( - 8.27.convert_length_from_to(&In, &Mm) - .convert_length_from_to(&Mm, &In), - 8.27 - ); - assert_eq!( - 9.0.convert_length_from_to(&In, &Mm) - .convert_length_from_to(&Mm, &In), - 9.02 - ); - assert_eq!( - 10.88 - .convert_length_from_to(&In, &Mm) - .convert_length_from_to(&Mm, &In), - 10.87 - ); - assert_eq!( - 102.0 - .convert_length_from_to(&Mm, &In) - .convert_length_from_to(&In, &Mm), - 102.0 - ); - assert_eq!( - 120.0 - .convert_length_from_to(&Mm, &In) - .convert_length_from_to(&In, &Mm), - 120.0 - ); - assert_eq!( - 168.0 - .convert_length_from_to(&Mm, &In) - .convert_length_from_to(&In, &Mm), - 168.0 - ); - assert_eq!( - 190.0 - .convert_length_from_to(&Mm, &In) - .convert_length_from_to(&In, &Mm), - 190.0 - ); - } - - #[test] - // Float equality comparison is fine here because the floats - // have already been rounded by the functions under test - #[allow(clippy::float_cmp)] - fn test_convert_weight_from_to() { - use WeightUnit::*; - assert_eq!(123.456.convert_weight_from_to(&G, &Oz), 4.3548); - assert_eq!(123.456.convert_weight_from_to(&Oz, &G), 3500.0); - assert_eq!(4.25.convert_weight_from_to(&Oz, &G), 120.0); - assert_eq!(108.0.convert_weight_from_to(&G, &Oz), 3.8096); - assert_eq!(6.0.convert_weight_from_to(&Oz, &G), 170.0); - assert_eq!(152.0.convert_weight_from_to(&G, &Oz), 5.3616); - assert_eq!(8.5.convert_weight_from_to(&Oz, &G), 241.0); - assert_eq!(216.0.convert_weight_from_to(&G, &Oz), 7.6192); - // Test that converting and then converting back again - // returns a value within a reasonable margin of error - assert_eq!( - 5.0.convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 5.0089 - ); - assert_eq!( - 5.125 - .convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 5.1147 - ); - assert_eq!( - 6.5.convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 6.4904 - ); - assert_eq!( - 7.25.convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 7.2664 - ); - assert_eq!( - 7.44.convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 7.4428 - ); - assert_eq!( - 8.0625 - .convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 8.0777 - ); - assert_eq!( - 9.0.convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 8.9949 - ); - assert_eq!( - 10.75 - .convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 10.7586 - ); - assert_eq!( - 10.88 - .convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 10.8644 - ); - assert_eq!( - 102.0 - .convert_weight_from_to(&G, &Oz) - .convert_weight_from_to(&Oz, &G), - 102.0 - ); - assert_eq!( - 120.0 - .convert_weight_from_to(&G, &Oz) - .convert_weight_from_to(&Oz, &G), - 120.0 - ); - assert_eq!( - 168.0 - .convert_weight_from_to(&G, &Oz) - .convert_weight_from_to(&Oz, &G), - 168.0 - ); - assert_eq!( - 190.0 - .convert_weight_from_to(&G, &Oz) - .convert_weight_from_to(&Oz, &G), - 190.0 - ); - } - - #[test] - fn test_doi_with_domain() { - let doi = "https://doi.org/10.12345/Test-Suffix.01"; - assert_eq!(format!("{}", Doi(doi.to_string()).with_domain()), doi); - } - - #[test] - fn test_orcid_with_domain() { - let orcid = "https://orcid.org/0000-0002-1234-5678"; - assert_eq!(format!("{}", Orcid(orcid.to_string()).with_domain()), orcid); - } - - #[test] - fn test_ror_with_domain() { - let ror = "https://ror.org/0abcdef12"; - assert_eq!(format!("{}", Ror(ror.to_string()).with_domain()), ror); - } - - #[test] - fn test_timestamp_parse_from_rfc3339_valid() { - let input = "1999-12-31T23:59:00Z"; - let timestamp = Timestamp::parse_from_rfc3339(input); - assert!(timestamp.is_ok()); - - let expected = Timestamp(Utc.with_ymd_and_hms(1999, 12, 31, 23, 59, 0).unwrap()); - assert_eq!(timestamp.unwrap(), expected); - } - - #[test] - fn test_timestamp_parse_from_rfc3339_invalid_format() { - let input = "1999-12-31 23:59:00"; // Missing 'T' and 'Z' - let timestamp = Timestamp::parse_from_rfc3339(input); - assert!(timestamp.is_err()); - } - - #[test] - fn test_timestamp_parse_from_rfc3339_invalid_date() { - let input = "1999-02-30T23:59:00Z"; // Invalid date - let timestamp = Timestamp::parse_from_rfc3339(input); - assert!(timestamp.is_err()); - } - - #[test] - fn test_timestamp_to_rfc3339() { - let timestamp = Timestamp(Utc.with_ymd_and_hms(1999, 12, 31, 23, 59, 0).unwrap()); - assert_eq!(timestamp.to_rfc3339(), "1999-12-31T23:59:00+00:00"); - } - - #[test] - fn test_timestamp_round_trip_rfc3339_conversion() { - let original_string = "2023-11-13T12:34:56Z"; - let timestamp = Timestamp::parse_from_rfc3339(original_string).unwrap(); - let converted_string = timestamp.to_rfc3339(); - - let round_trip_timestamp = Timestamp::parse_from_rfc3339(&converted_string).unwrap(); - assert_eq!(timestamp, round_trip_timestamp); - } -} +pub(crate) mod tests; +pub mod r#abstract; +pub mod additional_resource; pub mod affiliation; +pub mod award; +pub mod biography; +pub mod book_review; +pub mod contact; pub mod contribution; pub mod contributor; +pub mod endorsement; +pub mod file; pub mod funding; pub mod imprint; pub mod institution; pub mod issue; pub mod language; +pub mod locale; pub mod location; pub mod price; pub mod publication; @@ -1095,5 +899,7 @@ pub mod publisher; pub mod reference; pub mod series; pub mod subject; +pub mod title; pub mod work; +pub mod work_featured_video; pub mod work_relation; diff --git a/thoth-api/src/model/price/crud.rs b/thoth-api/src/model/price/crud.rs index b213b081..03563dc4 100644 --- a/thoth-api/src/model/price/crud.rs +++ b/thoth-api/src/model/price/crud.rs @@ -1,9 +1,7 @@ use super::{CurrencyCode, NewPrice, NewPriceHistory, PatchPrice, Price, PriceField, PriceHistory}; -use crate::graphql::model::PriceOrderBy; -use crate::graphql::utils::Direction; +use crate::graphql::types::inputs::PriceOrderBy; use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{price, price_history}; -use crate::{crud_methods, db_insert}; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; use thoth_errors::ThothResult; use uuid::Uuid; @@ -15,6 +13,7 @@ impl Crud for Price { type FilterParameter1 = CurrencyCode; type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.price_id @@ -32,6 +31,7 @@ impl Crud for Price { currency_codes: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Price>> { use crate::schema::price::dsl::*; let mut connection = db.get()?; @@ -44,30 +44,24 @@ impl Crud for Price { .into_boxed(); query = match order.field { - PriceField::PriceId => match order.direction { - Direction::Asc => query.order(price_id.asc()), - Direction::Desc => query.order(price_id.desc()), - }, - PriceField::PublicationId => match order.direction { - Direction::Asc => query.order(publication_id.asc()), - Direction::Desc => query.order(publication_id.desc()), - }, - PriceField::CurrencyCode => match order.direction { - Direction::Asc => query.order(currency_code.asc()), - Direction::Desc => query.order(currency_code.desc()), - }, - PriceField::UnitPrice => match order.direction { - Direction::Asc => query.order(unit_price.asc()), - Direction::Desc => query.order(unit_price.desc()), - }, - PriceField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - PriceField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + PriceField::PriceId => { + apply_directional_order!(query, order.direction, order, price_id) + } + PriceField::PublicationId => { + apply_directional_order!(query, order.direction, order, publication_id) + } + PriceField::CurrencyCode => { + apply_directional_order!(query, order.direction, order, currency_code) + } + PriceField::UnitPrice => { + apply_directional_order!(query, order.direction, order, unit_price) + } + PriceField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + PriceField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if !publishers.is_empty() { query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); @@ -92,6 +86,7 @@ impl Crud for Price { currency_codes: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::price::dsl::*; let mut connection = db.get()?; @@ -110,20 +105,20 @@ impl Crud for Price { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::publication::Publication::from_id(db, &self.publication_id)?.publisher_id(db) - } - crud_methods!(price::table, price::dsl::price); } +publisher_id_impls!(Price, NewPrice, PatchPrice, |s, db| { + crate::model::publication::Publication::from_id(db, &s.publication_id)?.publisher_id(db) +}); + impl HistoryEntry for Price { type NewHistoryEntity = NewPriceHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { price_id: self.price_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -134,27 +129,3 @@ impl DbInsert for NewPriceHistory { db_insert!(price_history::table); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_price_pk() { - let price: Price = Default::default(); - assert_eq!(price.pk(), price.price_id); - } - - #[test] - fn test_new_price_history_from_price() { - let price: Price = Default::default(); - let account_id: Uuid = Default::default(); - let new_price_history = price.new_history_entry(&account_id); - assert_eq!(new_price_history.price_id, price.price_id); - assert_eq!(new_price_history.account_id, account_id); - assert_eq!( - new_price_history.data, - serde_json::Value::String(serde_json::to_string(&price).unwrap()) - ); - } -} diff --git a/thoth-api/src/model/price/mod.rs b/thoth-api/src/model/price/mod.rs index cccf672f..210aa423 100644 --- a/thoth-api/src/model/price/mod.rs +++ b/thoth-api/src/model/price/mod.rs @@ -23,7 +23,7 @@ pub enum PriceField { UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Price { @@ -37,7 +37,7 @@ pub struct Price { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new amount of money that a publication costs"), diesel(table_name = price) )] @@ -49,7 +49,7 @@ pub struct NewPrice { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing amount of money that a publication costs"), diesel(table_name = price, treat_none_as_null = true) )] @@ -62,7 +62,7 @@ pub struct PatchPrice { #[cfg_attr( feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), graphql(description = "Three-letter ISO 4217 code representing a currency"), ExistingTypePath = "crate::schema::sql_types::CurrencyCode" )] @@ -823,640 +823,27 @@ pub enum CurrencyCode { Zwr, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct PriceHistory { pub price_history_id: Uuid, pub price_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } -#[cfg_attr(feature = "backend", derive(Insertable), diesel(table_name = price_history))] +#[cfg_attr(feature = "backend", derive(diesel::Insertable), diesel(table_name = price_history))] pub struct NewPriceHistory { pub price_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } -#[test] -fn test_currencycode_default() { - let currencycode: CurrencyCode = Default::default(); - assert_eq!(currencycode, CurrencyCode::Gbp); -} - -#[test] -fn test_currencycode_display() { - assert_eq!(format!("{}", CurrencyCode::Adp), "ADP"); - assert_eq!(format!("{}", CurrencyCode::Aed), "AED"); - assert_eq!(format!("{}", CurrencyCode::Afa), "AFA"); - assert_eq!(format!("{}", CurrencyCode::Afn), "AFN"); - assert_eq!(format!("{}", CurrencyCode::Alk), "ALK"); - assert_eq!(format!("{}", CurrencyCode::All), "ALL"); - assert_eq!(format!("{}", CurrencyCode::Amd), "AMD"); - assert_eq!(format!("{}", CurrencyCode::Ang), "ANG"); - assert_eq!(format!("{}", CurrencyCode::Aoa), "AOA"); - assert_eq!(format!("{}", CurrencyCode::Aok), "AOK"); - assert_eq!(format!("{}", CurrencyCode::Aon), "AON"); - assert_eq!(format!("{}", CurrencyCode::Aor), "AOR"); - assert_eq!(format!("{}", CurrencyCode::Ara), "ARA"); - assert_eq!(format!("{}", CurrencyCode::Arp), "ARP"); - assert_eq!(format!("{}", CurrencyCode::Ars), "ARS"); - assert_eq!(format!("{}", CurrencyCode::Ary), "ARY"); - assert_eq!(format!("{}", CurrencyCode::Ats), "ATS"); - assert_eq!(format!("{}", CurrencyCode::Aud), "AUD"); - assert_eq!(format!("{}", CurrencyCode::Awg), "AWG"); - assert_eq!(format!("{}", CurrencyCode::Aym), "AYM"); - assert_eq!(format!("{}", CurrencyCode::Azm), "AZM"); - assert_eq!(format!("{}", CurrencyCode::Azn), "AZN"); - assert_eq!(format!("{}", CurrencyCode::Bad), "BAD"); - assert_eq!(format!("{}", CurrencyCode::Bam), "BAM"); - assert_eq!(format!("{}", CurrencyCode::Bbd), "BBD"); - assert_eq!(format!("{}", CurrencyCode::Bdt), "BDT"); - assert_eq!(format!("{}", CurrencyCode::Bec), "BEC"); - assert_eq!(format!("{}", CurrencyCode::Bef), "BEF"); - assert_eq!(format!("{}", CurrencyCode::Bel), "BEL"); - assert_eq!(format!("{}", CurrencyCode::Bgj), "BGJ"); - assert_eq!(format!("{}", CurrencyCode::Bgk), "BGK"); - assert_eq!(format!("{}", CurrencyCode::Bgl), "BGL"); - assert_eq!(format!("{}", CurrencyCode::Bgn), "BGN"); - assert_eq!(format!("{}", CurrencyCode::Bhd), "BHD"); - assert_eq!(format!("{}", CurrencyCode::Bif), "BIF"); - assert_eq!(format!("{}", CurrencyCode::Bmd), "BMD"); - assert_eq!(format!("{}", CurrencyCode::Bnd), "BND"); - assert_eq!(format!("{}", CurrencyCode::Bob), "BOB"); - assert_eq!(format!("{}", CurrencyCode::Bop), "BOP"); - assert_eq!(format!("{}", CurrencyCode::Bov), "BOV"); - assert_eq!(format!("{}", CurrencyCode::Brb), "BRB"); - assert_eq!(format!("{}", CurrencyCode::Brc), "BRC"); - assert_eq!(format!("{}", CurrencyCode::Bre), "BRE"); - assert_eq!(format!("{}", CurrencyCode::Brl), "BRL"); - assert_eq!(format!("{}", CurrencyCode::Brn), "BRN"); - assert_eq!(format!("{}", CurrencyCode::Brr), "BRR"); - assert_eq!(format!("{}", CurrencyCode::Bsd), "BSD"); - assert_eq!(format!("{}", CurrencyCode::Btn), "BTN"); - assert_eq!(format!("{}", CurrencyCode::Buk), "BUK"); - assert_eq!(format!("{}", CurrencyCode::Bwp), "BWP"); - assert_eq!(format!("{}", CurrencyCode::Byb), "BYB"); - assert_eq!(format!("{}", CurrencyCode::Byn), "BYN"); - assert_eq!(format!("{}", CurrencyCode::Byr), "BYR"); - assert_eq!(format!("{}", CurrencyCode::Bzd), "BZD"); - assert_eq!(format!("{}", CurrencyCode::Cad), "CAD"); - assert_eq!(format!("{}", CurrencyCode::Cdf), "CDF"); - assert_eq!(format!("{}", CurrencyCode::Chc), "CHC"); - assert_eq!(format!("{}", CurrencyCode::Che), "CHE"); - assert_eq!(format!("{}", CurrencyCode::Chf), "CHF"); - assert_eq!(format!("{}", CurrencyCode::Chw), "CHW"); - assert_eq!(format!("{}", CurrencyCode::Clf), "CLF"); - assert_eq!(format!("{}", CurrencyCode::Clp), "CLP"); - assert_eq!(format!("{}", CurrencyCode::Cny), "CNY"); - assert_eq!(format!("{}", CurrencyCode::Cop), "COP"); - assert_eq!(format!("{}", CurrencyCode::Cou), "COU"); - assert_eq!(format!("{}", CurrencyCode::Crc), "CRC"); - assert_eq!(format!("{}", CurrencyCode::Csd), "CSD"); - assert_eq!(format!("{}", CurrencyCode::Csj), "CSJ"); - assert_eq!(format!("{}", CurrencyCode::Csk), "CSK"); - assert_eq!(format!("{}", CurrencyCode::Cuc), "CUC"); - assert_eq!(format!("{}", CurrencyCode::Cup), "CUP"); - assert_eq!(format!("{}", CurrencyCode::Cve), "CVE"); - assert_eq!(format!("{}", CurrencyCode::Cyp), "CYP"); - assert_eq!(format!("{}", CurrencyCode::Czk), "CZK"); - assert_eq!(format!("{}", CurrencyCode::Ddm), "DDM"); - assert_eq!(format!("{}", CurrencyCode::Dem), "DEM"); - assert_eq!(format!("{}", CurrencyCode::Djf), "DJF"); - assert_eq!(format!("{}", CurrencyCode::Dkk), "DKK"); - assert_eq!(format!("{}", CurrencyCode::Dop), "DOP"); - assert_eq!(format!("{}", CurrencyCode::Dzd), "DZD"); - assert_eq!(format!("{}", CurrencyCode::Ecs), "ECS"); - assert_eq!(format!("{}", CurrencyCode::Ecv), "ECV"); - assert_eq!(format!("{}", CurrencyCode::Eek), "EEK"); - assert_eq!(format!("{}", CurrencyCode::Egp), "EGP"); - assert_eq!(format!("{}", CurrencyCode::Ern), "ERN"); - assert_eq!(format!("{}", CurrencyCode::Esa), "ESA"); - assert_eq!(format!("{}", CurrencyCode::Esb), "ESB"); - assert_eq!(format!("{}", CurrencyCode::Esp), "ESP"); - assert_eq!(format!("{}", CurrencyCode::Etb), "ETB"); - assert_eq!(format!("{}", CurrencyCode::Eur), "EUR"); - assert_eq!(format!("{}", CurrencyCode::Fim), "FIM"); - assert_eq!(format!("{}", CurrencyCode::Fjd), "FJD"); - assert_eq!(format!("{}", CurrencyCode::Fkp), "FKP"); - assert_eq!(format!("{}", CurrencyCode::Frf), "FRF"); - assert_eq!(format!("{}", CurrencyCode::Gbp), "GBP"); - assert_eq!(format!("{}", CurrencyCode::Gek), "GEK"); - assert_eq!(format!("{}", CurrencyCode::Gel), "GEL"); - assert_eq!(format!("{}", CurrencyCode::Ghc), "GHC"); - assert_eq!(format!("{}", CurrencyCode::Ghp), "GHP"); - assert_eq!(format!("{}", CurrencyCode::Ghs), "GHS"); - assert_eq!(format!("{}", CurrencyCode::Gip), "GIP"); - assert_eq!(format!("{}", CurrencyCode::Gmd), "GMD"); - assert_eq!(format!("{}", CurrencyCode::Gne), "GNE"); - assert_eq!(format!("{}", CurrencyCode::Gnf), "GNF"); - assert_eq!(format!("{}", CurrencyCode::Gns), "GNS"); - assert_eq!(format!("{}", CurrencyCode::Gqe), "GQE"); - assert_eq!(format!("{}", CurrencyCode::Grd), "GRD"); - assert_eq!(format!("{}", CurrencyCode::Gtq), "GTQ"); - assert_eq!(format!("{}", CurrencyCode::Gwe), "GWE"); - assert_eq!(format!("{}", CurrencyCode::Gwp), "GWP"); - assert_eq!(format!("{}", CurrencyCode::Gyd), "GYD"); - assert_eq!(format!("{}", CurrencyCode::Hkd), "HKD"); - assert_eq!(format!("{}", CurrencyCode::Hnl), "HNL"); - assert_eq!(format!("{}", CurrencyCode::Hrd), "HRD"); - assert_eq!(format!("{}", CurrencyCode::Hrk), "HRK"); - assert_eq!(format!("{}", CurrencyCode::Htg), "HTG"); - assert_eq!(format!("{}", CurrencyCode::Huf), "HUF"); - assert_eq!(format!("{}", CurrencyCode::Idr), "IDR"); - assert_eq!(format!("{}", CurrencyCode::Iep), "IEP"); - assert_eq!(format!("{}", CurrencyCode::Ilp), "ILP"); - assert_eq!(format!("{}", CurrencyCode::Ilr), "ILR"); - assert_eq!(format!("{}", CurrencyCode::Ils), "ILS"); - assert_eq!(format!("{}", CurrencyCode::Inr), "INR"); - assert_eq!(format!("{}", CurrencyCode::Iqd), "IQD"); - assert_eq!(format!("{}", CurrencyCode::Irr), "IRR"); - assert_eq!(format!("{}", CurrencyCode::Isj), "ISJ"); - assert_eq!(format!("{}", CurrencyCode::Isk), "ISK"); - assert_eq!(format!("{}", CurrencyCode::Itl), "ITL"); - assert_eq!(format!("{}", CurrencyCode::Jmd), "JMD"); - assert_eq!(format!("{}", CurrencyCode::Jod), "JOD"); - assert_eq!(format!("{}", CurrencyCode::Jpy), "JPY"); - assert_eq!(format!("{}", CurrencyCode::Kes), "KES"); - assert_eq!(format!("{}", CurrencyCode::Kgs), "KGS"); - assert_eq!(format!("{}", CurrencyCode::Khr), "KHR"); - assert_eq!(format!("{}", CurrencyCode::Kmf), "KMF"); - assert_eq!(format!("{}", CurrencyCode::Kpw), "KPW"); - assert_eq!(format!("{}", CurrencyCode::Krw), "KRW"); - assert_eq!(format!("{}", CurrencyCode::Kwd), "KWD"); - assert_eq!(format!("{}", CurrencyCode::Kyd), "KYD"); - assert_eq!(format!("{}", CurrencyCode::Kzt), "KZT"); - assert_eq!(format!("{}", CurrencyCode::Laj), "LAJ"); - assert_eq!(format!("{}", CurrencyCode::Lak), "LAK"); - assert_eq!(format!("{}", CurrencyCode::Lbp), "LBP"); - assert_eq!(format!("{}", CurrencyCode::Lkr), "LKR"); - assert_eq!(format!("{}", CurrencyCode::Lrd), "LRD"); - assert_eq!(format!("{}", CurrencyCode::Lsl), "LSL"); - assert_eq!(format!("{}", CurrencyCode::Lsm), "LSM"); - assert_eq!(format!("{}", CurrencyCode::Ltl), "LTL"); - assert_eq!(format!("{}", CurrencyCode::Ltt), "LTT"); - assert_eq!(format!("{}", CurrencyCode::Luc), "LUC"); - assert_eq!(format!("{}", CurrencyCode::Luf), "LUF"); - assert_eq!(format!("{}", CurrencyCode::Lul), "LUL"); - assert_eq!(format!("{}", CurrencyCode::Lvl), "LVL"); - assert_eq!(format!("{}", CurrencyCode::Lvr), "LVR"); - assert_eq!(format!("{}", CurrencyCode::Lyd), "LYD"); - assert_eq!(format!("{}", CurrencyCode::Mad), "MAD"); - assert_eq!(format!("{}", CurrencyCode::Mdl), "MDL"); - assert_eq!(format!("{}", CurrencyCode::Mga), "MGA"); - assert_eq!(format!("{}", CurrencyCode::Mgf), "MGF"); - assert_eq!(format!("{}", CurrencyCode::Mkd), "MKD"); - assert_eq!(format!("{}", CurrencyCode::Mlf), "MLF"); - assert_eq!(format!("{}", CurrencyCode::Mmk), "MMK"); - assert_eq!(format!("{}", CurrencyCode::Mnt), "MNT"); - assert_eq!(format!("{}", CurrencyCode::Mop), "MOP"); - assert_eq!(format!("{}", CurrencyCode::Mro), "MRO"); - assert_eq!(format!("{}", CurrencyCode::Mru), "MRU"); - assert_eq!(format!("{}", CurrencyCode::Mtl), "MTL"); - assert_eq!(format!("{}", CurrencyCode::Mtp), "MTP"); - assert_eq!(format!("{}", CurrencyCode::Mur), "MUR"); - assert_eq!(format!("{}", CurrencyCode::Mvq), "MVQ"); - assert_eq!(format!("{}", CurrencyCode::Mvr), "MVR"); - assert_eq!(format!("{}", CurrencyCode::Mwk), "MWK"); - assert_eq!(format!("{}", CurrencyCode::Mxn), "MXN"); - assert_eq!(format!("{}", CurrencyCode::Mxp), "MXP"); - assert_eq!(format!("{}", CurrencyCode::Mxv), "MXV"); - assert_eq!(format!("{}", CurrencyCode::Myr), "MYR"); - assert_eq!(format!("{}", CurrencyCode::Mze), "MZE"); - assert_eq!(format!("{}", CurrencyCode::Mzm), "MZM"); - assert_eq!(format!("{}", CurrencyCode::Mzn), "MZN"); - assert_eq!(format!("{}", CurrencyCode::Nad), "NAD"); - assert_eq!(format!("{}", CurrencyCode::Ngn), "NGN"); - assert_eq!(format!("{}", CurrencyCode::Nic), "NIC"); - assert_eq!(format!("{}", CurrencyCode::Nio), "NIO"); - assert_eq!(format!("{}", CurrencyCode::Nlg), "NLG"); - assert_eq!(format!("{}", CurrencyCode::Nok), "NOK"); - assert_eq!(format!("{}", CurrencyCode::Npr), "NPR"); - assert_eq!(format!("{}", CurrencyCode::Nzd), "NZD"); - assert_eq!(format!("{}", CurrencyCode::Omr), "OMR"); - assert_eq!(format!("{}", CurrencyCode::Pab), "PAB"); - assert_eq!(format!("{}", CurrencyCode::Peh), "PEH"); - assert_eq!(format!("{}", CurrencyCode::Pei), "PEI"); - assert_eq!(format!("{}", CurrencyCode::Pen), "PEN"); - assert_eq!(format!("{}", CurrencyCode::Pes), "PES"); - assert_eq!(format!("{}", CurrencyCode::Pgk), "PGK"); - assert_eq!(format!("{}", CurrencyCode::Php), "PHP"); - assert_eq!(format!("{}", CurrencyCode::Pkr), "PKR"); - assert_eq!(format!("{}", CurrencyCode::Pln), "PLN"); - assert_eq!(format!("{}", CurrencyCode::Plz), "PLZ"); - assert_eq!(format!("{}", CurrencyCode::Pte), "PTE"); - assert_eq!(format!("{}", CurrencyCode::Pyg), "PYG"); - assert_eq!(format!("{}", CurrencyCode::Qar), "QAR"); - assert_eq!(format!("{}", CurrencyCode::Rhd), "RHD"); - assert_eq!(format!("{}", CurrencyCode::Rok), "ROK"); - assert_eq!(format!("{}", CurrencyCode::Rol), "ROL"); - assert_eq!(format!("{}", CurrencyCode::Ron), "RON"); - assert_eq!(format!("{}", CurrencyCode::Rsd), "RSD"); - assert_eq!(format!("{}", CurrencyCode::Rub), "RUB"); - assert_eq!(format!("{}", CurrencyCode::Rur), "RUR"); - assert_eq!(format!("{}", CurrencyCode::Rwf), "RWF"); - assert_eq!(format!("{}", CurrencyCode::Sar), "SAR"); - assert_eq!(format!("{}", CurrencyCode::Sbd), "SBD"); - assert_eq!(format!("{}", CurrencyCode::Scr), "SCR"); - assert_eq!(format!("{}", CurrencyCode::Sdd), "SDD"); - assert_eq!(format!("{}", CurrencyCode::Sdg), "SDG"); - assert_eq!(format!("{}", CurrencyCode::Sdp), "SDP"); - assert_eq!(format!("{}", CurrencyCode::Sek), "SEK"); - assert_eq!(format!("{}", CurrencyCode::Sgd), "SGD"); - assert_eq!(format!("{}", CurrencyCode::Shp), "SHP"); - assert_eq!(format!("{}", CurrencyCode::Sit), "SIT"); - assert_eq!(format!("{}", CurrencyCode::Skk), "SKK"); - assert_eq!(format!("{}", CurrencyCode::Sll), "SLL"); - assert_eq!(format!("{}", CurrencyCode::Sos), "SOS"); - assert_eq!(format!("{}", CurrencyCode::Srd), "SRD"); - assert_eq!(format!("{}", CurrencyCode::Srg), "SRG"); - assert_eq!(format!("{}", CurrencyCode::Ssp), "SSP"); - assert_eq!(format!("{}", CurrencyCode::Std), "STD"); - assert_eq!(format!("{}", CurrencyCode::Stn), "STN"); - assert_eq!(format!("{}", CurrencyCode::Sur), "SUR"); - assert_eq!(format!("{}", CurrencyCode::Svc), "SVC"); - assert_eq!(format!("{}", CurrencyCode::Syp), "SYP"); - assert_eq!(format!("{}", CurrencyCode::Szl), "SZL"); - assert_eq!(format!("{}", CurrencyCode::Thb), "THB"); - assert_eq!(format!("{}", CurrencyCode::Tjr), "TJR"); - assert_eq!(format!("{}", CurrencyCode::Tjs), "TJS"); - assert_eq!(format!("{}", CurrencyCode::Tmm), "TMM"); - assert_eq!(format!("{}", CurrencyCode::Tmt), "TMT"); - assert_eq!(format!("{}", CurrencyCode::Tnd), "TND"); - assert_eq!(format!("{}", CurrencyCode::Top), "TOP"); - assert_eq!(format!("{}", CurrencyCode::Tpe), "TPE"); - assert_eq!(format!("{}", CurrencyCode::Trl), "TRL"); - assert_eq!(format!("{}", CurrencyCode::Try), "TRY"); - assert_eq!(format!("{}", CurrencyCode::Ttd), "TTD"); - assert_eq!(format!("{}", CurrencyCode::Twd), "TWD"); - assert_eq!(format!("{}", CurrencyCode::Tzs), "TZS"); - assert_eq!(format!("{}", CurrencyCode::Uah), "UAH"); - assert_eq!(format!("{}", CurrencyCode::Uak), "UAK"); - assert_eq!(format!("{}", CurrencyCode::Ugs), "UGS"); - assert_eq!(format!("{}", CurrencyCode::Ugw), "UGW"); - assert_eq!(format!("{}", CurrencyCode::Ugx), "UGX"); - assert_eq!(format!("{}", CurrencyCode::Usd), "USD"); - assert_eq!(format!("{}", CurrencyCode::Usn), "USN"); - assert_eq!(format!("{}", CurrencyCode::Uss), "USS"); - assert_eq!(format!("{}", CurrencyCode::Uyi), "UYI"); - assert_eq!(format!("{}", CurrencyCode::Uyn), "UYN"); - assert_eq!(format!("{}", CurrencyCode::Uyp), "UYP"); - assert_eq!(format!("{}", CurrencyCode::Uyu), "UYU"); - assert_eq!(format!("{}", CurrencyCode::Uyw), "UYW"); - assert_eq!(format!("{}", CurrencyCode::Uzs), "UZS"); - assert_eq!(format!("{}", CurrencyCode::Veb), "VEB"); - assert_eq!(format!("{}", CurrencyCode::Vef), "VEF"); - assert_eq!(format!("{}", CurrencyCode::Ves), "VES"); - assert_eq!(format!("{}", CurrencyCode::Vnc), "VNC"); - assert_eq!(format!("{}", CurrencyCode::Vnd), "VND"); - assert_eq!(format!("{}", CurrencyCode::Vuv), "VUV"); - assert_eq!(format!("{}", CurrencyCode::Wst), "WST"); - assert_eq!(format!("{}", CurrencyCode::Xaf), "XAF"); - assert_eq!(format!("{}", CurrencyCode::Xag), "XAG"); - assert_eq!(format!("{}", CurrencyCode::Xau), "XAU"); - assert_eq!(format!("{}", CurrencyCode::Xba), "XBA"); - assert_eq!(format!("{}", CurrencyCode::Xbb), "XBB"); - assert_eq!(format!("{}", CurrencyCode::Xbc), "XBC"); - assert_eq!(format!("{}", CurrencyCode::Xbd), "XBD"); - assert_eq!(format!("{}", CurrencyCode::Xcd), "XCD"); - assert_eq!(format!("{}", CurrencyCode::Xdr), "XDR"); - assert_eq!(format!("{}", CurrencyCode::Xeu), "XEU"); - assert_eq!(format!("{}", CurrencyCode::Xfo), "XFO"); - assert_eq!(format!("{}", CurrencyCode::Xfu), "XFU"); - assert_eq!(format!("{}", CurrencyCode::Xof), "XOF"); - assert_eq!(format!("{}", CurrencyCode::Xpd), "XPD"); - assert_eq!(format!("{}", CurrencyCode::Xpf), "XPF"); - assert_eq!(format!("{}", CurrencyCode::Xpt), "XPT"); - assert_eq!(format!("{}", CurrencyCode::Xre), "XRE"); - assert_eq!(format!("{}", CurrencyCode::Xsu), "XSU"); - assert_eq!(format!("{}", CurrencyCode::Xts), "XTS"); - assert_eq!(format!("{}", CurrencyCode::Xua), "XUA"); - assert_eq!(format!("{}", CurrencyCode::Xxx), "XXX"); - assert_eq!(format!("{}", CurrencyCode::Ydd), "YDD"); - assert_eq!(format!("{}", CurrencyCode::Yer), "YER"); - assert_eq!(format!("{}", CurrencyCode::Yud), "YUD"); - assert_eq!(format!("{}", CurrencyCode::Yum), "YUM"); - assert_eq!(format!("{}", CurrencyCode::Yun), "YUN"); - assert_eq!(format!("{}", CurrencyCode::Zal), "ZAL"); - assert_eq!(format!("{}", CurrencyCode::Zar), "ZAR"); - assert_eq!(format!("{}", CurrencyCode::Zmk), "ZMK"); - assert_eq!(format!("{}", CurrencyCode::Zmw), "ZMW"); - assert_eq!(format!("{}", CurrencyCode::Zrn), "ZRN"); - assert_eq!(format!("{}", CurrencyCode::Zrz), "ZRZ"); - assert_eq!(format!("{}", CurrencyCode::Zwc), "ZWC"); - assert_eq!(format!("{}", CurrencyCode::Zwd), "ZWD"); - assert_eq!(format!("{}", CurrencyCode::Zwl), "ZWL"); - assert_eq!(format!("{}", CurrencyCode::Zwn), "ZWN"); - assert_eq!(format!("{}", CurrencyCode::Zwr), "ZWR"); -} - -#[test] -fn test_currencycode_fromstr() { - use std::str::FromStr; - assert_eq!(CurrencyCode::from_str("ADP").unwrap(), CurrencyCode::Adp); - assert_eq!(CurrencyCode::from_str("AED").unwrap(), CurrencyCode::Aed); - assert_eq!(CurrencyCode::from_str("AFA").unwrap(), CurrencyCode::Afa); - assert_eq!(CurrencyCode::from_str("AFN").unwrap(), CurrencyCode::Afn); - assert_eq!(CurrencyCode::from_str("ALK").unwrap(), CurrencyCode::Alk); - assert_eq!(CurrencyCode::from_str("ALL").unwrap(), CurrencyCode::All); - assert_eq!(CurrencyCode::from_str("AMD").unwrap(), CurrencyCode::Amd); - assert_eq!(CurrencyCode::from_str("ANG").unwrap(), CurrencyCode::Ang); - assert_eq!(CurrencyCode::from_str("AOA").unwrap(), CurrencyCode::Aoa); - assert_eq!(CurrencyCode::from_str("AOK").unwrap(), CurrencyCode::Aok); - assert_eq!(CurrencyCode::from_str("AON").unwrap(), CurrencyCode::Aon); - assert_eq!(CurrencyCode::from_str("AOR").unwrap(), CurrencyCode::Aor); - assert_eq!(CurrencyCode::from_str("ARA").unwrap(), CurrencyCode::Ara); - assert_eq!(CurrencyCode::from_str("ARP").unwrap(), CurrencyCode::Arp); - assert_eq!(CurrencyCode::from_str("ARS").unwrap(), CurrencyCode::Ars); - assert_eq!(CurrencyCode::from_str("ARY").unwrap(), CurrencyCode::Ary); - assert_eq!(CurrencyCode::from_str("ATS").unwrap(), CurrencyCode::Ats); - assert_eq!(CurrencyCode::from_str("AUD").unwrap(), CurrencyCode::Aud); - assert_eq!(CurrencyCode::from_str("AWG").unwrap(), CurrencyCode::Awg); - assert_eq!(CurrencyCode::from_str("AYM").unwrap(), CurrencyCode::Aym); - assert_eq!(CurrencyCode::from_str("AZM").unwrap(), CurrencyCode::Azm); - assert_eq!(CurrencyCode::from_str("AZN").unwrap(), CurrencyCode::Azn); - assert_eq!(CurrencyCode::from_str("BAD").unwrap(), CurrencyCode::Bad); - assert_eq!(CurrencyCode::from_str("BAM").unwrap(), CurrencyCode::Bam); - assert_eq!(CurrencyCode::from_str("BBD").unwrap(), CurrencyCode::Bbd); - assert_eq!(CurrencyCode::from_str("BDT").unwrap(), CurrencyCode::Bdt); - assert_eq!(CurrencyCode::from_str("BEC").unwrap(), CurrencyCode::Bec); - assert_eq!(CurrencyCode::from_str("BEF").unwrap(), CurrencyCode::Bef); - assert_eq!(CurrencyCode::from_str("BEL").unwrap(), CurrencyCode::Bel); - assert_eq!(CurrencyCode::from_str("BGJ").unwrap(), CurrencyCode::Bgj); - assert_eq!(CurrencyCode::from_str("BGK").unwrap(), CurrencyCode::Bgk); - assert_eq!(CurrencyCode::from_str("BGL").unwrap(), CurrencyCode::Bgl); - assert_eq!(CurrencyCode::from_str("BGN").unwrap(), CurrencyCode::Bgn); - assert_eq!(CurrencyCode::from_str("BHD").unwrap(), CurrencyCode::Bhd); - assert_eq!(CurrencyCode::from_str("BIF").unwrap(), CurrencyCode::Bif); - assert_eq!(CurrencyCode::from_str("BMD").unwrap(), CurrencyCode::Bmd); - assert_eq!(CurrencyCode::from_str("BND").unwrap(), CurrencyCode::Bnd); - assert_eq!(CurrencyCode::from_str("BOB").unwrap(), CurrencyCode::Bob); - assert_eq!(CurrencyCode::from_str("BOP").unwrap(), CurrencyCode::Bop); - assert_eq!(CurrencyCode::from_str("BOV").unwrap(), CurrencyCode::Bov); - assert_eq!(CurrencyCode::from_str("BRB").unwrap(), CurrencyCode::Brb); - assert_eq!(CurrencyCode::from_str("BRC").unwrap(), CurrencyCode::Brc); - assert_eq!(CurrencyCode::from_str("BRE").unwrap(), CurrencyCode::Bre); - assert_eq!(CurrencyCode::from_str("BRL").unwrap(), CurrencyCode::Brl); - assert_eq!(CurrencyCode::from_str("BRN").unwrap(), CurrencyCode::Brn); - assert_eq!(CurrencyCode::from_str("BRR").unwrap(), CurrencyCode::Brr); - assert_eq!(CurrencyCode::from_str("BSD").unwrap(), CurrencyCode::Bsd); - assert_eq!(CurrencyCode::from_str("BTN").unwrap(), CurrencyCode::Btn); - assert_eq!(CurrencyCode::from_str("BUK").unwrap(), CurrencyCode::Buk); - assert_eq!(CurrencyCode::from_str("BWP").unwrap(), CurrencyCode::Bwp); - assert_eq!(CurrencyCode::from_str("BYB").unwrap(), CurrencyCode::Byb); - assert_eq!(CurrencyCode::from_str("BYN").unwrap(), CurrencyCode::Byn); - assert_eq!(CurrencyCode::from_str("BYR").unwrap(), CurrencyCode::Byr); - assert_eq!(CurrencyCode::from_str("BZD").unwrap(), CurrencyCode::Bzd); - assert_eq!(CurrencyCode::from_str("CAD").unwrap(), CurrencyCode::Cad); - assert_eq!(CurrencyCode::from_str("CDF").unwrap(), CurrencyCode::Cdf); - assert_eq!(CurrencyCode::from_str("CHC").unwrap(), CurrencyCode::Chc); - assert_eq!(CurrencyCode::from_str("CHE").unwrap(), CurrencyCode::Che); - assert_eq!(CurrencyCode::from_str("CHF").unwrap(), CurrencyCode::Chf); - assert_eq!(CurrencyCode::from_str("CHW").unwrap(), CurrencyCode::Chw); - assert_eq!(CurrencyCode::from_str("CLF").unwrap(), CurrencyCode::Clf); - assert_eq!(CurrencyCode::from_str("CLP").unwrap(), CurrencyCode::Clp); - assert_eq!(CurrencyCode::from_str("CNY").unwrap(), CurrencyCode::Cny); - assert_eq!(CurrencyCode::from_str("COP").unwrap(), CurrencyCode::Cop); - assert_eq!(CurrencyCode::from_str("COU").unwrap(), CurrencyCode::Cou); - assert_eq!(CurrencyCode::from_str("CRC").unwrap(), CurrencyCode::Crc); - assert_eq!(CurrencyCode::from_str("CSD").unwrap(), CurrencyCode::Csd); - assert_eq!(CurrencyCode::from_str("CSJ").unwrap(), CurrencyCode::Csj); - assert_eq!(CurrencyCode::from_str("CSK").unwrap(), CurrencyCode::Csk); - assert_eq!(CurrencyCode::from_str("CUC").unwrap(), CurrencyCode::Cuc); - assert_eq!(CurrencyCode::from_str("CUP").unwrap(), CurrencyCode::Cup); - assert_eq!(CurrencyCode::from_str("CVE").unwrap(), CurrencyCode::Cve); - assert_eq!(CurrencyCode::from_str("CYP").unwrap(), CurrencyCode::Cyp); - assert_eq!(CurrencyCode::from_str("CZK").unwrap(), CurrencyCode::Czk); - assert_eq!(CurrencyCode::from_str("DDM").unwrap(), CurrencyCode::Ddm); - assert_eq!(CurrencyCode::from_str("DEM").unwrap(), CurrencyCode::Dem); - assert_eq!(CurrencyCode::from_str("DJF").unwrap(), CurrencyCode::Djf); - assert_eq!(CurrencyCode::from_str("DKK").unwrap(), CurrencyCode::Dkk); - assert_eq!(CurrencyCode::from_str("DOP").unwrap(), CurrencyCode::Dop); - assert_eq!(CurrencyCode::from_str("DZD").unwrap(), CurrencyCode::Dzd); - assert_eq!(CurrencyCode::from_str("ECS").unwrap(), CurrencyCode::Ecs); - assert_eq!(CurrencyCode::from_str("ECV").unwrap(), CurrencyCode::Ecv); - assert_eq!(CurrencyCode::from_str("EEK").unwrap(), CurrencyCode::Eek); - assert_eq!(CurrencyCode::from_str("EGP").unwrap(), CurrencyCode::Egp); - assert_eq!(CurrencyCode::from_str("ERN").unwrap(), CurrencyCode::Ern); - assert_eq!(CurrencyCode::from_str("ESA").unwrap(), CurrencyCode::Esa); - assert_eq!(CurrencyCode::from_str("ESB").unwrap(), CurrencyCode::Esb); - assert_eq!(CurrencyCode::from_str("ESP").unwrap(), CurrencyCode::Esp); - assert_eq!(CurrencyCode::from_str("ETB").unwrap(), CurrencyCode::Etb); - assert_eq!(CurrencyCode::from_str("EUR").unwrap(), CurrencyCode::Eur); - assert_eq!(CurrencyCode::from_str("FIM").unwrap(), CurrencyCode::Fim); - assert_eq!(CurrencyCode::from_str("FJD").unwrap(), CurrencyCode::Fjd); - assert_eq!(CurrencyCode::from_str("FKP").unwrap(), CurrencyCode::Fkp); - assert_eq!(CurrencyCode::from_str("FRF").unwrap(), CurrencyCode::Frf); - assert_eq!(CurrencyCode::from_str("GBP").unwrap(), CurrencyCode::Gbp); - assert_eq!(CurrencyCode::from_str("GEK").unwrap(), CurrencyCode::Gek); - assert_eq!(CurrencyCode::from_str("GEL").unwrap(), CurrencyCode::Gel); - assert_eq!(CurrencyCode::from_str("GHC").unwrap(), CurrencyCode::Ghc); - assert_eq!(CurrencyCode::from_str("GHP").unwrap(), CurrencyCode::Ghp); - assert_eq!(CurrencyCode::from_str("GHS").unwrap(), CurrencyCode::Ghs); - assert_eq!(CurrencyCode::from_str("GIP").unwrap(), CurrencyCode::Gip); - assert_eq!(CurrencyCode::from_str("GMD").unwrap(), CurrencyCode::Gmd); - assert_eq!(CurrencyCode::from_str("GNE").unwrap(), CurrencyCode::Gne); - assert_eq!(CurrencyCode::from_str("GNF").unwrap(), CurrencyCode::Gnf); - assert_eq!(CurrencyCode::from_str("GNS").unwrap(), CurrencyCode::Gns); - assert_eq!(CurrencyCode::from_str("GQE").unwrap(), CurrencyCode::Gqe); - assert_eq!(CurrencyCode::from_str("GRD").unwrap(), CurrencyCode::Grd); - assert_eq!(CurrencyCode::from_str("GTQ").unwrap(), CurrencyCode::Gtq); - assert_eq!(CurrencyCode::from_str("GWE").unwrap(), CurrencyCode::Gwe); - assert_eq!(CurrencyCode::from_str("GWP").unwrap(), CurrencyCode::Gwp); - assert_eq!(CurrencyCode::from_str("GYD").unwrap(), CurrencyCode::Gyd); - assert_eq!(CurrencyCode::from_str("HKD").unwrap(), CurrencyCode::Hkd); - assert_eq!(CurrencyCode::from_str("HNL").unwrap(), CurrencyCode::Hnl); - assert_eq!(CurrencyCode::from_str("HRD").unwrap(), CurrencyCode::Hrd); - assert_eq!(CurrencyCode::from_str("HRK").unwrap(), CurrencyCode::Hrk); - assert_eq!(CurrencyCode::from_str("HTG").unwrap(), CurrencyCode::Htg); - assert_eq!(CurrencyCode::from_str("HUF").unwrap(), CurrencyCode::Huf); - assert_eq!(CurrencyCode::from_str("IDR").unwrap(), CurrencyCode::Idr); - assert_eq!(CurrencyCode::from_str("IEP").unwrap(), CurrencyCode::Iep); - assert_eq!(CurrencyCode::from_str("ILP").unwrap(), CurrencyCode::Ilp); - assert_eq!(CurrencyCode::from_str("ILR").unwrap(), CurrencyCode::Ilr); - assert_eq!(CurrencyCode::from_str("ILS").unwrap(), CurrencyCode::Ils); - assert_eq!(CurrencyCode::from_str("INR").unwrap(), CurrencyCode::Inr); - assert_eq!(CurrencyCode::from_str("IQD").unwrap(), CurrencyCode::Iqd); - assert_eq!(CurrencyCode::from_str("IRR").unwrap(), CurrencyCode::Irr); - assert_eq!(CurrencyCode::from_str("ISJ").unwrap(), CurrencyCode::Isj); - assert_eq!(CurrencyCode::from_str("ISK").unwrap(), CurrencyCode::Isk); - assert_eq!(CurrencyCode::from_str("ITL").unwrap(), CurrencyCode::Itl); - assert_eq!(CurrencyCode::from_str("JMD").unwrap(), CurrencyCode::Jmd); - assert_eq!(CurrencyCode::from_str("JOD").unwrap(), CurrencyCode::Jod); - assert_eq!(CurrencyCode::from_str("JPY").unwrap(), CurrencyCode::Jpy); - assert_eq!(CurrencyCode::from_str("KES").unwrap(), CurrencyCode::Kes); - assert_eq!(CurrencyCode::from_str("KGS").unwrap(), CurrencyCode::Kgs); - assert_eq!(CurrencyCode::from_str("KHR").unwrap(), CurrencyCode::Khr); - assert_eq!(CurrencyCode::from_str("KMF").unwrap(), CurrencyCode::Kmf); - assert_eq!(CurrencyCode::from_str("KPW").unwrap(), CurrencyCode::Kpw); - assert_eq!(CurrencyCode::from_str("KRW").unwrap(), CurrencyCode::Krw); - assert_eq!(CurrencyCode::from_str("KWD").unwrap(), CurrencyCode::Kwd); - assert_eq!(CurrencyCode::from_str("KYD").unwrap(), CurrencyCode::Kyd); - assert_eq!(CurrencyCode::from_str("KZT").unwrap(), CurrencyCode::Kzt); - assert_eq!(CurrencyCode::from_str("LAJ").unwrap(), CurrencyCode::Laj); - assert_eq!(CurrencyCode::from_str("LAK").unwrap(), CurrencyCode::Lak); - assert_eq!(CurrencyCode::from_str("LBP").unwrap(), CurrencyCode::Lbp); - assert_eq!(CurrencyCode::from_str("LKR").unwrap(), CurrencyCode::Lkr); - assert_eq!(CurrencyCode::from_str("LRD").unwrap(), CurrencyCode::Lrd); - assert_eq!(CurrencyCode::from_str("LSL").unwrap(), CurrencyCode::Lsl); - assert_eq!(CurrencyCode::from_str("LSM").unwrap(), CurrencyCode::Lsm); - assert_eq!(CurrencyCode::from_str("LTL").unwrap(), CurrencyCode::Ltl); - assert_eq!(CurrencyCode::from_str("LTT").unwrap(), CurrencyCode::Ltt); - assert_eq!(CurrencyCode::from_str("LUC").unwrap(), CurrencyCode::Luc); - assert_eq!(CurrencyCode::from_str("LUF").unwrap(), CurrencyCode::Luf); - assert_eq!(CurrencyCode::from_str("LUL").unwrap(), CurrencyCode::Lul); - assert_eq!(CurrencyCode::from_str("LVL").unwrap(), CurrencyCode::Lvl); - assert_eq!(CurrencyCode::from_str("LVR").unwrap(), CurrencyCode::Lvr); - assert_eq!(CurrencyCode::from_str("LYD").unwrap(), CurrencyCode::Lyd); - assert_eq!(CurrencyCode::from_str("MAD").unwrap(), CurrencyCode::Mad); - assert_eq!(CurrencyCode::from_str("MDL").unwrap(), CurrencyCode::Mdl); - assert_eq!(CurrencyCode::from_str("MGA").unwrap(), CurrencyCode::Mga); - assert_eq!(CurrencyCode::from_str("MGF").unwrap(), CurrencyCode::Mgf); - assert_eq!(CurrencyCode::from_str("MKD").unwrap(), CurrencyCode::Mkd); - assert_eq!(CurrencyCode::from_str("MLF").unwrap(), CurrencyCode::Mlf); - assert_eq!(CurrencyCode::from_str("MMK").unwrap(), CurrencyCode::Mmk); - assert_eq!(CurrencyCode::from_str("MNT").unwrap(), CurrencyCode::Mnt); - assert_eq!(CurrencyCode::from_str("MOP").unwrap(), CurrencyCode::Mop); - assert_eq!(CurrencyCode::from_str("MRO").unwrap(), CurrencyCode::Mro); - assert_eq!(CurrencyCode::from_str("MRU").unwrap(), CurrencyCode::Mru); - assert_eq!(CurrencyCode::from_str("MTL").unwrap(), CurrencyCode::Mtl); - assert_eq!(CurrencyCode::from_str("MTP").unwrap(), CurrencyCode::Mtp); - assert_eq!(CurrencyCode::from_str("MUR").unwrap(), CurrencyCode::Mur); - assert_eq!(CurrencyCode::from_str("MVQ").unwrap(), CurrencyCode::Mvq); - assert_eq!(CurrencyCode::from_str("MVR").unwrap(), CurrencyCode::Mvr); - assert_eq!(CurrencyCode::from_str("MWK").unwrap(), CurrencyCode::Mwk); - assert_eq!(CurrencyCode::from_str("MXN").unwrap(), CurrencyCode::Mxn); - assert_eq!(CurrencyCode::from_str("MXP").unwrap(), CurrencyCode::Mxp); - assert_eq!(CurrencyCode::from_str("MXV").unwrap(), CurrencyCode::Mxv); - assert_eq!(CurrencyCode::from_str("MYR").unwrap(), CurrencyCode::Myr); - assert_eq!(CurrencyCode::from_str("MZE").unwrap(), CurrencyCode::Mze); - assert_eq!(CurrencyCode::from_str("MZM").unwrap(), CurrencyCode::Mzm); - assert_eq!(CurrencyCode::from_str("MZN").unwrap(), CurrencyCode::Mzn); - assert_eq!(CurrencyCode::from_str("NAD").unwrap(), CurrencyCode::Nad); - assert_eq!(CurrencyCode::from_str("NGN").unwrap(), CurrencyCode::Ngn); - assert_eq!(CurrencyCode::from_str("NIC").unwrap(), CurrencyCode::Nic); - assert_eq!(CurrencyCode::from_str("NIO").unwrap(), CurrencyCode::Nio); - assert_eq!(CurrencyCode::from_str("NLG").unwrap(), CurrencyCode::Nlg); - assert_eq!(CurrencyCode::from_str("NOK").unwrap(), CurrencyCode::Nok); - assert_eq!(CurrencyCode::from_str("NPR").unwrap(), CurrencyCode::Npr); - assert_eq!(CurrencyCode::from_str("NZD").unwrap(), CurrencyCode::Nzd); - assert_eq!(CurrencyCode::from_str("OMR").unwrap(), CurrencyCode::Omr); - assert_eq!(CurrencyCode::from_str("PAB").unwrap(), CurrencyCode::Pab); - assert_eq!(CurrencyCode::from_str("PEH").unwrap(), CurrencyCode::Peh); - assert_eq!(CurrencyCode::from_str("PEI").unwrap(), CurrencyCode::Pei); - assert_eq!(CurrencyCode::from_str("PEN").unwrap(), CurrencyCode::Pen); - assert_eq!(CurrencyCode::from_str("PES").unwrap(), CurrencyCode::Pes); - assert_eq!(CurrencyCode::from_str("PGK").unwrap(), CurrencyCode::Pgk); - assert_eq!(CurrencyCode::from_str("PHP").unwrap(), CurrencyCode::Php); - assert_eq!(CurrencyCode::from_str("PKR").unwrap(), CurrencyCode::Pkr); - assert_eq!(CurrencyCode::from_str("PLN").unwrap(), CurrencyCode::Pln); - assert_eq!(CurrencyCode::from_str("PLZ").unwrap(), CurrencyCode::Plz); - assert_eq!(CurrencyCode::from_str("PTE").unwrap(), CurrencyCode::Pte); - assert_eq!(CurrencyCode::from_str("PYG").unwrap(), CurrencyCode::Pyg); - assert_eq!(CurrencyCode::from_str("QAR").unwrap(), CurrencyCode::Qar); - assert_eq!(CurrencyCode::from_str("RHD").unwrap(), CurrencyCode::Rhd); - assert_eq!(CurrencyCode::from_str("ROK").unwrap(), CurrencyCode::Rok); - assert_eq!(CurrencyCode::from_str("ROL").unwrap(), CurrencyCode::Rol); - assert_eq!(CurrencyCode::from_str("RON").unwrap(), CurrencyCode::Ron); - assert_eq!(CurrencyCode::from_str("RSD").unwrap(), CurrencyCode::Rsd); - assert_eq!(CurrencyCode::from_str("RUB").unwrap(), CurrencyCode::Rub); - assert_eq!(CurrencyCode::from_str("RUR").unwrap(), CurrencyCode::Rur); - assert_eq!(CurrencyCode::from_str("RWF").unwrap(), CurrencyCode::Rwf); - assert_eq!(CurrencyCode::from_str("SAR").unwrap(), CurrencyCode::Sar); - assert_eq!(CurrencyCode::from_str("SBD").unwrap(), CurrencyCode::Sbd); - assert_eq!(CurrencyCode::from_str("SCR").unwrap(), CurrencyCode::Scr); - assert_eq!(CurrencyCode::from_str("SDD").unwrap(), CurrencyCode::Sdd); - assert_eq!(CurrencyCode::from_str("SDG").unwrap(), CurrencyCode::Sdg); - assert_eq!(CurrencyCode::from_str("SDP").unwrap(), CurrencyCode::Sdp); - assert_eq!(CurrencyCode::from_str("SEK").unwrap(), CurrencyCode::Sek); - assert_eq!(CurrencyCode::from_str("SGD").unwrap(), CurrencyCode::Sgd); - assert_eq!(CurrencyCode::from_str("SHP").unwrap(), CurrencyCode::Shp); - assert_eq!(CurrencyCode::from_str("SIT").unwrap(), CurrencyCode::Sit); - assert_eq!(CurrencyCode::from_str("SKK").unwrap(), CurrencyCode::Skk); - assert_eq!(CurrencyCode::from_str("SLL").unwrap(), CurrencyCode::Sll); - assert_eq!(CurrencyCode::from_str("SOS").unwrap(), CurrencyCode::Sos); - assert_eq!(CurrencyCode::from_str("SRD").unwrap(), CurrencyCode::Srd); - assert_eq!(CurrencyCode::from_str("SRG").unwrap(), CurrencyCode::Srg); - assert_eq!(CurrencyCode::from_str("SSP").unwrap(), CurrencyCode::Ssp); - assert_eq!(CurrencyCode::from_str("STD").unwrap(), CurrencyCode::Std); - assert_eq!(CurrencyCode::from_str("STN").unwrap(), CurrencyCode::Stn); - assert_eq!(CurrencyCode::from_str("SUR").unwrap(), CurrencyCode::Sur); - assert_eq!(CurrencyCode::from_str("SVC").unwrap(), CurrencyCode::Svc); - assert_eq!(CurrencyCode::from_str("SYP").unwrap(), CurrencyCode::Syp); - assert_eq!(CurrencyCode::from_str("SZL").unwrap(), CurrencyCode::Szl); - assert_eq!(CurrencyCode::from_str("THB").unwrap(), CurrencyCode::Thb); - assert_eq!(CurrencyCode::from_str("TJR").unwrap(), CurrencyCode::Tjr); - assert_eq!(CurrencyCode::from_str("TJS").unwrap(), CurrencyCode::Tjs); - assert_eq!(CurrencyCode::from_str("TMM").unwrap(), CurrencyCode::Tmm); - assert_eq!(CurrencyCode::from_str("TMT").unwrap(), CurrencyCode::Tmt); - assert_eq!(CurrencyCode::from_str("TND").unwrap(), CurrencyCode::Tnd); - assert_eq!(CurrencyCode::from_str("TOP").unwrap(), CurrencyCode::Top); - assert_eq!(CurrencyCode::from_str("TPE").unwrap(), CurrencyCode::Tpe); - assert_eq!(CurrencyCode::from_str("TRL").unwrap(), CurrencyCode::Trl); - assert_eq!(CurrencyCode::from_str("TRY").unwrap(), CurrencyCode::Try); - assert_eq!(CurrencyCode::from_str("TTD").unwrap(), CurrencyCode::Ttd); - assert_eq!(CurrencyCode::from_str("TWD").unwrap(), CurrencyCode::Twd); - assert_eq!(CurrencyCode::from_str("TZS").unwrap(), CurrencyCode::Tzs); - assert_eq!(CurrencyCode::from_str("UAH").unwrap(), CurrencyCode::Uah); - assert_eq!(CurrencyCode::from_str("UAK").unwrap(), CurrencyCode::Uak); - assert_eq!(CurrencyCode::from_str("UGS").unwrap(), CurrencyCode::Ugs); - assert_eq!(CurrencyCode::from_str("UGW").unwrap(), CurrencyCode::Ugw); - assert_eq!(CurrencyCode::from_str("UGX").unwrap(), CurrencyCode::Ugx); - assert_eq!(CurrencyCode::from_str("USD").unwrap(), CurrencyCode::Usd); - assert_eq!(CurrencyCode::from_str("USN").unwrap(), CurrencyCode::Usn); - assert_eq!(CurrencyCode::from_str("USS").unwrap(), CurrencyCode::Uss); - assert_eq!(CurrencyCode::from_str("UYI").unwrap(), CurrencyCode::Uyi); - assert_eq!(CurrencyCode::from_str("UYN").unwrap(), CurrencyCode::Uyn); - assert_eq!(CurrencyCode::from_str("UYP").unwrap(), CurrencyCode::Uyp); - assert_eq!(CurrencyCode::from_str("UYU").unwrap(), CurrencyCode::Uyu); - assert_eq!(CurrencyCode::from_str("UYW").unwrap(), CurrencyCode::Uyw); - assert_eq!(CurrencyCode::from_str("UZS").unwrap(), CurrencyCode::Uzs); - assert_eq!(CurrencyCode::from_str("VEB").unwrap(), CurrencyCode::Veb); - assert_eq!(CurrencyCode::from_str("VEF").unwrap(), CurrencyCode::Vef); - assert_eq!(CurrencyCode::from_str("VES").unwrap(), CurrencyCode::Ves); - assert_eq!(CurrencyCode::from_str("VNC").unwrap(), CurrencyCode::Vnc); - assert_eq!(CurrencyCode::from_str("VND").unwrap(), CurrencyCode::Vnd); - assert_eq!(CurrencyCode::from_str("VUV").unwrap(), CurrencyCode::Vuv); - assert_eq!(CurrencyCode::from_str("WST").unwrap(), CurrencyCode::Wst); - assert_eq!(CurrencyCode::from_str("XAF").unwrap(), CurrencyCode::Xaf); - assert_eq!(CurrencyCode::from_str("XAG").unwrap(), CurrencyCode::Xag); - assert_eq!(CurrencyCode::from_str("XAU").unwrap(), CurrencyCode::Xau); - assert_eq!(CurrencyCode::from_str("XBA").unwrap(), CurrencyCode::Xba); - assert_eq!(CurrencyCode::from_str("XBB").unwrap(), CurrencyCode::Xbb); - assert_eq!(CurrencyCode::from_str("XBC").unwrap(), CurrencyCode::Xbc); - assert_eq!(CurrencyCode::from_str("XBD").unwrap(), CurrencyCode::Xbd); - assert_eq!(CurrencyCode::from_str("XCD").unwrap(), CurrencyCode::Xcd); - assert_eq!(CurrencyCode::from_str("XDR").unwrap(), CurrencyCode::Xdr); - assert_eq!(CurrencyCode::from_str("XEU").unwrap(), CurrencyCode::Xeu); - assert_eq!(CurrencyCode::from_str("XFO").unwrap(), CurrencyCode::Xfo); - assert_eq!(CurrencyCode::from_str("XFU").unwrap(), CurrencyCode::Xfu); - assert_eq!(CurrencyCode::from_str("XOF").unwrap(), CurrencyCode::Xof); - assert_eq!(CurrencyCode::from_str("XPD").unwrap(), CurrencyCode::Xpd); - assert_eq!(CurrencyCode::from_str("XPF").unwrap(), CurrencyCode::Xpf); - assert_eq!(CurrencyCode::from_str("XPT").unwrap(), CurrencyCode::Xpt); - assert_eq!(CurrencyCode::from_str("XRE").unwrap(), CurrencyCode::Xre); - assert_eq!(CurrencyCode::from_str("XSU").unwrap(), CurrencyCode::Xsu); - assert_eq!(CurrencyCode::from_str("XTS").unwrap(), CurrencyCode::Xts); - assert_eq!(CurrencyCode::from_str("XUA").unwrap(), CurrencyCode::Xua); - assert_eq!(CurrencyCode::from_str("XXX").unwrap(), CurrencyCode::Xxx); - assert_eq!(CurrencyCode::from_str("YDD").unwrap(), CurrencyCode::Ydd); - assert_eq!(CurrencyCode::from_str("YER").unwrap(), CurrencyCode::Yer); - assert_eq!(CurrencyCode::from_str("YUD").unwrap(), CurrencyCode::Yud); - assert_eq!(CurrencyCode::from_str("YUM").unwrap(), CurrencyCode::Yum); - assert_eq!(CurrencyCode::from_str("YUN").unwrap(), CurrencyCode::Yun); - assert_eq!(CurrencyCode::from_str("ZAL").unwrap(), CurrencyCode::Zal); - assert_eq!(CurrencyCode::from_str("ZAR").unwrap(), CurrencyCode::Zar); - assert_eq!(CurrencyCode::from_str("ZMK").unwrap(), CurrencyCode::Zmk); - assert_eq!(CurrencyCode::from_str("ZMW").unwrap(), CurrencyCode::Zmw); - assert_eq!(CurrencyCode::from_str("ZRN").unwrap(), CurrencyCode::Zrn); - assert_eq!(CurrencyCode::from_str("ZRZ").unwrap(), CurrencyCode::Zrz); - assert_eq!(CurrencyCode::from_str("ZWC").unwrap(), CurrencyCode::Zwc); - assert_eq!(CurrencyCode::from_str("ZWD").unwrap(), CurrencyCode::Zwd); - assert_eq!(CurrencyCode::from_str("ZWL").unwrap(), CurrencyCode::Zwl); - assert_eq!(CurrencyCode::from_str("ZWN").unwrap(), CurrencyCode::Zwn); - assert_eq!(CurrencyCode::from_str("ZWR").unwrap(), CurrencyCode::Zwr); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::PricePolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/price/policy.rs b/thoth-api/src/model/price/policy.rs new file mode 100644 index 00000000..239709c1 --- /dev/null +++ b/thoth-api/src/model/price/policy.rs @@ -0,0 +1,48 @@ +use crate::model::price::{NewPrice, PatchPrice, Price}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Price`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +/// - enforcing business rules (e.g. non-zero unit price) +pub struct PricePolicy; + +fn validate_unit_price(unit_price: f64) -> ThothResult<()> { + // Prices must be non-zero (and non-negative). + if unit_price <= 0.0 { + return Err(ThothError::PriceZeroError); + } + Ok(()) +} + +impl CreatePolicy<NewPrice> for PricePolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewPrice, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + validate_unit_price(data.unit_price) + } +} + +impl UpdatePolicy<Price, PatchPrice> for PricePolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Price, + patch: &PatchPrice, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + // Enforce non-zero unit price. + validate_unit_price(patch.unit_price) + } +} + +impl DeletePolicy<Price> for PricePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Price) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/price/tests.rs b/thoth-api/src/model/price/tests.rs new file mode 100644 index 00000000..b1a9cd18 --- /dev/null +++ b/thoth-api/src/model/price/tests.rs @@ -0,0 +1,1236 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_price( + pool: &crate::db::PgPool, + publication_id: Uuid, + currency_code: CurrencyCode, + unit_price: f64, +) -> Price { + let new_price = NewPrice { + publication_id, + currency_code, + unit_price, + }; + + Price::create(pool, &new_price).expect("Failed to create price") +} + +mod defaults { + use super::*; + + #[test] + fn currencycode_default_is_gbp() { + let currencycode: CurrencyCode = Default::default(); + assert_eq!(currencycode, CurrencyCode::Gbp); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn currencycode_display_formats_expected_strings() { + assert_eq!(format!("{}", CurrencyCode::Adp), "ADP"); + assert_eq!(format!("{}", CurrencyCode::Aed), "AED"); + assert_eq!(format!("{}", CurrencyCode::Afa), "AFA"); + assert_eq!(format!("{}", CurrencyCode::Afn), "AFN"); + assert_eq!(format!("{}", CurrencyCode::Alk), "ALK"); + assert_eq!(format!("{}", CurrencyCode::All), "ALL"); + assert_eq!(format!("{}", CurrencyCode::Amd), "AMD"); + assert_eq!(format!("{}", CurrencyCode::Ang), "ANG"); + assert_eq!(format!("{}", CurrencyCode::Aoa), "AOA"); + assert_eq!(format!("{}", CurrencyCode::Aok), "AOK"); + assert_eq!(format!("{}", CurrencyCode::Aon), "AON"); + assert_eq!(format!("{}", CurrencyCode::Aor), "AOR"); + assert_eq!(format!("{}", CurrencyCode::Ara), "ARA"); + assert_eq!(format!("{}", CurrencyCode::Arp), "ARP"); + assert_eq!(format!("{}", CurrencyCode::Ars), "ARS"); + assert_eq!(format!("{}", CurrencyCode::Ary), "ARY"); + assert_eq!(format!("{}", CurrencyCode::Ats), "ATS"); + assert_eq!(format!("{}", CurrencyCode::Aud), "AUD"); + assert_eq!(format!("{}", CurrencyCode::Awg), "AWG"); + assert_eq!(format!("{}", CurrencyCode::Aym), "AYM"); + assert_eq!(format!("{}", CurrencyCode::Azm), "AZM"); + assert_eq!(format!("{}", CurrencyCode::Azn), "AZN"); + assert_eq!(format!("{}", CurrencyCode::Bad), "BAD"); + assert_eq!(format!("{}", CurrencyCode::Bam), "BAM"); + assert_eq!(format!("{}", CurrencyCode::Bbd), "BBD"); + assert_eq!(format!("{}", CurrencyCode::Bdt), "BDT"); + assert_eq!(format!("{}", CurrencyCode::Bec), "BEC"); + assert_eq!(format!("{}", CurrencyCode::Bef), "BEF"); + assert_eq!(format!("{}", CurrencyCode::Bel), "BEL"); + assert_eq!(format!("{}", CurrencyCode::Bgj), "BGJ"); + assert_eq!(format!("{}", CurrencyCode::Bgk), "BGK"); + assert_eq!(format!("{}", CurrencyCode::Bgl), "BGL"); + assert_eq!(format!("{}", CurrencyCode::Bgn), "BGN"); + assert_eq!(format!("{}", CurrencyCode::Bhd), "BHD"); + assert_eq!(format!("{}", CurrencyCode::Bif), "BIF"); + assert_eq!(format!("{}", CurrencyCode::Bmd), "BMD"); + assert_eq!(format!("{}", CurrencyCode::Bnd), "BND"); + assert_eq!(format!("{}", CurrencyCode::Bob), "BOB"); + assert_eq!(format!("{}", CurrencyCode::Bop), "BOP"); + assert_eq!(format!("{}", CurrencyCode::Bov), "BOV"); + assert_eq!(format!("{}", CurrencyCode::Brb), "BRB"); + assert_eq!(format!("{}", CurrencyCode::Brc), "BRC"); + assert_eq!(format!("{}", CurrencyCode::Bre), "BRE"); + assert_eq!(format!("{}", CurrencyCode::Brl), "BRL"); + assert_eq!(format!("{}", CurrencyCode::Brn), "BRN"); + assert_eq!(format!("{}", CurrencyCode::Brr), "BRR"); + assert_eq!(format!("{}", CurrencyCode::Bsd), "BSD"); + assert_eq!(format!("{}", CurrencyCode::Btn), "BTN"); + assert_eq!(format!("{}", CurrencyCode::Buk), "BUK"); + assert_eq!(format!("{}", CurrencyCode::Bwp), "BWP"); + assert_eq!(format!("{}", CurrencyCode::Byb), "BYB"); + assert_eq!(format!("{}", CurrencyCode::Byn), "BYN"); + assert_eq!(format!("{}", CurrencyCode::Byr), "BYR"); + assert_eq!(format!("{}", CurrencyCode::Bzd), "BZD"); + assert_eq!(format!("{}", CurrencyCode::Cad), "CAD"); + assert_eq!(format!("{}", CurrencyCode::Cdf), "CDF"); + assert_eq!(format!("{}", CurrencyCode::Chc), "CHC"); + assert_eq!(format!("{}", CurrencyCode::Che), "CHE"); + assert_eq!(format!("{}", CurrencyCode::Chf), "CHF"); + assert_eq!(format!("{}", CurrencyCode::Chw), "CHW"); + assert_eq!(format!("{}", CurrencyCode::Clf), "CLF"); + assert_eq!(format!("{}", CurrencyCode::Clp), "CLP"); + assert_eq!(format!("{}", CurrencyCode::Cny), "CNY"); + assert_eq!(format!("{}", CurrencyCode::Cop), "COP"); + assert_eq!(format!("{}", CurrencyCode::Cou), "COU"); + assert_eq!(format!("{}", CurrencyCode::Crc), "CRC"); + assert_eq!(format!("{}", CurrencyCode::Csd), "CSD"); + assert_eq!(format!("{}", CurrencyCode::Csj), "CSJ"); + assert_eq!(format!("{}", CurrencyCode::Csk), "CSK"); + assert_eq!(format!("{}", CurrencyCode::Cuc), "CUC"); + assert_eq!(format!("{}", CurrencyCode::Cup), "CUP"); + assert_eq!(format!("{}", CurrencyCode::Cve), "CVE"); + assert_eq!(format!("{}", CurrencyCode::Cyp), "CYP"); + assert_eq!(format!("{}", CurrencyCode::Czk), "CZK"); + assert_eq!(format!("{}", CurrencyCode::Ddm), "DDM"); + assert_eq!(format!("{}", CurrencyCode::Dem), "DEM"); + assert_eq!(format!("{}", CurrencyCode::Djf), "DJF"); + assert_eq!(format!("{}", CurrencyCode::Dkk), "DKK"); + assert_eq!(format!("{}", CurrencyCode::Dop), "DOP"); + assert_eq!(format!("{}", CurrencyCode::Dzd), "DZD"); + assert_eq!(format!("{}", CurrencyCode::Ecs), "ECS"); + assert_eq!(format!("{}", CurrencyCode::Ecv), "ECV"); + assert_eq!(format!("{}", CurrencyCode::Eek), "EEK"); + assert_eq!(format!("{}", CurrencyCode::Egp), "EGP"); + assert_eq!(format!("{}", CurrencyCode::Ern), "ERN"); + assert_eq!(format!("{}", CurrencyCode::Esa), "ESA"); + assert_eq!(format!("{}", CurrencyCode::Esb), "ESB"); + assert_eq!(format!("{}", CurrencyCode::Esp), "ESP"); + assert_eq!(format!("{}", CurrencyCode::Etb), "ETB"); + assert_eq!(format!("{}", CurrencyCode::Eur), "EUR"); + assert_eq!(format!("{}", CurrencyCode::Fim), "FIM"); + assert_eq!(format!("{}", CurrencyCode::Fjd), "FJD"); + assert_eq!(format!("{}", CurrencyCode::Fkp), "FKP"); + assert_eq!(format!("{}", CurrencyCode::Frf), "FRF"); + assert_eq!(format!("{}", CurrencyCode::Gbp), "GBP"); + assert_eq!(format!("{}", CurrencyCode::Gek), "GEK"); + assert_eq!(format!("{}", CurrencyCode::Gel), "GEL"); + assert_eq!(format!("{}", CurrencyCode::Ghc), "GHC"); + assert_eq!(format!("{}", CurrencyCode::Ghp), "GHP"); + assert_eq!(format!("{}", CurrencyCode::Ghs), "GHS"); + assert_eq!(format!("{}", CurrencyCode::Gip), "GIP"); + assert_eq!(format!("{}", CurrencyCode::Gmd), "GMD"); + assert_eq!(format!("{}", CurrencyCode::Gne), "GNE"); + assert_eq!(format!("{}", CurrencyCode::Gnf), "GNF"); + assert_eq!(format!("{}", CurrencyCode::Gns), "GNS"); + assert_eq!(format!("{}", CurrencyCode::Gqe), "GQE"); + assert_eq!(format!("{}", CurrencyCode::Grd), "GRD"); + assert_eq!(format!("{}", CurrencyCode::Gtq), "GTQ"); + assert_eq!(format!("{}", CurrencyCode::Gwe), "GWE"); + assert_eq!(format!("{}", CurrencyCode::Gwp), "GWP"); + assert_eq!(format!("{}", CurrencyCode::Gyd), "GYD"); + assert_eq!(format!("{}", CurrencyCode::Hkd), "HKD"); + assert_eq!(format!("{}", CurrencyCode::Hnl), "HNL"); + assert_eq!(format!("{}", CurrencyCode::Hrd), "HRD"); + assert_eq!(format!("{}", CurrencyCode::Hrk), "HRK"); + assert_eq!(format!("{}", CurrencyCode::Htg), "HTG"); + assert_eq!(format!("{}", CurrencyCode::Huf), "HUF"); + assert_eq!(format!("{}", CurrencyCode::Idr), "IDR"); + assert_eq!(format!("{}", CurrencyCode::Iep), "IEP"); + assert_eq!(format!("{}", CurrencyCode::Ilp), "ILP"); + assert_eq!(format!("{}", CurrencyCode::Ilr), "ILR"); + assert_eq!(format!("{}", CurrencyCode::Ils), "ILS"); + assert_eq!(format!("{}", CurrencyCode::Inr), "INR"); + assert_eq!(format!("{}", CurrencyCode::Iqd), "IQD"); + assert_eq!(format!("{}", CurrencyCode::Irr), "IRR"); + assert_eq!(format!("{}", CurrencyCode::Isj), "ISJ"); + assert_eq!(format!("{}", CurrencyCode::Isk), "ISK"); + assert_eq!(format!("{}", CurrencyCode::Itl), "ITL"); + assert_eq!(format!("{}", CurrencyCode::Jmd), "JMD"); + assert_eq!(format!("{}", CurrencyCode::Jod), "JOD"); + assert_eq!(format!("{}", CurrencyCode::Jpy), "JPY"); + assert_eq!(format!("{}", CurrencyCode::Kes), "KES"); + assert_eq!(format!("{}", CurrencyCode::Kgs), "KGS"); + assert_eq!(format!("{}", CurrencyCode::Khr), "KHR"); + assert_eq!(format!("{}", CurrencyCode::Kmf), "KMF"); + assert_eq!(format!("{}", CurrencyCode::Kpw), "KPW"); + assert_eq!(format!("{}", CurrencyCode::Krw), "KRW"); + assert_eq!(format!("{}", CurrencyCode::Kwd), "KWD"); + assert_eq!(format!("{}", CurrencyCode::Kyd), "KYD"); + assert_eq!(format!("{}", CurrencyCode::Kzt), "KZT"); + assert_eq!(format!("{}", CurrencyCode::Laj), "LAJ"); + assert_eq!(format!("{}", CurrencyCode::Lak), "LAK"); + assert_eq!(format!("{}", CurrencyCode::Lbp), "LBP"); + assert_eq!(format!("{}", CurrencyCode::Lkr), "LKR"); + assert_eq!(format!("{}", CurrencyCode::Lrd), "LRD"); + assert_eq!(format!("{}", CurrencyCode::Lsl), "LSL"); + assert_eq!(format!("{}", CurrencyCode::Lsm), "LSM"); + assert_eq!(format!("{}", CurrencyCode::Ltl), "LTL"); + assert_eq!(format!("{}", CurrencyCode::Ltt), "LTT"); + assert_eq!(format!("{}", CurrencyCode::Luc), "LUC"); + assert_eq!(format!("{}", CurrencyCode::Luf), "LUF"); + assert_eq!(format!("{}", CurrencyCode::Lul), "LUL"); + assert_eq!(format!("{}", CurrencyCode::Lvl), "LVL"); + assert_eq!(format!("{}", CurrencyCode::Lvr), "LVR"); + assert_eq!(format!("{}", CurrencyCode::Lyd), "LYD"); + assert_eq!(format!("{}", CurrencyCode::Mad), "MAD"); + assert_eq!(format!("{}", CurrencyCode::Mdl), "MDL"); + assert_eq!(format!("{}", CurrencyCode::Mga), "MGA"); + assert_eq!(format!("{}", CurrencyCode::Mgf), "MGF"); + assert_eq!(format!("{}", CurrencyCode::Mkd), "MKD"); + assert_eq!(format!("{}", CurrencyCode::Mlf), "MLF"); + assert_eq!(format!("{}", CurrencyCode::Mmk), "MMK"); + assert_eq!(format!("{}", CurrencyCode::Mnt), "MNT"); + assert_eq!(format!("{}", CurrencyCode::Mop), "MOP"); + assert_eq!(format!("{}", CurrencyCode::Mro), "MRO"); + assert_eq!(format!("{}", CurrencyCode::Mru), "MRU"); + assert_eq!(format!("{}", CurrencyCode::Mtl), "MTL"); + assert_eq!(format!("{}", CurrencyCode::Mtp), "MTP"); + assert_eq!(format!("{}", CurrencyCode::Mur), "MUR"); + assert_eq!(format!("{}", CurrencyCode::Mvq), "MVQ"); + assert_eq!(format!("{}", CurrencyCode::Mvr), "MVR"); + assert_eq!(format!("{}", CurrencyCode::Mwk), "MWK"); + assert_eq!(format!("{}", CurrencyCode::Mxn), "MXN"); + assert_eq!(format!("{}", CurrencyCode::Mxp), "MXP"); + assert_eq!(format!("{}", CurrencyCode::Mxv), "MXV"); + assert_eq!(format!("{}", CurrencyCode::Myr), "MYR"); + assert_eq!(format!("{}", CurrencyCode::Mze), "MZE"); + assert_eq!(format!("{}", CurrencyCode::Mzm), "MZM"); + assert_eq!(format!("{}", CurrencyCode::Mzn), "MZN"); + assert_eq!(format!("{}", CurrencyCode::Nad), "NAD"); + assert_eq!(format!("{}", CurrencyCode::Ngn), "NGN"); + assert_eq!(format!("{}", CurrencyCode::Nic), "NIC"); + assert_eq!(format!("{}", CurrencyCode::Nio), "NIO"); + assert_eq!(format!("{}", CurrencyCode::Nlg), "NLG"); + assert_eq!(format!("{}", CurrencyCode::Nok), "NOK"); + assert_eq!(format!("{}", CurrencyCode::Npr), "NPR"); + assert_eq!(format!("{}", CurrencyCode::Nzd), "NZD"); + assert_eq!(format!("{}", CurrencyCode::Omr), "OMR"); + assert_eq!(format!("{}", CurrencyCode::Pab), "PAB"); + assert_eq!(format!("{}", CurrencyCode::Peh), "PEH"); + assert_eq!(format!("{}", CurrencyCode::Pei), "PEI"); + assert_eq!(format!("{}", CurrencyCode::Pen), "PEN"); + assert_eq!(format!("{}", CurrencyCode::Pes), "PES"); + assert_eq!(format!("{}", CurrencyCode::Pgk), "PGK"); + assert_eq!(format!("{}", CurrencyCode::Php), "PHP"); + assert_eq!(format!("{}", CurrencyCode::Pkr), "PKR"); + assert_eq!(format!("{}", CurrencyCode::Pln), "PLN"); + assert_eq!(format!("{}", CurrencyCode::Plz), "PLZ"); + assert_eq!(format!("{}", CurrencyCode::Pte), "PTE"); + assert_eq!(format!("{}", CurrencyCode::Pyg), "PYG"); + assert_eq!(format!("{}", CurrencyCode::Qar), "QAR"); + assert_eq!(format!("{}", CurrencyCode::Rhd), "RHD"); + assert_eq!(format!("{}", CurrencyCode::Rok), "ROK"); + assert_eq!(format!("{}", CurrencyCode::Rol), "ROL"); + assert_eq!(format!("{}", CurrencyCode::Ron), "RON"); + assert_eq!(format!("{}", CurrencyCode::Rsd), "RSD"); + assert_eq!(format!("{}", CurrencyCode::Rub), "RUB"); + assert_eq!(format!("{}", CurrencyCode::Rur), "RUR"); + assert_eq!(format!("{}", CurrencyCode::Rwf), "RWF"); + assert_eq!(format!("{}", CurrencyCode::Sar), "SAR"); + assert_eq!(format!("{}", CurrencyCode::Sbd), "SBD"); + assert_eq!(format!("{}", CurrencyCode::Scr), "SCR"); + assert_eq!(format!("{}", CurrencyCode::Sdd), "SDD"); + assert_eq!(format!("{}", CurrencyCode::Sdg), "SDG"); + assert_eq!(format!("{}", CurrencyCode::Sdp), "SDP"); + assert_eq!(format!("{}", CurrencyCode::Sek), "SEK"); + assert_eq!(format!("{}", CurrencyCode::Sgd), "SGD"); + assert_eq!(format!("{}", CurrencyCode::Shp), "SHP"); + assert_eq!(format!("{}", CurrencyCode::Sit), "SIT"); + assert_eq!(format!("{}", CurrencyCode::Skk), "SKK"); + assert_eq!(format!("{}", CurrencyCode::Sll), "SLL"); + assert_eq!(format!("{}", CurrencyCode::Sos), "SOS"); + assert_eq!(format!("{}", CurrencyCode::Srd), "SRD"); + assert_eq!(format!("{}", CurrencyCode::Srg), "SRG"); + assert_eq!(format!("{}", CurrencyCode::Ssp), "SSP"); + assert_eq!(format!("{}", CurrencyCode::Std), "STD"); + assert_eq!(format!("{}", CurrencyCode::Stn), "STN"); + assert_eq!(format!("{}", CurrencyCode::Sur), "SUR"); + assert_eq!(format!("{}", CurrencyCode::Svc), "SVC"); + assert_eq!(format!("{}", CurrencyCode::Syp), "SYP"); + assert_eq!(format!("{}", CurrencyCode::Szl), "SZL"); + assert_eq!(format!("{}", CurrencyCode::Thb), "THB"); + assert_eq!(format!("{}", CurrencyCode::Tjr), "TJR"); + assert_eq!(format!("{}", CurrencyCode::Tjs), "TJS"); + assert_eq!(format!("{}", CurrencyCode::Tmm), "TMM"); + assert_eq!(format!("{}", CurrencyCode::Tmt), "TMT"); + assert_eq!(format!("{}", CurrencyCode::Tnd), "TND"); + assert_eq!(format!("{}", CurrencyCode::Top), "TOP"); + assert_eq!(format!("{}", CurrencyCode::Tpe), "TPE"); + assert_eq!(format!("{}", CurrencyCode::Trl), "TRL"); + assert_eq!(format!("{}", CurrencyCode::Try), "TRY"); + assert_eq!(format!("{}", CurrencyCode::Ttd), "TTD"); + assert_eq!(format!("{}", CurrencyCode::Twd), "TWD"); + assert_eq!(format!("{}", CurrencyCode::Tzs), "TZS"); + assert_eq!(format!("{}", CurrencyCode::Uah), "UAH"); + assert_eq!(format!("{}", CurrencyCode::Uak), "UAK"); + assert_eq!(format!("{}", CurrencyCode::Ugs), "UGS"); + assert_eq!(format!("{}", CurrencyCode::Ugw), "UGW"); + assert_eq!(format!("{}", CurrencyCode::Ugx), "UGX"); + assert_eq!(format!("{}", CurrencyCode::Usd), "USD"); + assert_eq!(format!("{}", CurrencyCode::Usn), "USN"); + assert_eq!(format!("{}", CurrencyCode::Uss), "USS"); + assert_eq!(format!("{}", CurrencyCode::Uyi), "UYI"); + assert_eq!(format!("{}", CurrencyCode::Uyn), "UYN"); + assert_eq!(format!("{}", CurrencyCode::Uyp), "UYP"); + assert_eq!(format!("{}", CurrencyCode::Uyu), "UYU"); + assert_eq!(format!("{}", CurrencyCode::Uyw), "UYW"); + assert_eq!(format!("{}", CurrencyCode::Uzs), "UZS"); + assert_eq!(format!("{}", CurrencyCode::Veb), "VEB"); + assert_eq!(format!("{}", CurrencyCode::Vef), "VEF"); + assert_eq!(format!("{}", CurrencyCode::Ves), "VES"); + assert_eq!(format!("{}", CurrencyCode::Vnc), "VNC"); + assert_eq!(format!("{}", CurrencyCode::Vnd), "VND"); + assert_eq!(format!("{}", CurrencyCode::Vuv), "VUV"); + assert_eq!(format!("{}", CurrencyCode::Wst), "WST"); + assert_eq!(format!("{}", CurrencyCode::Xaf), "XAF"); + assert_eq!(format!("{}", CurrencyCode::Xag), "XAG"); + assert_eq!(format!("{}", CurrencyCode::Xau), "XAU"); + assert_eq!(format!("{}", CurrencyCode::Xba), "XBA"); + assert_eq!(format!("{}", CurrencyCode::Xbb), "XBB"); + assert_eq!(format!("{}", CurrencyCode::Xbc), "XBC"); + assert_eq!(format!("{}", CurrencyCode::Xbd), "XBD"); + assert_eq!(format!("{}", CurrencyCode::Xcd), "XCD"); + assert_eq!(format!("{}", CurrencyCode::Xdr), "XDR"); + assert_eq!(format!("{}", CurrencyCode::Xeu), "XEU"); + assert_eq!(format!("{}", CurrencyCode::Xfo), "XFO"); + assert_eq!(format!("{}", CurrencyCode::Xfu), "XFU"); + assert_eq!(format!("{}", CurrencyCode::Xof), "XOF"); + assert_eq!(format!("{}", CurrencyCode::Xpd), "XPD"); + assert_eq!(format!("{}", CurrencyCode::Xpf), "XPF"); + assert_eq!(format!("{}", CurrencyCode::Xpt), "XPT"); + assert_eq!(format!("{}", CurrencyCode::Xre), "XRE"); + assert_eq!(format!("{}", CurrencyCode::Xsu), "XSU"); + assert_eq!(format!("{}", CurrencyCode::Xts), "XTS"); + assert_eq!(format!("{}", CurrencyCode::Xua), "XUA"); + assert_eq!(format!("{}", CurrencyCode::Xxx), "XXX"); + assert_eq!(format!("{}", CurrencyCode::Ydd), "YDD"); + assert_eq!(format!("{}", CurrencyCode::Yer), "YER"); + assert_eq!(format!("{}", CurrencyCode::Yud), "YUD"); + assert_eq!(format!("{}", CurrencyCode::Yum), "YUM"); + assert_eq!(format!("{}", CurrencyCode::Yun), "YUN"); + assert_eq!(format!("{}", CurrencyCode::Zal), "ZAL"); + assert_eq!(format!("{}", CurrencyCode::Zar), "ZAR"); + assert_eq!(format!("{}", CurrencyCode::Zmk), "ZMK"); + assert_eq!(format!("{}", CurrencyCode::Zmw), "ZMW"); + assert_eq!(format!("{}", CurrencyCode::Zrn), "ZRN"); + assert_eq!(format!("{}", CurrencyCode::Zrz), "ZRZ"); + assert_eq!(format!("{}", CurrencyCode::Zwc), "ZWC"); + assert_eq!(format!("{}", CurrencyCode::Zwd), "ZWD"); + assert_eq!(format!("{}", CurrencyCode::Zwl), "ZWL"); + assert_eq!(format!("{}", CurrencyCode::Zwn), "ZWN"); + assert_eq!(format!("{}", CurrencyCode::Zwr), "ZWR"); + } + + #[test] + fn currencycode_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!(CurrencyCode::from_str("ADP").unwrap(), CurrencyCode::Adp); + assert_eq!(CurrencyCode::from_str("AED").unwrap(), CurrencyCode::Aed); + assert_eq!(CurrencyCode::from_str("AFA").unwrap(), CurrencyCode::Afa); + assert_eq!(CurrencyCode::from_str("AFN").unwrap(), CurrencyCode::Afn); + assert_eq!(CurrencyCode::from_str("ALK").unwrap(), CurrencyCode::Alk); + assert_eq!(CurrencyCode::from_str("ALL").unwrap(), CurrencyCode::All); + assert_eq!(CurrencyCode::from_str("AMD").unwrap(), CurrencyCode::Amd); + assert_eq!(CurrencyCode::from_str("ANG").unwrap(), CurrencyCode::Ang); + assert_eq!(CurrencyCode::from_str("AOA").unwrap(), CurrencyCode::Aoa); + assert_eq!(CurrencyCode::from_str("AOK").unwrap(), CurrencyCode::Aok); + assert_eq!(CurrencyCode::from_str("AON").unwrap(), CurrencyCode::Aon); + assert_eq!(CurrencyCode::from_str("AOR").unwrap(), CurrencyCode::Aor); + assert_eq!(CurrencyCode::from_str("ARA").unwrap(), CurrencyCode::Ara); + assert_eq!(CurrencyCode::from_str("ARP").unwrap(), CurrencyCode::Arp); + assert_eq!(CurrencyCode::from_str("ARS").unwrap(), CurrencyCode::Ars); + assert_eq!(CurrencyCode::from_str("ARY").unwrap(), CurrencyCode::Ary); + assert_eq!(CurrencyCode::from_str("ATS").unwrap(), CurrencyCode::Ats); + assert_eq!(CurrencyCode::from_str("AUD").unwrap(), CurrencyCode::Aud); + assert_eq!(CurrencyCode::from_str("AWG").unwrap(), CurrencyCode::Awg); + assert_eq!(CurrencyCode::from_str("AYM").unwrap(), CurrencyCode::Aym); + assert_eq!(CurrencyCode::from_str("AZM").unwrap(), CurrencyCode::Azm); + assert_eq!(CurrencyCode::from_str("AZN").unwrap(), CurrencyCode::Azn); + assert_eq!(CurrencyCode::from_str("BAD").unwrap(), CurrencyCode::Bad); + assert_eq!(CurrencyCode::from_str("BAM").unwrap(), CurrencyCode::Bam); + assert_eq!(CurrencyCode::from_str("BBD").unwrap(), CurrencyCode::Bbd); + assert_eq!(CurrencyCode::from_str("BDT").unwrap(), CurrencyCode::Bdt); + assert_eq!(CurrencyCode::from_str("BEC").unwrap(), CurrencyCode::Bec); + assert_eq!(CurrencyCode::from_str("BEF").unwrap(), CurrencyCode::Bef); + assert_eq!(CurrencyCode::from_str("BEL").unwrap(), CurrencyCode::Bel); + assert_eq!(CurrencyCode::from_str("BGJ").unwrap(), CurrencyCode::Bgj); + assert_eq!(CurrencyCode::from_str("BGK").unwrap(), CurrencyCode::Bgk); + assert_eq!(CurrencyCode::from_str("BGL").unwrap(), CurrencyCode::Bgl); + assert_eq!(CurrencyCode::from_str("BGN").unwrap(), CurrencyCode::Bgn); + assert_eq!(CurrencyCode::from_str("BHD").unwrap(), CurrencyCode::Bhd); + assert_eq!(CurrencyCode::from_str("BIF").unwrap(), CurrencyCode::Bif); + assert_eq!(CurrencyCode::from_str("BMD").unwrap(), CurrencyCode::Bmd); + assert_eq!(CurrencyCode::from_str("BND").unwrap(), CurrencyCode::Bnd); + assert_eq!(CurrencyCode::from_str("BOB").unwrap(), CurrencyCode::Bob); + assert_eq!(CurrencyCode::from_str("BOP").unwrap(), CurrencyCode::Bop); + assert_eq!(CurrencyCode::from_str("BOV").unwrap(), CurrencyCode::Bov); + assert_eq!(CurrencyCode::from_str("BRB").unwrap(), CurrencyCode::Brb); + assert_eq!(CurrencyCode::from_str("BRC").unwrap(), CurrencyCode::Brc); + assert_eq!(CurrencyCode::from_str("BRE").unwrap(), CurrencyCode::Bre); + assert_eq!(CurrencyCode::from_str("BRL").unwrap(), CurrencyCode::Brl); + assert_eq!(CurrencyCode::from_str("BRN").unwrap(), CurrencyCode::Brn); + assert_eq!(CurrencyCode::from_str("BRR").unwrap(), CurrencyCode::Brr); + assert_eq!(CurrencyCode::from_str("BSD").unwrap(), CurrencyCode::Bsd); + assert_eq!(CurrencyCode::from_str("BTN").unwrap(), CurrencyCode::Btn); + assert_eq!(CurrencyCode::from_str("BUK").unwrap(), CurrencyCode::Buk); + assert_eq!(CurrencyCode::from_str("BWP").unwrap(), CurrencyCode::Bwp); + assert_eq!(CurrencyCode::from_str("BYB").unwrap(), CurrencyCode::Byb); + assert_eq!(CurrencyCode::from_str("BYN").unwrap(), CurrencyCode::Byn); + assert_eq!(CurrencyCode::from_str("BYR").unwrap(), CurrencyCode::Byr); + assert_eq!(CurrencyCode::from_str("BZD").unwrap(), CurrencyCode::Bzd); + assert_eq!(CurrencyCode::from_str("CAD").unwrap(), CurrencyCode::Cad); + assert_eq!(CurrencyCode::from_str("CDF").unwrap(), CurrencyCode::Cdf); + assert_eq!(CurrencyCode::from_str("CHC").unwrap(), CurrencyCode::Chc); + assert_eq!(CurrencyCode::from_str("CHE").unwrap(), CurrencyCode::Che); + assert_eq!(CurrencyCode::from_str("CHF").unwrap(), CurrencyCode::Chf); + assert_eq!(CurrencyCode::from_str("CHW").unwrap(), CurrencyCode::Chw); + assert_eq!(CurrencyCode::from_str("CLF").unwrap(), CurrencyCode::Clf); + assert_eq!(CurrencyCode::from_str("CLP").unwrap(), CurrencyCode::Clp); + assert_eq!(CurrencyCode::from_str("CNY").unwrap(), CurrencyCode::Cny); + assert_eq!(CurrencyCode::from_str("COP").unwrap(), CurrencyCode::Cop); + assert_eq!(CurrencyCode::from_str("COU").unwrap(), CurrencyCode::Cou); + assert_eq!(CurrencyCode::from_str("CRC").unwrap(), CurrencyCode::Crc); + assert_eq!(CurrencyCode::from_str("CSD").unwrap(), CurrencyCode::Csd); + assert_eq!(CurrencyCode::from_str("CSJ").unwrap(), CurrencyCode::Csj); + assert_eq!(CurrencyCode::from_str("CSK").unwrap(), CurrencyCode::Csk); + assert_eq!(CurrencyCode::from_str("CUC").unwrap(), CurrencyCode::Cuc); + assert_eq!(CurrencyCode::from_str("CUP").unwrap(), CurrencyCode::Cup); + assert_eq!(CurrencyCode::from_str("CVE").unwrap(), CurrencyCode::Cve); + assert_eq!(CurrencyCode::from_str("CYP").unwrap(), CurrencyCode::Cyp); + assert_eq!(CurrencyCode::from_str("CZK").unwrap(), CurrencyCode::Czk); + assert_eq!(CurrencyCode::from_str("DDM").unwrap(), CurrencyCode::Ddm); + assert_eq!(CurrencyCode::from_str("DEM").unwrap(), CurrencyCode::Dem); + assert_eq!(CurrencyCode::from_str("DJF").unwrap(), CurrencyCode::Djf); + assert_eq!(CurrencyCode::from_str("DKK").unwrap(), CurrencyCode::Dkk); + assert_eq!(CurrencyCode::from_str("DOP").unwrap(), CurrencyCode::Dop); + assert_eq!(CurrencyCode::from_str("DZD").unwrap(), CurrencyCode::Dzd); + assert_eq!(CurrencyCode::from_str("ECS").unwrap(), CurrencyCode::Ecs); + assert_eq!(CurrencyCode::from_str("ECV").unwrap(), CurrencyCode::Ecv); + assert_eq!(CurrencyCode::from_str("EEK").unwrap(), CurrencyCode::Eek); + assert_eq!(CurrencyCode::from_str("EGP").unwrap(), CurrencyCode::Egp); + assert_eq!(CurrencyCode::from_str("ERN").unwrap(), CurrencyCode::Ern); + assert_eq!(CurrencyCode::from_str("ESA").unwrap(), CurrencyCode::Esa); + assert_eq!(CurrencyCode::from_str("ESB").unwrap(), CurrencyCode::Esb); + assert_eq!(CurrencyCode::from_str("ESP").unwrap(), CurrencyCode::Esp); + assert_eq!(CurrencyCode::from_str("ETB").unwrap(), CurrencyCode::Etb); + assert_eq!(CurrencyCode::from_str("EUR").unwrap(), CurrencyCode::Eur); + assert_eq!(CurrencyCode::from_str("FIM").unwrap(), CurrencyCode::Fim); + assert_eq!(CurrencyCode::from_str("FJD").unwrap(), CurrencyCode::Fjd); + assert_eq!(CurrencyCode::from_str("FKP").unwrap(), CurrencyCode::Fkp); + assert_eq!(CurrencyCode::from_str("FRF").unwrap(), CurrencyCode::Frf); + assert_eq!(CurrencyCode::from_str("GBP").unwrap(), CurrencyCode::Gbp); + assert_eq!(CurrencyCode::from_str("GEK").unwrap(), CurrencyCode::Gek); + assert_eq!(CurrencyCode::from_str("GEL").unwrap(), CurrencyCode::Gel); + assert_eq!(CurrencyCode::from_str("GHC").unwrap(), CurrencyCode::Ghc); + assert_eq!(CurrencyCode::from_str("GHP").unwrap(), CurrencyCode::Ghp); + assert_eq!(CurrencyCode::from_str("GHS").unwrap(), CurrencyCode::Ghs); + assert_eq!(CurrencyCode::from_str("GIP").unwrap(), CurrencyCode::Gip); + assert_eq!(CurrencyCode::from_str("GMD").unwrap(), CurrencyCode::Gmd); + assert_eq!(CurrencyCode::from_str("GNE").unwrap(), CurrencyCode::Gne); + assert_eq!(CurrencyCode::from_str("GNF").unwrap(), CurrencyCode::Gnf); + assert_eq!(CurrencyCode::from_str("GNS").unwrap(), CurrencyCode::Gns); + assert_eq!(CurrencyCode::from_str("GQE").unwrap(), CurrencyCode::Gqe); + assert_eq!(CurrencyCode::from_str("GRD").unwrap(), CurrencyCode::Grd); + assert_eq!(CurrencyCode::from_str("GTQ").unwrap(), CurrencyCode::Gtq); + assert_eq!(CurrencyCode::from_str("GWE").unwrap(), CurrencyCode::Gwe); + assert_eq!(CurrencyCode::from_str("GWP").unwrap(), CurrencyCode::Gwp); + assert_eq!(CurrencyCode::from_str("GYD").unwrap(), CurrencyCode::Gyd); + assert_eq!(CurrencyCode::from_str("HKD").unwrap(), CurrencyCode::Hkd); + assert_eq!(CurrencyCode::from_str("HNL").unwrap(), CurrencyCode::Hnl); + assert_eq!(CurrencyCode::from_str("HRD").unwrap(), CurrencyCode::Hrd); + assert_eq!(CurrencyCode::from_str("HRK").unwrap(), CurrencyCode::Hrk); + assert_eq!(CurrencyCode::from_str("HTG").unwrap(), CurrencyCode::Htg); + assert_eq!(CurrencyCode::from_str("HUF").unwrap(), CurrencyCode::Huf); + assert_eq!(CurrencyCode::from_str("IDR").unwrap(), CurrencyCode::Idr); + assert_eq!(CurrencyCode::from_str("IEP").unwrap(), CurrencyCode::Iep); + assert_eq!(CurrencyCode::from_str("ILP").unwrap(), CurrencyCode::Ilp); + assert_eq!(CurrencyCode::from_str("ILR").unwrap(), CurrencyCode::Ilr); + assert_eq!(CurrencyCode::from_str("ILS").unwrap(), CurrencyCode::Ils); + assert_eq!(CurrencyCode::from_str("INR").unwrap(), CurrencyCode::Inr); + assert_eq!(CurrencyCode::from_str("IQD").unwrap(), CurrencyCode::Iqd); + assert_eq!(CurrencyCode::from_str("IRR").unwrap(), CurrencyCode::Irr); + assert_eq!(CurrencyCode::from_str("ISJ").unwrap(), CurrencyCode::Isj); + assert_eq!(CurrencyCode::from_str("ISK").unwrap(), CurrencyCode::Isk); + assert_eq!(CurrencyCode::from_str("ITL").unwrap(), CurrencyCode::Itl); + assert_eq!(CurrencyCode::from_str("JMD").unwrap(), CurrencyCode::Jmd); + assert_eq!(CurrencyCode::from_str("JOD").unwrap(), CurrencyCode::Jod); + assert_eq!(CurrencyCode::from_str("JPY").unwrap(), CurrencyCode::Jpy); + assert_eq!(CurrencyCode::from_str("KES").unwrap(), CurrencyCode::Kes); + assert_eq!(CurrencyCode::from_str("KGS").unwrap(), CurrencyCode::Kgs); + assert_eq!(CurrencyCode::from_str("KHR").unwrap(), CurrencyCode::Khr); + assert_eq!(CurrencyCode::from_str("KMF").unwrap(), CurrencyCode::Kmf); + assert_eq!(CurrencyCode::from_str("KPW").unwrap(), CurrencyCode::Kpw); + assert_eq!(CurrencyCode::from_str("KRW").unwrap(), CurrencyCode::Krw); + assert_eq!(CurrencyCode::from_str("KWD").unwrap(), CurrencyCode::Kwd); + assert_eq!(CurrencyCode::from_str("KYD").unwrap(), CurrencyCode::Kyd); + assert_eq!(CurrencyCode::from_str("KZT").unwrap(), CurrencyCode::Kzt); + assert_eq!(CurrencyCode::from_str("LAJ").unwrap(), CurrencyCode::Laj); + assert_eq!(CurrencyCode::from_str("LAK").unwrap(), CurrencyCode::Lak); + assert_eq!(CurrencyCode::from_str("LBP").unwrap(), CurrencyCode::Lbp); + assert_eq!(CurrencyCode::from_str("LKR").unwrap(), CurrencyCode::Lkr); + assert_eq!(CurrencyCode::from_str("LRD").unwrap(), CurrencyCode::Lrd); + assert_eq!(CurrencyCode::from_str("LSL").unwrap(), CurrencyCode::Lsl); + assert_eq!(CurrencyCode::from_str("LSM").unwrap(), CurrencyCode::Lsm); + assert_eq!(CurrencyCode::from_str("LTL").unwrap(), CurrencyCode::Ltl); + assert_eq!(CurrencyCode::from_str("LTT").unwrap(), CurrencyCode::Ltt); + assert_eq!(CurrencyCode::from_str("LUC").unwrap(), CurrencyCode::Luc); + assert_eq!(CurrencyCode::from_str("LUF").unwrap(), CurrencyCode::Luf); + assert_eq!(CurrencyCode::from_str("LUL").unwrap(), CurrencyCode::Lul); + assert_eq!(CurrencyCode::from_str("LVL").unwrap(), CurrencyCode::Lvl); + assert_eq!(CurrencyCode::from_str("LVR").unwrap(), CurrencyCode::Lvr); + assert_eq!(CurrencyCode::from_str("LYD").unwrap(), CurrencyCode::Lyd); + assert_eq!(CurrencyCode::from_str("MAD").unwrap(), CurrencyCode::Mad); + assert_eq!(CurrencyCode::from_str("MDL").unwrap(), CurrencyCode::Mdl); + assert_eq!(CurrencyCode::from_str("MGA").unwrap(), CurrencyCode::Mga); + assert_eq!(CurrencyCode::from_str("MGF").unwrap(), CurrencyCode::Mgf); + assert_eq!(CurrencyCode::from_str("MKD").unwrap(), CurrencyCode::Mkd); + assert_eq!(CurrencyCode::from_str("MLF").unwrap(), CurrencyCode::Mlf); + assert_eq!(CurrencyCode::from_str("MMK").unwrap(), CurrencyCode::Mmk); + assert_eq!(CurrencyCode::from_str("MNT").unwrap(), CurrencyCode::Mnt); + assert_eq!(CurrencyCode::from_str("MOP").unwrap(), CurrencyCode::Mop); + assert_eq!(CurrencyCode::from_str("MRO").unwrap(), CurrencyCode::Mro); + assert_eq!(CurrencyCode::from_str("MRU").unwrap(), CurrencyCode::Mru); + assert_eq!(CurrencyCode::from_str("MTL").unwrap(), CurrencyCode::Mtl); + assert_eq!(CurrencyCode::from_str("MTP").unwrap(), CurrencyCode::Mtp); + assert_eq!(CurrencyCode::from_str("MUR").unwrap(), CurrencyCode::Mur); + assert_eq!(CurrencyCode::from_str("MVQ").unwrap(), CurrencyCode::Mvq); + assert_eq!(CurrencyCode::from_str("MVR").unwrap(), CurrencyCode::Mvr); + assert_eq!(CurrencyCode::from_str("MWK").unwrap(), CurrencyCode::Mwk); + assert_eq!(CurrencyCode::from_str("MXN").unwrap(), CurrencyCode::Mxn); + assert_eq!(CurrencyCode::from_str("MXP").unwrap(), CurrencyCode::Mxp); + assert_eq!(CurrencyCode::from_str("MXV").unwrap(), CurrencyCode::Mxv); + assert_eq!(CurrencyCode::from_str("MYR").unwrap(), CurrencyCode::Myr); + assert_eq!(CurrencyCode::from_str("MZE").unwrap(), CurrencyCode::Mze); + assert_eq!(CurrencyCode::from_str("MZM").unwrap(), CurrencyCode::Mzm); + assert_eq!(CurrencyCode::from_str("MZN").unwrap(), CurrencyCode::Mzn); + assert_eq!(CurrencyCode::from_str("NAD").unwrap(), CurrencyCode::Nad); + assert_eq!(CurrencyCode::from_str("NGN").unwrap(), CurrencyCode::Ngn); + assert_eq!(CurrencyCode::from_str("NIC").unwrap(), CurrencyCode::Nic); + assert_eq!(CurrencyCode::from_str("NIO").unwrap(), CurrencyCode::Nio); + assert_eq!(CurrencyCode::from_str("NLG").unwrap(), CurrencyCode::Nlg); + assert_eq!(CurrencyCode::from_str("NOK").unwrap(), CurrencyCode::Nok); + assert_eq!(CurrencyCode::from_str("NPR").unwrap(), CurrencyCode::Npr); + assert_eq!(CurrencyCode::from_str("NZD").unwrap(), CurrencyCode::Nzd); + assert_eq!(CurrencyCode::from_str("OMR").unwrap(), CurrencyCode::Omr); + assert_eq!(CurrencyCode::from_str("PAB").unwrap(), CurrencyCode::Pab); + assert_eq!(CurrencyCode::from_str("PEH").unwrap(), CurrencyCode::Peh); + assert_eq!(CurrencyCode::from_str("PEI").unwrap(), CurrencyCode::Pei); + assert_eq!(CurrencyCode::from_str("PEN").unwrap(), CurrencyCode::Pen); + assert_eq!(CurrencyCode::from_str("PES").unwrap(), CurrencyCode::Pes); + assert_eq!(CurrencyCode::from_str("PGK").unwrap(), CurrencyCode::Pgk); + assert_eq!(CurrencyCode::from_str("PHP").unwrap(), CurrencyCode::Php); + assert_eq!(CurrencyCode::from_str("PKR").unwrap(), CurrencyCode::Pkr); + assert_eq!(CurrencyCode::from_str("PLN").unwrap(), CurrencyCode::Pln); + assert_eq!(CurrencyCode::from_str("PLZ").unwrap(), CurrencyCode::Plz); + assert_eq!(CurrencyCode::from_str("PTE").unwrap(), CurrencyCode::Pte); + assert_eq!(CurrencyCode::from_str("PYG").unwrap(), CurrencyCode::Pyg); + assert_eq!(CurrencyCode::from_str("QAR").unwrap(), CurrencyCode::Qar); + assert_eq!(CurrencyCode::from_str("RHD").unwrap(), CurrencyCode::Rhd); + assert_eq!(CurrencyCode::from_str("ROK").unwrap(), CurrencyCode::Rok); + assert_eq!(CurrencyCode::from_str("ROL").unwrap(), CurrencyCode::Rol); + assert_eq!(CurrencyCode::from_str("RON").unwrap(), CurrencyCode::Ron); + assert_eq!(CurrencyCode::from_str("RSD").unwrap(), CurrencyCode::Rsd); + assert_eq!(CurrencyCode::from_str("RUB").unwrap(), CurrencyCode::Rub); + assert_eq!(CurrencyCode::from_str("RUR").unwrap(), CurrencyCode::Rur); + assert_eq!(CurrencyCode::from_str("RWF").unwrap(), CurrencyCode::Rwf); + assert_eq!(CurrencyCode::from_str("SAR").unwrap(), CurrencyCode::Sar); + assert_eq!(CurrencyCode::from_str("SBD").unwrap(), CurrencyCode::Sbd); + assert_eq!(CurrencyCode::from_str("SCR").unwrap(), CurrencyCode::Scr); + assert_eq!(CurrencyCode::from_str("SDD").unwrap(), CurrencyCode::Sdd); + assert_eq!(CurrencyCode::from_str("SDG").unwrap(), CurrencyCode::Sdg); + assert_eq!(CurrencyCode::from_str("SDP").unwrap(), CurrencyCode::Sdp); + assert_eq!(CurrencyCode::from_str("SEK").unwrap(), CurrencyCode::Sek); + assert_eq!(CurrencyCode::from_str("SGD").unwrap(), CurrencyCode::Sgd); + assert_eq!(CurrencyCode::from_str("SHP").unwrap(), CurrencyCode::Shp); + assert_eq!(CurrencyCode::from_str("SIT").unwrap(), CurrencyCode::Sit); + assert_eq!(CurrencyCode::from_str("SKK").unwrap(), CurrencyCode::Skk); + assert_eq!(CurrencyCode::from_str("SLL").unwrap(), CurrencyCode::Sll); + assert_eq!(CurrencyCode::from_str("SOS").unwrap(), CurrencyCode::Sos); + assert_eq!(CurrencyCode::from_str("SRD").unwrap(), CurrencyCode::Srd); + assert_eq!(CurrencyCode::from_str("SRG").unwrap(), CurrencyCode::Srg); + assert_eq!(CurrencyCode::from_str("SSP").unwrap(), CurrencyCode::Ssp); + assert_eq!(CurrencyCode::from_str("STD").unwrap(), CurrencyCode::Std); + assert_eq!(CurrencyCode::from_str("STN").unwrap(), CurrencyCode::Stn); + assert_eq!(CurrencyCode::from_str("SUR").unwrap(), CurrencyCode::Sur); + assert_eq!(CurrencyCode::from_str("SVC").unwrap(), CurrencyCode::Svc); + assert_eq!(CurrencyCode::from_str("SYP").unwrap(), CurrencyCode::Syp); + assert_eq!(CurrencyCode::from_str("SZL").unwrap(), CurrencyCode::Szl); + assert_eq!(CurrencyCode::from_str("THB").unwrap(), CurrencyCode::Thb); + assert_eq!(CurrencyCode::from_str("TJR").unwrap(), CurrencyCode::Tjr); + assert_eq!(CurrencyCode::from_str("TJS").unwrap(), CurrencyCode::Tjs); + assert_eq!(CurrencyCode::from_str("TMM").unwrap(), CurrencyCode::Tmm); + assert_eq!(CurrencyCode::from_str("TMT").unwrap(), CurrencyCode::Tmt); + assert_eq!(CurrencyCode::from_str("TND").unwrap(), CurrencyCode::Tnd); + assert_eq!(CurrencyCode::from_str("TOP").unwrap(), CurrencyCode::Top); + assert_eq!(CurrencyCode::from_str("TPE").unwrap(), CurrencyCode::Tpe); + assert_eq!(CurrencyCode::from_str("TRL").unwrap(), CurrencyCode::Trl); + assert_eq!(CurrencyCode::from_str("TRY").unwrap(), CurrencyCode::Try); + assert_eq!(CurrencyCode::from_str("TTD").unwrap(), CurrencyCode::Ttd); + assert_eq!(CurrencyCode::from_str("TWD").unwrap(), CurrencyCode::Twd); + assert_eq!(CurrencyCode::from_str("TZS").unwrap(), CurrencyCode::Tzs); + assert_eq!(CurrencyCode::from_str("UAH").unwrap(), CurrencyCode::Uah); + assert_eq!(CurrencyCode::from_str("UAK").unwrap(), CurrencyCode::Uak); + assert_eq!(CurrencyCode::from_str("UGS").unwrap(), CurrencyCode::Ugs); + assert_eq!(CurrencyCode::from_str("UGW").unwrap(), CurrencyCode::Ugw); + assert_eq!(CurrencyCode::from_str("UGX").unwrap(), CurrencyCode::Ugx); + assert_eq!(CurrencyCode::from_str("USD").unwrap(), CurrencyCode::Usd); + assert_eq!(CurrencyCode::from_str("USN").unwrap(), CurrencyCode::Usn); + assert_eq!(CurrencyCode::from_str("USS").unwrap(), CurrencyCode::Uss); + assert_eq!(CurrencyCode::from_str("UYI").unwrap(), CurrencyCode::Uyi); + assert_eq!(CurrencyCode::from_str("UYN").unwrap(), CurrencyCode::Uyn); + assert_eq!(CurrencyCode::from_str("UYP").unwrap(), CurrencyCode::Uyp); + assert_eq!(CurrencyCode::from_str("UYU").unwrap(), CurrencyCode::Uyu); + assert_eq!(CurrencyCode::from_str("UYW").unwrap(), CurrencyCode::Uyw); + assert_eq!(CurrencyCode::from_str("UZS").unwrap(), CurrencyCode::Uzs); + assert_eq!(CurrencyCode::from_str("VEB").unwrap(), CurrencyCode::Veb); + assert_eq!(CurrencyCode::from_str("VEF").unwrap(), CurrencyCode::Vef); + assert_eq!(CurrencyCode::from_str("VES").unwrap(), CurrencyCode::Ves); + assert_eq!(CurrencyCode::from_str("VNC").unwrap(), CurrencyCode::Vnc); + assert_eq!(CurrencyCode::from_str("VND").unwrap(), CurrencyCode::Vnd); + assert_eq!(CurrencyCode::from_str("VUV").unwrap(), CurrencyCode::Vuv); + assert_eq!(CurrencyCode::from_str("WST").unwrap(), CurrencyCode::Wst); + assert_eq!(CurrencyCode::from_str("XAF").unwrap(), CurrencyCode::Xaf); + assert_eq!(CurrencyCode::from_str("XAG").unwrap(), CurrencyCode::Xag); + assert_eq!(CurrencyCode::from_str("XAU").unwrap(), CurrencyCode::Xau); + assert_eq!(CurrencyCode::from_str("XBA").unwrap(), CurrencyCode::Xba); + assert_eq!(CurrencyCode::from_str("XBB").unwrap(), CurrencyCode::Xbb); + assert_eq!(CurrencyCode::from_str("XBC").unwrap(), CurrencyCode::Xbc); + assert_eq!(CurrencyCode::from_str("XBD").unwrap(), CurrencyCode::Xbd); + assert_eq!(CurrencyCode::from_str("XCD").unwrap(), CurrencyCode::Xcd); + assert_eq!(CurrencyCode::from_str("XDR").unwrap(), CurrencyCode::Xdr); + assert_eq!(CurrencyCode::from_str("XEU").unwrap(), CurrencyCode::Xeu); + assert_eq!(CurrencyCode::from_str("XFO").unwrap(), CurrencyCode::Xfo); + assert_eq!(CurrencyCode::from_str("XFU").unwrap(), CurrencyCode::Xfu); + assert_eq!(CurrencyCode::from_str("XOF").unwrap(), CurrencyCode::Xof); + assert_eq!(CurrencyCode::from_str("XPD").unwrap(), CurrencyCode::Xpd); + assert_eq!(CurrencyCode::from_str("XPF").unwrap(), CurrencyCode::Xpf); + assert_eq!(CurrencyCode::from_str("XPT").unwrap(), CurrencyCode::Xpt); + assert_eq!(CurrencyCode::from_str("XRE").unwrap(), CurrencyCode::Xre); + assert_eq!(CurrencyCode::from_str("XSU").unwrap(), CurrencyCode::Xsu); + assert_eq!(CurrencyCode::from_str("XTS").unwrap(), CurrencyCode::Xts); + assert_eq!(CurrencyCode::from_str("XUA").unwrap(), CurrencyCode::Xua); + assert_eq!(CurrencyCode::from_str("XXX").unwrap(), CurrencyCode::Xxx); + assert_eq!(CurrencyCode::from_str("YDD").unwrap(), CurrencyCode::Ydd); + assert_eq!(CurrencyCode::from_str("YER").unwrap(), CurrencyCode::Yer); + assert_eq!(CurrencyCode::from_str("YUD").unwrap(), CurrencyCode::Yud); + assert_eq!(CurrencyCode::from_str("YUM").unwrap(), CurrencyCode::Yum); + assert_eq!(CurrencyCode::from_str("YUN").unwrap(), CurrencyCode::Yun); + assert_eq!(CurrencyCode::from_str("ZAL").unwrap(), CurrencyCode::Zal); + assert_eq!(CurrencyCode::from_str("ZAR").unwrap(), CurrencyCode::Zar); + assert_eq!(CurrencyCode::from_str("ZMK").unwrap(), CurrencyCode::Zmk); + assert_eq!(CurrencyCode::from_str("ZMW").unwrap(), CurrencyCode::Zmw); + assert_eq!(CurrencyCode::from_str("ZRN").unwrap(), CurrencyCode::Zrn); + assert_eq!(CurrencyCode::from_str("ZRZ").unwrap(), CurrencyCode::Zrz); + assert_eq!(CurrencyCode::from_str("ZWC").unwrap(), CurrencyCode::Zwc); + assert_eq!(CurrencyCode::from_str("ZWD").unwrap(), CurrencyCode::Zwd); + assert_eq!(CurrencyCode::from_str("ZWL").unwrap(), CurrencyCode::Zwl); + assert_eq!(CurrencyCode::from_str("ZWN").unwrap(), CurrencyCode::Zwn); + assert_eq!(CurrencyCode::from_str("ZWR").unwrap(), CurrencyCode::Zwr); + } +} + +#[cfg(feature = "backend")] +mod conversions { + use super::*; + use crate::model::tests::db::setup_test_db; + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn currencycode_graphql_roundtrip() { + assert_graphql_enum_roundtrip(CurrencyCode::Gbp); + } + + #[test] + fn currencycode_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<CurrencyCode, crate::schema::sql_types::CurrencyCode>( + pool.as_ref(), + "'gbp'::currency_code", + CurrencyCode::Gbp, + ); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let price: Price = Default::default(); + assert_eq!(price.pk(), price.price_id); + } + + #[test] + fn history_entry_serializes_model() { + let price: Price = Default::default(); + let user_id = "123456".to_string(); + let new_price_history = price.new_history_entry(&user_id); + assert_eq!(new_price_history.price_id, price.price_id); + assert_eq!(new_price_history.user_id, user_id); + assert_eq!( + new_price_history.data, + serde_json::Value::String(serde_json::to_string(&price).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::price::policy::PricePolicy; + use crate::model::tests::db::{ + create_imprint, create_publication, create_publisher, create_work, setup_test_db, + test_context_with_user, test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("price-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + let new_price = NewPrice { + publication_id: publication.publication_id, + currency_code: CurrencyCode::Usd, + unit_price: 9.99, + }; + + let price = Price::create(pool.as_ref(), &new_price).expect("Failed to create"); + let patch = PatchPrice { + price_id: price.price_id, + publication_id: price.publication_id, + currency_code: price.currency_code, + unit_price: 14.5, + }; + + assert!(PricePolicy::can_create(&ctx, &new_price, ()).is_ok()); + assert!(PricePolicy::can_update(&ctx, &price, &patch, ()).is_ok()); + assert!(PricePolicy::can_delete(&ctx, &price).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + let price = make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Usd, + 9.99, + ); + let patch = PatchPrice { + price_id: price.price_id, + publication_id: price.publication_id, + currency_code: price.currency_code, + unit_price: 14.5, + }; + + let user = test_user_with_role("price-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_price = NewPrice { + publication_id: publication.publication_id, + currency_code: CurrencyCode::Usd, + unit_price: 9.99, + }; + + assert!(PricePolicy::can_create(&ctx, &new_price, ()).is_err()); + assert!(PricePolicy::can_update(&ctx, &price, &patch, ()).is_err()); + assert!(PricePolicy::can_delete(&ctx, &price).is_err()); + } + + #[test] + fn crud_policy_rejects_zero_unit_price() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("price-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + let new_price = NewPrice { + publication_id: publication.publication_id, + currency_code: CurrencyCode::Usd, + unit_price: 0.0, + }; + + assert!(PricePolicy::can_create(&ctx, &new_price, ()).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::graphql::types::inputs::{Direction, PriceOrderBy}; + use crate::model::tests::db::{ + create_imprint, create_publication, create_publisher, create_work, setup_test_db, + test_context, + }; + use crate::model::Crud; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let new_price = NewPrice { + publication_id: publication.publication_id, + currency_code: CurrencyCode::Usd, + unit_price: 9.99, + }; + + let price = Price::create(pool.as_ref(), &new_price).expect("Failed to create"); + let fetched = Price::from_id(pool.as_ref(), &price.price_id).expect("Failed to fetch"); + assert_eq!(price.price_id, fetched.price_id); + + let patch = PatchPrice { + price_id: price.price_id, + publication_id: price.publication_id, + currency_code: price.currency_code, + unit_price: 14.5, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = price.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.unit_price, patch.unit_price); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Price::from_id(pool.as_ref(), &deleted.price_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Usd, + 9.99, + ); + make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Gbp, + 12.99, + ); + + let order = PriceOrderBy { + field: PriceField::PriceId, + direction: Direction::Asc, + }; + + let first = Price::all( + pool.as_ref(), + 1, + 0, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch prices"); + let second = Price::all( + pool.as_ref(), + 1, + 1, + None, + PriceOrderBy { + field: PriceField::PriceId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch prices"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].price_id, second[0].price_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Usd, + 9.99, + ); + make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Gbp, + 12.99, + ); + + let count = Price::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count prices"); + assert_eq!(count, 2); + } + + #[test] + fn crud_count_filters_by_currency_code() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Usd, + 9.99, + ); + make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Gbp, + 12.99, + ); + + let count = Price::count( + pool.as_ref(), + None, + vec![], + vec![CurrencyCode::Usd], + vec![], + None, + None, + ) + .expect("Failed to count prices by currency"); + assert_eq!(count, 1); + } + + #[test] + fn crud_filter_param_limits_currency_codes() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let matches = make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Usd, + 9.99, + ); + make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Gbp, + 12.99, + ); + + let filtered = Price::all( + pool.as_ref(), + 10, + 0, + None, + PriceOrderBy { + field: PriceField::PriceId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![CurrencyCode::Usd], + vec![], + None, + None, + ) + .expect("Failed to filter prices by currency"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].price_id, matches.price_id); + } + + #[test] + fn crud_filter_parent_publication_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + let other_work = create_work(pool.as_ref(), &imprint); + let other_publication = create_publication(pool.as_ref(), &other_work); + + let matches = make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Usd, + 9.99, + ); + make_price( + pool.as_ref(), + other_publication.publication_id, + CurrencyCode::Gbp, + 12.99, + ); + + let filtered = Price::all( + pool.as_ref(), + 10, + 0, + None, + PriceOrderBy { + field: PriceField::PriceId, + direction: Direction::Asc, + }, + vec![], + Some(publication.publication_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter prices by publication"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].price_id, matches.price_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + let matches = make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Usd, + 9.99, + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let other_work = create_work(pool.as_ref(), &other_imprint); + let other_publication = create_publication(pool.as_ref(), &other_work); + make_price( + pool.as_ref(), + other_publication.publication_id, + CurrencyCode::Gbp, + 12.99, + ); + + let filtered = Price::all( + pool.as_ref(), + 10, + 0, + None, + PriceOrderBy { + field: PriceField::PriceId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter prices by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].price_id, matches.price_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let first = make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Usd, + 9.99, + ); + let second = make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Gbp, + 12.99, + ); + let mut ids = [first.price_id, second.price_id]; + ids.sort(); + + let asc = Price::all( + pool.as_ref(), + 2, + 0, + None, + PriceOrderBy { + field: PriceField::PriceId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order prices (asc)"); + + let desc = Price::all( + pool.as_ref(), + 2, + 0, + None, + PriceOrderBy { + field: PriceField::PriceId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order prices (desc)"); + + assert_eq!(asc[0].price_id, ids[0]); + assert_eq!(desc[0].price_id, ids[1]); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Usd, + 9.99, + ); + make_price( + pool.as_ref(), + publication.publication_id, + CurrencyCode::Gbp, + 12.99, + ); + + let fields: Vec<fn() -> PriceField> = vec![ + || PriceField::PriceId, + || PriceField::PublicationId, + || PriceField::CurrencyCode, + || PriceField::UnitPrice, + || PriceField::CreatedAt, + || PriceField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Price::all( + pool.as_ref(), + 10, + 0, + None, + PriceOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order prices"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/publication/crud.rs b/thoth-api/src/model/publication/crud.rs index cfde44e2..8907d4fe 100644 --- a/thoth-api/src/model/publication/crud.rs +++ b/thoth-api/src/model/publication/crud.rs @@ -2,10 +2,8 @@ use super::{ NewPublication, NewPublicationHistory, PatchPublication, Publication, PublicationField, PublicationHistory, PublicationOrderBy, PublicationType, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry, Isbn}; +use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{publication, publication_history}; -use crate::{crud_methods, db_insert}; use diesel::{ dsl::sql, sql_types::Text, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, }; @@ -19,6 +17,7 @@ impl Crud for Publication { type FilterParameter1 = PublicationType; type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.publication_id @@ -36,6 +35,7 @@ impl Crud for Publication { publication_types: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Publication>> { use crate::schema::publication::dsl::*; let mut connection = db.get()?; @@ -45,62 +45,61 @@ impl Crud for Publication { .into_boxed(); query = match order.field { - PublicationField::PublicationId => match order.direction { - Direction::Asc => query.order(publication_id.asc()), - Direction::Desc => query.order(publication_id.desc()), - }, - PublicationField::PublicationType => match order.direction { - Direction::Asc => query.order(publication_type.asc()), - Direction::Desc => query.order(publication_type.desc()), - }, - PublicationField::WorkId => match order.direction { - Direction::Asc => query.order(work_id.asc()), - Direction::Desc => query.order(work_id.desc()), - }, - PublicationField::Isbn => match order.direction { - Direction::Asc => query.order(isbn.asc()), - Direction::Desc => query.order(isbn.desc()), - }, - PublicationField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - PublicationField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, - PublicationField::WidthMm => match order.direction { - Direction::Asc => query.order(width_mm.asc()), - Direction::Desc => query.order(width_mm.desc()), - }, - PublicationField::WidthIn => match order.direction { - Direction::Asc => query.order(width_in.asc()), - Direction::Desc => query.order(width_in.desc()), - }, - PublicationField::HeightMm => match order.direction { - Direction::Asc => query.order(height_mm.asc()), - Direction::Desc => query.order(height_mm.desc()), - }, - PublicationField::HeightIn => match order.direction { - Direction::Asc => query.order(height_in.asc()), - Direction::Desc => query.order(height_in.desc()), - }, - PublicationField::DepthMm => match order.direction { - Direction::Asc => query.order(depth_mm.asc()), - Direction::Desc => query.order(depth_mm.desc()), - }, - PublicationField::DepthIn => match order.direction { - Direction::Asc => query.order(depth_in.asc()), - Direction::Desc => query.order(depth_in.desc()), - }, - PublicationField::WeightG => match order.direction { - Direction::Asc => query.order(weight_g.asc()), - Direction::Desc => query.order(weight_g.desc()), - }, - PublicationField::WeightOz => match order.direction { - Direction::Asc => query.order(weight_oz.asc()), - Direction::Desc => query.order(weight_oz.desc()), - }, + PublicationField::PublicationId => { + apply_directional_order!(query, order.direction, order, publication_id) + } + PublicationField::PublicationType => { + apply_directional_order!(query, order.direction, order, publication_type) + } + PublicationField::WorkId => { + apply_directional_order!(query, order.direction, order, work_id) + } + PublicationField::Isbn => apply_directional_order!(query, order.direction, order, isbn), + PublicationField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + PublicationField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } + PublicationField::WidthMm => { + apply_directional_order!(query, order.direction, order, width_mm) + } + PublicationField::WidthIn => { + apply_directional_order!(query, order.direction, order, width_in) + } + PublicationField::HeightMm => { + apply_directional_order!(query, order.direction, order, height_mm) + } + PublicationField::HeightIn => { + apply_directional_order!(query, order.direction, order, height_in) + } + PublicationField::DepthMm => { + apply_directional_order!(query, order.direction, order, depth_mm) + } + PublicationField::DepthIn => { + apply_directional_order!(query, order.direction, order, depth_in) + } + PublicationField::WeightG => { + apply_directional_order!(query, order.direction, order, weight_g) + } + PublicationField::WeightOz => { + apply_directional_order!(query, order.direction, order, weight_oz) + } + PublicationField::AccessibilityStandard => { + apply_directional_order!(query, order.direction, order, accessibility_standard) + } + PublicationField::AccessibilityAdditionalStandard => apply_directional_order!( + query, + order.direction, + order, + accessibility_additional_standard + ), + PublicationField::AccessibilityException => { + apply_directional_order!(query, order.direction, order, accessibility_exception) + } + PublicationField::AccessibilityReportUrl => { + apply_directional_order!(query, order.direction, order, accessibility_report_url) + } }; if !publishers.is_empty() { query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); @@ -135,6 +134,7 @@ impl Crud for Publication { publication_types: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::publication::dsl::*; let mut connection = db.get()?; @@ -169,20 +169,20 @@ impl Crud for Publication { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } - crud_methods!(publication::table, publication::dsl::publication); } +publisher_id_impls!(Publication, NewPublication, PatchPublication, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Publication { type NewHistoryEntity = NewPublicationHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { publication_id: self.publication_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -193,30 +193,3 @@ impl DbInsert for NewPublicationHistory { db_insert!(publication_history::table); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_publication_pk() { - let publication: Publication = Default::default(); - assert_eq!(publication.pk(), publication.publication_id); - } - - #[test] - fn test_new_publication_history_from_publication() { - let publication: Publication = Default::default(); - let account_id: Uuid = Default::default(); - let new_publication_history = publication.new_history_entry(&account_id); - assert_eq!( - new_publication_history.publication_id, - publication.publication_id - ); - assert_eq!(new_publication_history.account_id, account_id); - assert_eq!( - new_publication_history.data, - serde_json::Value::String(serde_json::to_string(&publication).unwrap()) - ); - } -} diff --git a/thoth-api/src/model/publication/mod.rs b/thoth-api/src/model/publication/mod.rs index 7b7e2013..848ac2af 100644 --- a/thoth-api/src/model/publication/mod.rs +++ b/thoth-api/src/model/publication/mod.rs @@ -4,10 +4,7 @@ use strum::EnumString; use thoth_errors::{ThothError, ThothResult}; use uuid::Uuid; -use crate::graphql::utils::Direction; -use crate::model::location::Location; -use crate::model::price::Price; -use crate::model::work::WorkWithRelations; +use crate::graphql::types::inputs::Direction; use crate::model::Isbn; use crate::model::Timestamp; #[cfg(feature = "backend")] @@ -17,7 +14,7 @@ use crate::schema::publication_history; #[cfg_attr( feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), graphql(description = "Format of a publication"), ExistingTypePath = "crate::schema::sql_types::PublicationType" )] @@ -108,6 +105,114 @@ pub enum PublicationType { Wav, } +#[cfg_attr( + feature = "backend", + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), + graphql( + description = "Standardised specification for accessibility to which a publication may conform" + ), + ExistingTypePath = "crate::schema::sql_types::AccessibilityStandard" +)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize, EnumString, Display)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AccessibilityStandard { + #[cfg_attr( + feature = "backend", + db_rename = "wcag-21-aa", + graphql(description = "WCAG 2.1 AA") + )] + Wcag21aa, + #[cfg_attr( + feature = "backend", + db_rename = "wcag-21-aaa", + graphql(description = "WCAG 2.1 AAA") + )] + Wcag21aaa, + #[cfg_attr( + feature = "backend", + db_rename = "wcag-22-aa", + graphql(description = "WCAG 2.2 AA") + )] + Wcag22aa, + #[cfg_attr( + feature = "backend", + db_rename = "wcag-22-aaa", + graphql(description = "WCAG 2.2 AAA") + )] + Wcag22aaa, + #[cfg_attr( + feature = "backend", + db_rename = "epub-a11y-10-aa", + graphql(description = "EPUB Accessibility Specification 1.0 AA") + )] + EpubA11y10aa, + #[cfg_attr( + feature = "backend", + db_rename = "epub-a11y-10-aaa", + graphql(description = "EPUB Accessibility Specification 1.0 AAA") + )] + EpubA11y10aaa, + #[cfg_attr( + feature = "backend", + db_rename = "epub-a11y-11-aa", + graphql(description = "EPUB Accessibility Specification 1.1 AA") + )] + EpubA11y11aa, + #[cfg_attr( + feature = "backend", + db_rename = "epub-a11y-11-aaa", + graphql(description = "EPUB Accessibility Specification 1.1 AAA") + )] + EpubA11y11aaa, + #[cfg_attr( + feature = "backend", + db_rename = "pdf-ua-1", + graphql(description = "PDF/UA-1") + )] + PdfUa1, + #[cfg_attr( + feature = "backend", + db_rename = "pdf-ua-2", + graphql(description = "PDF/UA-2") + )] + PdfUa2, +} + +#[cfg_attr( + feature = "backend", + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), + graphql( + description = "Reason for publication not being required to comply with accessibility standards" + ), + ExistingTypePath = "crate::schema::sql_types::AccessibilityException" +)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize, EnumString, Display)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AccessibilityException { + #[cfg_attr( + feature = "backend", + db_rename = "micro-enterprises", + graphql(description = "Publisher is a micro-enterprise") + )] + MicroEnterprises, + #[cfg_attr( + feature = "backend", + db_rename = "disproportionate-burden", + graphql( + description = "Making the publication accessible would financially overburden the publisher" + ) + )] + DisproportionateBurden, + #[cfg_attr( + feature = "backend", + db_rename = "fundamental-alteration", + graphql( + description = "Making the publication accessible would fundamentally modify the nature of it" + ) + )] + FundamentalAlteration, +} + #[cfg_attr( feature = "backend", derive(juniper::GraphQLEnum), @@ -135,9 +240,13 @@ pub enum PublicationField { DepthIn, WeightG, WeightOz, + AccessibilityStandard, + AccessibilityAdditionalStandard, + AccessibilityException, + AccessibilityReportUrl, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Publication { @@ -155,32 +264,15 @@ pub struct Publication { pub depth_in: Option<f64>, pub weight_g: Option<f64>, pub weight_oz: Option<f64>, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PublicationWithRelations { - pub publication_id: Uuid, - pub publication_type: PublicationType, - pub work_id: Uuid, - pub isbn: Option<Isbn>, - pub updated_at: Timestamp, - pub width_mm: Option<f64>, - pub width_in: Option<f64>, - pub height_mm: Option<f64>, - pub height_in: Option<f64>, - pub depth_mm: Option<f64>, - pub depth_in: Option<f64>, - pub weight_g: Option<f64>, - pub weight_oz: Option<f64>, - pub prices: Option<Vec<Price>>, - pub locations: Option<Vec<Location>>, - pub work: WorkWithRelations, + pub accessibility_standard: Option<AccessibilityStandard>, + pub accessibility_additional_standard: Option<AccessibilityStandard>, + pub accessibility_exception: Option<AccessibilityException>, + pub accessibility_report_url: Option<String>, } #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new manifestation of a written text"), diesel(table_name = publication) )] @@ -196,11 +288,15 @@ pub struct NewPublication { pub depth_in: Option<f64>, pub weight_g: Option<f64>, pub weight_oz: Option<f64>, + pub accessibility_standard: Option<AccessibilityStandard>, + pub accessibility_additional_standard: Option<AccessibilityStandard>, + pub accessibility_exception: Option<AccessibilityException>, + pub accessibility_report_url: Option<String>, } #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing manifestation of a written text"), diesel(table_name = publication, treat_none_as_null = true) )] @@ -217,25 +313,29 @@ pub struct PatchPublication { pub depth_in: Option<f64>, pub weight_g: Option<f64>, pub weight_oz: Option<f64>, + pub accessibility_standard: Option<AccessibilityStandard>, + pub accessibility_additional_standard: Option<AccessibilityStandard>, + pub accessibility_exception: Option<AccessibilityException>, + pub accessibility_report_url: Option<String>, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct PublicationHistory { pub publication_history_id: Uuid, pub publication_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = publication_history) )] pub struct NewPublicationHistory { pub publication_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -380,278 +480,14 @@ macro_rules! publication_properties { }; } publication_properties!(Publication); -publication_properties!(PublicationWithRelations); publication_properties!(NewPublication); publication_properties!(PatchPublication); -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn test_publicationproperties_type() { - let mut publication: Publication = Default::default(); - for pub_type in [PublicationType::Paperback, PublicationType::Hardback] { - publication.publication_type = pub_type; - assert!(publication.is_physical()); - assert!(!publication.is_digital()); - } - for pub_type in [ - PublicationType::Azw3, - PublicationType::Docx, - PublicationType::Epub, - PublicationType::FictionBook, - PublicationType::Html, - PublicationType::Mobi, - PublicationType::Mp3, - PublicationType::Pdf, - PublicationType::Xml, - PublicationType::Wav, - ] { - publication.publication_type = pub_type; - assert!(!publication.is_physical()); - assert!(publication.is_digital()); - } - } - - #[test] - fn test_publicationproperties_width() { - let mut publication: Publication = Publication { - publication_type: PublicationType::Pdf, - width_mm: Some(100.0), - ..Default::default() - }; - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::DimensionDigitalError) - ); - publication.width_mm = None; - assert!(publication.validate_dimensions_constraints().is_ok()); - publication.width_in = Some(39.4); - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::DimensionDigitalError) - ); - publication.publication_type = PublicationType::Paperback; - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::WidthEmptyError) - ); - publication.width_in = None; - assert!(publication.validate_dimensions_constraints().is_ok()); - publication.width_mm = Some(100.0); - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::WidthEmptyError) - ); - publication.width_in = Some(39.4); - assert!(publication.validate_dimensions_constraints().is_ok()); - } - - #[test] - fn test_publicationproperties_height() { - let mut publication: Publication = Publication { - publication_type: PublicationType::Pdf, - height_mm: Some(100.0), - ..Default::default() - }; - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::DimensionDigitalError) - ); - publication.height_mm = None; - assert!(publication.validate_dimensions_constraints().is_ok()); - publication.height_in = Some(39.4); - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::DimensionDigitalError) - ); - publication.publication_type = PublicationType::Paperback; - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::HeightEmptyError) - ); - publication.height_in = None; - assert!(publication.validate_dimensions_constraints().is_ok()); - publication.height_mm = Some(100.0); - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::HeightEmptyError) - ); - publication.height_in = Some(39.4); - assert!(publication.validate_dimensions_constraints().is_ok()); - } - - #[test] - fn test_publicationproperties_depth() { - let mut publication: Publication = Publication { - publication_type: PublicationType::Pdf, - depth_mm: Some(10.0), - ..Default::default() - }; - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::DimensionDigitalError) - ); - publication.depth_mm = None; - assert!(publication.validate_dimensions_constraints().is_ok()); - publication.depth_in = Some(3.94); - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::DimensionDigitalError) - ); - publication.publication_type = PublicationType::Paperback; - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::DepthEmptyError) - ); - publication.depth_in = None; - assert!(publication.validate_dimensions_constraints().is_ok()); - publication.depth_mm = Some(10.0); - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::DepthEmptyError) - ); - publication.depth_in = Some(3.94); - assert!(publication.validate_dimensions_constraints().is_ok()); - } - - #[test] - fn test_publicationproperties_weight() { - let mut publication: Publication = Publication { - publication_type: PublicationType::Pdf, - weight_g: Some(100.0), - ..Default::default() - }; - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::DimensionDigitalError) - ); - publication.weight_g = None; - assert!(publication.validate_dimensions_constraints().is_ok()); - publication.weight_oz = Some(3.5); - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::DimensionDigitalError) - ); - publication.publication_type = PublicationType::Paperback; - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::WeightEmptyError) - ); - publication.weight_oz = None; - assert!(publication.validate_dimensions_constraints().is_ok()); - publication.weight_g = Some(100.0); - assert_eq!( - publication.validate_dimensions_constraints(), - Err(ThothError::WeightEmptyError) - ); - publication.weight_oz = Some(3.5); - assert!(publication.validate_dimensions_constraints().is_ok()); - } - - #[test] - fn test_publicationtype_default() { - let pubtype: PublicationType = Default::default(); - assert_eq!(pubtype, PublicationType::Paperback); - } - - #[test] - fn test_publicationfield_default() { - let pubfield: PublicationField = Default::default(); - assert_eq!(pubfield, PublicationField::PublicationType); - } - - #[test] - fn test_publicationtype_display() { - assert_eq!(format!("{}", PublicationType::Paperback), "Paperback"); - assert_eq!(format!("{}", PublicationType::Hardback), "Hardback"); - assert_eq!(format!("{}", PublicationType::Pdf), "PDF"); - assert_eq!(format!("{}", PublicationType::Html), "HTML"); - assert_eq!(format!("{}", PublicationType::Xml), "XML"); - assert_eq!(format!("{}", PublicationType::Epub), "Epub"); - assert_eq!(format!("{}", PublicationType::Mobi), "Mobi"); - assert_eq!(format!("{}", PublicationType::Azw3), "AZW3"); - assert_eq!(format!("{}", PublicationType::Docx), "DOCX"); - assert_eq!(format!("{}", PublicationType::FictionBook), "FictionBook"); - assert_eq!(format!("{}", PublicationType::Mp3), "MP3"); - assert_eq!(format!("{}", PublicationType::Wav), "WAV"); - } - - #[test] - fn test_publicationfield_display() { - assert_eq!(format!("{}", PublicationField::PublicationId), "ID"); - assert_eq!(format!("{}", PublicationField::PublicationType), "Type"); - assert_eq!(format!("{}", PublicationField::WorkId), "WorkID"); - assert_eq!(format!("{}", PublicationField::Isbn), "ISBN"); - assert_eq!(format!("{}", PublicationField::CreatedAt), "CreatedAt"); - assert_eq!(format!("{}", PublicationField::UpdatedAt), "UpdatedAt"); - assert_eq!(format!("{}", PublicationField::WidthMm), "WidthMm"); - assert_eq!(format!("{}", PublicationField::WidthIn), "WidthIn"); - assert_eq!(format!("{}", PublicationField::HeightMm), "HeightMm"); - assert_eq!(format!("{}", PublicationField::HeightIn), "HeightIn"); - assert_eq!(format!("{}", PublicationField::DepthMm), "DepthMm"); - assert_eq!(format!("{}", PublicationField::DepthIn), "DepthIn"); - assert_eq!(format!("{}", PublicationField::WeightG), "WeightG"); - assert_eq!(format!("{}", PublicationField::WeightOz), "WeightOz"); - } - - #[test] - fn test_publicationtype_fromstr() { - use std::str::FromStr; - for (input, expected) in [ - ("Paperback", PublicationType::Paperback), - ("Hardback", PublicationType::Hardback), - ("PDF", PublicationType::Pdf), - ("HTML", PublicationType::Html), - ("XML", PublicationType::Xml), - ("Epub", PublicationType::Epub), - ("Mobi", PublicationType::Mobi), - ("AZW3", PublicationType::Azw3), - ("DOCX", PublicationType::Docx), - ("FictionBook", PublicationType::FictionBook), - ("MP3", PublicationType::Mp3), - ("WAV", PublicationType::Wav), - ] - .iter() - { - assert_eq!(PublicationType::from_str(input).unwrap(), *expected); - } - - assert!(PublicationType::from_str("PNG").is_err()); - assert!(PublicationType::from_str("Latex").is_err()); - assert!(PublicationType::from_str("azw3").is_err()); - assert!(PublicationType::from_str("Fiction Book").is_err()); - } - - #[test] - fn test_publicationfield_fromstr() { - use std::str::FromStr; - for (input, expected) in [ - ("ID", PublicationField::PublicationId), - ("Type", PublicationField::PublicationType), - ("WorkID", PublicationField::WorkId), - ("ISBN", PublicationField::Isbn), - ("CreatedAt", PublicationField::CreatedAt), - ("UpdatedAt", PublicationField::UpdatedAt), - ("WidthMm", PublicationField::WidthMm), - ("WidthIn", PublicationField::WidthIn), - ("HeightMm", PublicationField::HeightMm), - ("HeightIn", PublicationField::HeightIn), - ("DepthMm", PublicationField::DepthMm), - ("DepthIn", PublicationField::DepthIn), - ("WeightG", PublicationField::WeightG), - ("WeightOz", PublicationField::WeightOz), - ] - .iter() - { - assert_eq!(PublicationField::from_str(input).unwrap(), *expected); - } - - assert!(PublicationField::from_str("PublicationID").is_err()); - assert!(PublicationField::from_str("Work Title").is_err()); - assert!(PublicationField::from_str("Work DOI").is_err()); - } -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::PublicationPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/publication/policy.rs b/thoth-api/src/model/publication/policy.rs new file mode 100644 index 00000000..58409b2f --- /dev/null +++ b/thoth-api/src/model/publication/policy.rs @@ -0,0 +1,50 @@ +use crate::model::{ + publication::{NewPublication, PatchPublication, Publication, PublicationProperties}, + work::{Work, WorkProperties}, + Crud, +}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy, UserAccess}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Publication`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct PublicationPolicy; + +impl CreatePolicy<NewPublication> for PublicationPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewPublication, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + data.validate(ctx.db()) + } +} + +impl UpdatePolicy<Publication, PatchPublication> for PublicationPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Publication, + patch: &PatchPublication, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + patch.validate(ctx.db()) + } +} + +impl DeletePolicy<Publication> for PublicationPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Publication) -> ThothResult<()> { + let user = ctx.require_publisher_for(current)?; + let work = Work::from_id(ctx.db(), ¤t.work_id)?; + if work.is_published() && !user.is_superuser() { + return Err(ThothError::ThothDeletePublicationError); + } + Ok(()) + } +} diff --git a/thoth-api/src/model/publication/tests.rs b/thoth-api/src/model/publication/tests.rs new file mode 100644 index 00000000..14417d3d --- /dev/null +++ b/thoth-api/src/model/publication/tests.rs @@ -0,0 +1,1298 @@ +use super::*; + +mod defaults { + use super::*; + + #[test] + fn publicationtype_default_is_paperback() { + let pubtype: PublicationType = Default::default(); + assert_eq!(pubtype, PublicationType::Paperback); + } + + #[test] + fn publicationfield_default_is_publication_type() { + let pubfield: PublicationField = Default::default(); + assert_eq!(pubfield, PublicationField::PublicationType); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn publicationtype_display_formats_expected_strings() { + assert_eq!(format!("{}", PublicationType::Paperback), "Paperback"); + assert_eq!(format!("{}", PublicationType::Hardback), "Hardback"); + assert_eq!(format!("{}", PublicationType::Pdf), "PDF"); + assert_eq!(format!("{}", PublicationType::Html), "HTML"); + assert_eq!(format!("{}", PublicationType::Xml), "XML"); + assert_eq!(format!("{}", PublicationType::Epub), "Epub"); + assert_eq!(format!("{}", PublicationType::Mobi), "Mobi"); + assert_eq!(format!("{}", PublicationType::Azw3), "AZW3"); + assert_eq!(format!("{}", PublicationType::Docx), "DOCX"); + assert_eq!(format!("{}", PublicationType::FictionBook), "FictionBook"); + assert_eq!(format!("{}", PublicationType::Mp3), "MP3"); + assert_eq!(format!("{}", PublicationType::Wav), "WAV"); + } + + #[test] + fn publicationfield_display_formats_expected_strings() { + assert_eq!(format!("{}", PublicationField::PublicationId), "ID"); + assert_eq!(format!("{}", PublicationField::PublicationType), "Type"); + assert_eq!(format!("{}", PublicationField::WorkId), "WorkID"); + assert_eq!(format!("{}", PublicationField::Isbn), "ISBN"); + assert_eq!(format!("{}", PublicationField::CreatedAt), "CreatedAt"); + assert_eq!(format!("{}", PublicationField::UpdatedAt), "UpdatedAt"); + assert_eq!(format!("{}", PublicationField::WidthMm), "WidthMm"); + assert_eq!(format!("{}", PublicationField::WidthIn), "WidthIn"); + assert_eq!(format!("{}", PublicationField::HeightMm), "HeightMm"); + assert_eq!(format!("{}", PublicationField::HeightIn), "HeightIn"); + assert_eq!(format!("{}", PublicationField::DepthMm), "DepthMm"); + assert_eq!(format!("{}", PublicationField::DepthIn), "DepthIn"); + assert_eq!(format!("{}", PublicationField::WeightG), "WeightG"); + assert_eq!(format!("{}", PublicationField::WeightOz), "WeightOz"); + } + + #[test] + fn publicationtype_fromstr_parses_expected_values() { + use std::str::FromStr; + for (input, expected) in [ + ("Paperback", PublicationType::Paperback), + ("Hardback", PublicationType::Hardback), + ("PDF", PublicationType::Pdf), + ("HTML", PublicationType::Html), + ("XML", PublicationType::Xml), + ("Epub", PublicationType::Epub), + ("Mobi", PublicationType::Mobi), + ("AZW3", PublicationType::Azw3), + ("DOCX", PublicationType::Docx), + ("FictionBook", PublicationType::FictionBook), + ("MP3", PublicationType::Mp3), + ("WAV", PublicationType::Wav), + ] + .iter() + { + assert_eq!(PublicationType::from_str(input).unwrap(), *expected); + } + + assert!(PublicationType::from_str("PNG").is_err()); + assert!(PublicationType::from_str("Latex").is_err()); + assert!(PublicationType::from_str("azw3").is_err()); + assert!(PublicationType::from_str("Fiction Book").is_err()); + } + + #[test] + fn publicationfield_fromstr_parses_expected_values() { + use std::str::FromStr; + for (input, expected) in [ + ("ID", PublicationField::PublicationId), + ("Type", PublicationField::PublicationType), + ("WorkID", PublicationField::WorkId), + ("ISBN", PublicationField::Isbn), + ("CreatedAt", PublicationField::CreatedAt), + ("UpdatedAt", PublicationField::UpdatedAt), + ("WidthMm", PublicationField::WidthMm), + ("WidthIn", PublicationField::WidthIn), + ("HeightMm", PublicationField::HeightMm), + ("HeightIn", PublicationField::HeightIn), + ("DepthMm", PublicationField::DepthMm), + ("DepthIn", PublicationField::DepthIn), + ("WeightG", PublicationField::WeightG), + ("WeightOz", PublicationField::WeightOz), + ] + .iter() + { + assert_eq!(PublicationField::from_str(input).unwrap(), *expected); + } + + assert!(PublicationField::from_str("PublicationID").is_err()); + assert!(PublicationField::from_str("Work Title").is_err()); + assert!(PublicationField::from_str("Work DOI").is_err()); + } +} + +#[cfg(feature = "backend")] +mod conversions { + use super::*; + use crate::model::tests::db::setup_test_db; + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn publicationtype_graphql_roundtrip() { + assert_graphql_enum_roundtrip(PublicationType::Paperback); + } + + #[test] + fn accessibilitystandard_graphql_roundtrip() { + assert_graphql_enum_roundtrip(AccessibilityStandard::EpubA11y11aa); + } + + #[test] + fn accessibilityexception_graphql_roundtrip() { + assert_graphql_enum_roundtrip(AccessibilityException::MicroEnterprises); + } + + #[test] + fn publicationtype_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<PublicationType, crate::schema::sql_types::PublicationType>( + pool.as_ref(), + "'Paperback'::publication_type", + PublicationType::Paperback, + ); + } + + #[test] + fn accessibilitystandard_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::< + AccessibilityStandard, + crate::schema::sql_types::AccessibilityStandard, + >( + pool.as_ref(), + "'epub-a11y-11-aa'::accessibility_standard", + AccessibilityStandard::EpubA11y11aa, + ); + } + + #[test] + fn accessibilityexception_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::< + AccessibilityException, + crate::schema::sql_types::AccessibilityException, + >( + pool.as_ref(), + "'micro-enterprises'::accessibility_exception", + AccessibilityException::MicroEnterprises, + ); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn is_physical_returns_true_for_print_types() { + let mut publication: Publication = Default::default(); + for pub_type in [PublicationType::Paperback, PublicationType::Hardback] { + publication.publication_type = pub_type; + assert!(publication.is_physical()); + assert!(!publication.is_digital()); + } + for pub_type in [ + PublicationType::Azw3, + PublicationType::Docx, + PublicationType::Epub, + PublicationType::FictionBook, + PublicationType::Html, + PublicationType::Mobi, + PublicationType::Mp3, + PublicationType::Pdf, + PublicationType::Xml, + PublicationType::Wav, + ] { + publication.publication_type = pub_type; + assert!(!publication.is_physical()); + assert!(publication.is_digital()); + } + } + + #[test] + fn pk_returns_id() { + let publication: Publication = Default::default(); + assert_eq!(publication.pk(), publication.publication_id); + } + + #[test] + fn history_entry_serializes_model() { + let publication: Publication = Default::default(); + let user_id = "123456".to_string(); + let new_publication_history = publication.new_history_entry(&user_id); + assert_eq!( + new_publication_history.publication_id, + publication.publication_id + ); + assert_eq!(new_publication_history.user_id, user_id); + assert_eq!( + new_publication_history.data, + serde_json::Value::String(serde_json::to_string(&publication).unwrap()) + ); + } +} + +mod validation { + use super::*; + + #[test] + fn validate_dimensions_enforces_width_constraints() { + let mut publication: Publication = Publication { + publication_type: PublicationType::Pdf, + width_mm: Some(100.0), + ..Default::default() + }; + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::DimensionDigitalError) + ); + publication.width_mm = None; + assert!(publication.validate_dimensions_constraints().is_ok()); + publication.width_in = Some(39.4); + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::DimensionDigitalError) + ); + publication.publication_type = PublicationType::Paperback; + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::WidthEmptyError) + ); + publication.width_in = None; + assert!(publication.validate_dimensions_constraints().is_ok()); + publication.width_mm = Some(100.0); + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::WidthEmptyError) + ); + publication.width_in = Some(39.4); + assert!(publication.validate_dimensions_constraints().is_ok()); + } + + #[test] + fn validate_dimensions_enforces_height_constraints() { + let mut publication: Publication = Publication { + publication_type: PublicationType::Pdf, + height_mm: Some(100.0), + ..Default::default() + }; + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::DimensionDigitalError) + ); + publication.height_mm = None; + assert!(publication.validate_dimensions_constraints().is_ok()); + publication.height_in = Some(39.4); + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::DimensionDigitalError) + ); + publication.publication_type = PublicationType::Paperback; + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::HeightEmptyError) + ); + publication.height_in = None; + assert!(publication.validate_dimensions_constraints().is_ok()); + publication.height_mm = Some(100.0); + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::HeightEmptyError) + ); + publication.height_in = Some(39.4); + assert!(publication.validate_dimensions_constraints().is_ok()); + } + + #[test] + fn validate_dimensions_enforces_depth_constraints() { + let mut publication: Publication = Publication { + publication_type: PublicationType::Pdf, + depth_mm: Some(10.0), + ..Default::default() + }; + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::DimensionDigitalError) + ); + publication.depth_mm = None; + assert!(publication.validate_dimensions_constraints().is_ok()); + publication.depth_in = Some(3.94); + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::DimensionDigitalError) + ); + publication.publication_type = PublicationType::Paperback; + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::DepthEmptyError) + ); + publication.depth_in = None; + assert!(publication.validate_dimensions_constraints().is_ok()); + publication.depth_mm = Some(10.0); + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::DepthEmptyError) + ); + publication.depth_in = Some(3.94); + assert!(publication.validate_dimensions_constraints().is_ok()); + } + + #[test] + fn validate_dimensions_enforces_weight_constraints() { + let mut publication: Publication = Publication { + publication_type: PublicationType::Pdf, + weight_g: Some(100.0), + ..Default::default() + }; + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::DimensionDigitalError) + ); + publication.weight_g = None; + assert!(publication.validate_dimensions_constraints().is_ok()); + publication.weight_oz = Some(3.5); + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::DimensionDigitalError) + ); + publication.publication_type = PublicationType::Paperback; + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::WeightEmptyError) + ); + publication.weight_oz = None; + assert!(publication.validate_dimensions_constraints().is_ok()); + publication.weight_g = Some(100.0); + assert_eq!( + publication.validate_dimensions_constraints(), + Err(ThothError::WeightEmptyError) + ); + publication.weight_oz = Some(3.5); + assert!(publication.validate_dimensions_constraints().is_ok()); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + use chrono::NaiveDate; + + use crate::model::publication::policy::PublicationPolicy; + use crate::model::tests::db::{ + create_imprint, create_publication, create_publisher, create_work, setup_test_db, + test_context_with_user, test_superuser, test_user_with_role, + }; + use crate::model::work::{NewWork, Work, WorkStatus, WorkType}; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + use thoth_errors::ThothError; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("publication-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let new_publication = NewPublication { + publication_type: PublicationType::Paperback, + work_id: work.work_id, + isbn: None, + width_mm: None, + width_in: None, + height_mm: None, + height_in: None, + depth_mm: None, + depth_in: None, + weight_g: None, + weight_oz: None, + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + }; + + let publication = + Publication::create(pool.as_ref(), &new_publication).expect("Failed to create"); + let patch = PatchPublication { + publication_id: publication.publication_id, + publication_type: publication.publication_type, + work_id: publication.work_id, + isbn: publication.isbn.clone(), + width_mm: publication.width_mm, + width_in: publication.width_in, + height_mm: publication.height_mm, + height_in: publication.height_in, + depth_mm: publication.depth_mm, + depth_in: publication.depth_in, + weight_g: publication.weight_g, + weight_oz: publication.weight_oz, + accessibility_standard: publication.accessibility_standard, + accessibility_additional_standard: publication.accessibility_additional_standard, + accessibility_exception: publication.accessibility_exception, + accessibility_report_url: publication.accessibility_report_url.clone(), + }; + + assert!(PublicationPolicy::can_create(&ctx, &new_publication, ()).is_ok()); + assert!(PublicationPolicy::can_update(&ctx, &publication, &patch, ()).is_ok()); + assert!(PublicationPolicy::can_delete(&ctx, &publication).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + let patch = PatchPublication { + publication_id: publication.publication_id, + publication_type: publication.publication_type, + work_id: publication.work_id, + isbn: publication.isbn.clone(), + width_mm: publication.width_mm, + width_in: publication.width_in, + height_mm: publication.height_mm, + height_in: publication.height_in, + depth_mm: publication.depth_mm, + depth_in: publication.depth_in, + weight_g: publication.weight_g, + weight_oz: publication.weight_oz, + accessibility_standard: publication.accessibility_standard, + accessibility_additional_standard: publication.accessibility_additional_standard, + accessibility_exception: publication.accessibility_exception, + accessibility_report_url: publication.accessibility_report_url.clone(), + }; + + let user = test_user_with_role("publication-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_publication = NewPublication { + publication_type: PublicationType::Paperback, + work_id: work.work_id, + isbn: None, + width_mm: None, + width_in: None, + height_mm: None, + height_in: None, + depth_mm: None, + depth_in: None, + weight_g: None, + weight_oz: None, + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + }; + + assert!(PublicationPolicy::can_create(&ctx, &new_publication, ()).is_err()); + assert!(PublicationPolicy::can_update(&ctx, &publication, &patch, ()).is_err()); + assert!(PublicationPolicy::can_delete(&ctx, &publication).is_err()); + } + + #[test] + fn crud_policy_prevents_non_superuser_from_deleting_publication_on_published_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let published_work = Work::create( + pool.as_ref(), + &NewWork { + work_type: WorkType::Monograph, + work_status: WorkStatus::Active, + reference: None, + edition: Some(1), + imprint_id: imprint.imprint_id, + doi: None, + publication_date: NaiveDate::from_ymd_opt(2020, 1, 1), + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: None, + last_page: None, + page_interval: None, + }, + ) + .expect("Failed to create published work"); + let publication = create_publication(pool.as_ref(), &published_work); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("publication-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + assert_eq!( + PublicationPolicy::can_delete(&ctx, &publication).unwrap_err(), + ThothError::ThothDeletePublicationError + ); + } + + #[test] + fn crud_policy_allows_superuser_to_delete_publication_on_published_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let published_work = Work::create( + pool.as_ref(), + &NewWork { + work_type: WorkType::Monograph, + work_status: WorkStatus::Active, + reference: None, + edition: Some(1), + imprint_id: imprint.imprint_id, + doi: None, + publication_date: NaiveDate::from_ymd_opt(2020, 1, 1), + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: None, + last_page: None, + page_interval: None, + }, + ) + .expect("Failed to create published work"); + let publication = create_publication(pool.as_ref(), &published_work); + + let super_ctx = test_context_with_user(pool.clone(), test_superuser("publication-super")); + assert!(PublicationPolicy::can_delete(&super_ctx, &publication).is_ok()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + use std::str::FromStr; + + use crate::model::tests::db::{ + create_imprint, create_publication, create_publisher, create_work, setup_test_db, + test_context, + }; + use crate::model::work::{NewWork, Work, WorkStatus, WorkType}; + use crate::model::Crud; + + fn make_publication( + pool: &crate::db::PgPool, + work_id: Uuid, + publication_type: PublicationType, + isbn: Option<Isbn>, + ) -> Publication { + let new_publication = NewPublication { + publication_type, + work_id, + isbn, + width_mm: None, + width_in: None, + height_mm: None, + height_in: None, + depth_mm: None, + depth_in: None, + weight_g: None, + weight_oz: None, + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + }; + + Publication::create(pool, &new_publication).expect("Failed to create publication") + } + + fn make_work_with_type( + pool: &crate::db::PgPool, + imprint_id: Uuid, + work_type: WorkType, + ) -> Work { + let new_work = NewWork { + work_type, + work_status: WorkStatus::Forthcoming, + reference: None, + edition: if work_type == WorkType::BookChapter { + None + } else { + Some(1) + }, + imprint_id, + doi: None, + publication_date: None, + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: None, + last_page: None, + page_interval: None, + }; + + Work::create(pool, &new_work).expect("Failed to create work") + } + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + let fetched_publication = Publication::from_id(pool.as_ref(), &publication.publication_id) + .expect("Failed to fetch publication"); + assert_eq!( + publication.publication_id, + fetched_publication.publication_id + ); + + let patch = PatchPublication { + publication_id: publication.publication_id, + publication_type: publication.publication_type, + work_id: publication.work_id, + isbn: publication.isbn.clone(), + width_mm: Some(123.0), + width_in: Some(4.84), + height_mm: publication.height_mm, + height_in: publication.height_in, + depth_mm: publication.depth_mm, + depth_in: publication.depth_in, + weight_g: publication.weight_g, + weight_oz: publication.weight_oz, + accessibility_standard: publication.accessibility_standard, + accessibility_additional_standard: publication.accessibility_additional_standard, + accessibility_exception: publication.accessibility_exception, + accessibility_report_url: publication.accessibility_report_url.clone(), + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = publication + .update(&ctx, &patch) + .expect("Failed to update publication"); + assert_eq!(updated.width_mm, patch.width_mm); + + let deleted = updated + .delete(pool.as_ref()) + .expect("Failed to delete publication"); + assert!(Publication::from_id(pool.as_ref(), &deleted.publication_id).is_err()); + } + + #[test] + fn crud_validate_rejects_chapter_with_isbn() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = make_work_with_type(pool.as_ref(), imprint.imprint_id, WorkType::BookChapter); + + let publication = Publication::create( + pool.as_ref(), + &NewPublication { + publication_type: PublicationType::Pdf, + work_id: work.work_id, + isbn: Some(Isbn::from_str("978-0-306-40615-7").unwrap()), + width_mm: None, + width_in: None, + height_mm: None, + height_in: None, + depth_mm: None, + depth_in: None, + weight_g: None, + weight_oz: None, + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + }, + ) + .expect("Failed to create publication"); + + let result = publication.validate(pool.as_ref()); + assert!(matches!(result, Err(ThothError::ChapterIsbnError))); + } + + #[test] + fn crud_validate_rejects_chapter_with_dimensions() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = make_work_with_type(pool.as_ref(), imprint.imprint_id, WorkType::BookChapter); + + let publication = Publication { + publication_id: Uuid::new_v4(), + publication_type: PublicationType::Pdf, + work_id: work.work_id, + isbn: None, + created_at: Default::default(), + updated_at: Default::default(), + width_mm: Some(100.0), + width_in: None, + height_mm: None, + height_in: None, + depth_mm: None, + depth_in: None, + weight_g: None, + weight_oz: None, + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + }; + + let result = publication.validate(pool.as_ref()); + assert!(matches!(result, Err(ThothError::ChapterDimensionError))); + } + + #[test] + fn crud_validate_allows_chapter_without_isbn_or_dimensions() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = make_work_with_type(pool.as_ref(), imprint.imprint_id, WorkType::BookChapter); + + let publication = Publication::create( + pool.as_ref(), + &NewPublication { + publication_type: PublicationType::Pdf, + work_id: work.work_id, + isbn: None, + width_mm: None, + width_in: None, + height_mm: None, + height_in: None, + depth_mm: None, + depth_in: None, + weight_g: None, + weight_oz: None, + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + }, + ) + .expect("Failed to create publication"); + + assert!(publication.validate(pool.as_ref()).is_ok()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Paperback, + None, + ); + make_publication(pool.as_ref(), work.work_id, PublicationType::Pdf, None); + + let order = PublicationOrderBy { + field: PublicationField::PublicationId, + direction: Direction::Asc, + }; + + let first = Publication::all( + pool.as_ref(), + 1, + 0, + None, + order.clone(), + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch publications"); + let second = Publication::all( + pool.as_ref(), + 1, + 1, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch publications"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].publication_id, second[0].publication_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Paperback, + None, + ); + make_publication(pool.as_ref(), work.work_id, PublicationType::Pdf, None); + + let count = Publication::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count publications"); + assert_eq!(count, 2); + } + + #[test] + fn crud_count_filters_by_publication_type() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Paperback, + None, + ); + make_publication(pool.as_ref(), work.work_id, PublicationType::Pdf, None); + + let count = Publication::count( + pool.as_ref(), + None, + vec![], + vec![PublicationType::Paperback], + vec![], + None, + None, + ) + .expect("Failed to count publications by type"); + assert_eq!(count, 1); + } + + #[test] + fn crud_count_filters_by_publishers() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Paperback, + None, + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let other_work = create_work(pool.as_ref(), &other_imprint); + make_publication( + pool.as_ref(), + other_work.work_id, + PublicationType::Pdf, + None, + ); + + let count = Publication::count( + pool.as_ref(), + None, + vec![publisher.publisher_id], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count publications by publisher"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_count_filters_by_isbn_substring() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Paperback, + Some(Isbn::from_str("978-0-306-40615-7").unwrap()), + ); + make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Pdf, + Some(Isbn::from_str("978-1-4028-9462-6").unwrap()), + ); + + let count = Publication::count( + pool.as_ref(), + Some("306-40615".to_string()), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count publications by ISBN filter"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_filter_matches_isbn() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let marker = "978-0-306-40615-7"; + let matches = make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Paperback, + Some(Isbn::from_str(marker).unwrap()), + ); + make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Pdf, + Some(Isbn::from_str("978-1-4028-9462-6").unwrap()), + ); + + let filtered = Publication::all( + pool.as_ref(), + 10, + 0, + Some("306-40615".to_string()), + PublicationOrderBy { + field: PublicationField::PublicationId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter publications"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].publication_id, matches.publication_id); + } + + #[test] + fn crud_filter_parent_work_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + + let matches = make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Paperback, + None, + ); + make_publication( + pool.as_ref(), + other_work.work_id, + PublicationType::Pdf, + None, + ); + + let filtered = Publication::all( + pool.as_ref(), + 10, + 0, + None, + PublicationOrderBy { + field: PublicationField::PublicationId, + direction: Direction::Asc, + }, + vec![], + Some(work.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter publications by work"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].publication_id, matches.publication_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let matches = make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Paperback, + None, + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let other_work = create_work(pool.as_ref(), &other_imprint); + make_publication( + pool.as_ref(), + other_work.work_id, + PublicationType::Pdf, + None, + ); + + let filtered = Publication::all( + pool.as_ref(), + 10, + 0, + None, + PublicationOrderBy { + field: PublicationField::PublicationId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter publications by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].publication_id, matches.publication_id); + } + + #[test] + fn crud_filter_param_limits_publication_types() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let matches = make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Paperback, + None, + ); + make_publication(pool.as_ref(), work.work_id, PublicationType::Pdf, None); + + let filtered = Publication::all( + pool.as_ref(), + 10, + 0, + None, + PublicationOrderBy { + field: PublicationField::PublicationId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![PublicationType::Paperback], + vec![], + None, + None, + ) + .expect("Failed to filter publications by type"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].publication_id, matches.publication_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let first = make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Paperback, + None, + ); + let second = make_publication(pool.as_ref(), work.work_id, PublicationType::Pdf, None); + let mut ids = [first.publication_id, second.publication_id]; + ids.sort(); + + let asc = Publication::all( + pool.as_ref(), + 2, + 0, + None, + PublicationOrderBy { + field: PublicationField::PublicationId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order publications (asc)"); + + let desc = Publication::all( + pool.as_ref(), + 2, + 0, + None, + PublicationOrderBy { + field: PublicationField::PublicationId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order publications (desc)"); + + assert_eq!(asc[0].publication_id, ids[0]); + assert_eq!(desc[0].publication_id, ids[1]); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_publication( + pool.as_ref(), + work.work_id, + PublicationType::Paperback, + None, + ); + make_publication(pool.as_ref(), work.work_id, PublicationType::Pdf, None); + + let fields: Vec<fn() -> PublicationField> = vec![ + || PublicationField::PublicationId, + || PublicationField::PublicationType, + || PublicationField::WorkId, + || PublicationField::Isbn, + || PublicationField::CreatedAt, + || PublicationField::UpdatedAt, + || PublicationField::WidthMm, + || PublicationField::WidthIn, + || PublicationField::HeightMm, + || PublicationField::HeightIn, + || PublicationField::DepthMm, + || PublicationField::DepthIn, + || PublicationField::WeightG, + || PublicationField::WeightOz, + || PublicationField::AccessibilityStandard, + || PublicationField::AccessibilityAdditionalStandard, + || PublicationField::AccessibilityException, + || PublicationField::AccessibilityReportUrl, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Publication::all( + pool.as_ref(), + 10, + 0, + None, + PublicationOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order publications"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/publisher/crud.rs b/thoth-api/src/model/publisher/crud.rs index b2776f01..449fc721 100644 --- a/thoth-api/src/model/publisher/crud.rs +++ b/thoth-api/src/model/publisher/crud.rs @@ -2,10 +2,9 @@ use super::{ NewPublisher, NewPublisherHistory, PatchPublisher, Publisher, PublisherField, PublisherHistory, PublisherOrderBy, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::db::PgPool; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{publisher, publisher_history}; -use crate::{crud_methods, db_insert}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, }; @@ -19,6 +18,7 @@ impl Crud for Publisher { type FilterParameter1 = (); type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.publisher_id @@ -36,36 +36,40 @@ impl Crud for Publisher { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Publisher>> { use crate::schema::publisher::dsl::*; let mut connection = db.get()?; let mut query = publisher.into_boxed(); query = match order.field { - PublisherField::PublisherId => match order.direction { - Direction::Asc => query.order(publisher_id.asc()), - Direction::Desc => query.order(publisher_id.desc()), - }, - PublisherField::PublisherName => match order.direction { - Direction::Asc => query.order(publisher_name.asc()), - Direction::Desc => query.order(publisher_name.desc()), - }, - PublisherField::PublisherShortname => match order.direction { - Direction::Asc => query.order(publisher_shortname.asc()), - Direction::Desc => query.order(publisher_shortname.desc()), - }, - PublisherField::PublisherUrl => match order.direction { - Direction::Asc => query.order(publisher_url.asc()), - Direction::Desc => query.order(publisher_url.desc()), - }, - PublisherField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - PublisherField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + PublisherField::PublisherId => { + apply_directional_order!(query, order.direction, order, publisher_id) + } + PublisherField::PublisherName => { + apply_directional_order!(query, order.direction, order, publisher_name) + } + PublisherField::PublisherShortname => { + apply_directional_order!(query, order.direction, order, publisher_shortname) + } + PublisherField::PublisherUrl => { + apply_directional_order!(query, order.direction, order, publisher_url) + } + PublisherField::ZitadelId => { + apply_directional_order!(query, order.direction, order, zitadel_id) + } + PublisherField::AccessibilityStatement => { + apply_directional_order!(query, order.direction, order, accessibility_statement) + } + PublisherField::AccessibilityReportUrl => { + apply_directional_order!(query, order.direction, order, accessibility_report_url) + } + PublisherField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + PublisherField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if !publishers.is_empty() { query = query.filter(publisher_id.eq_any(publishers)); @@ -91,6 +95,7 @@ impl Crud for Publisher { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::publisher::dsl::*; let mut connection = db.get()?; @@ -117,20 +122,49 @@ impl Crud for Publisher { .map_err(Into::into) } - fn publisher_id(&self, _db: &crate::db::PgPool) -> ThothResult<Uuid> { - Ok(self.pk()) + crud_methods!(publisher::table, publisher::dsl::publisher); +} + +impl Publisher { + pub fn by_zitadel_ids( + db: &crate::db::PgPool, + org_ids: Vec<String>, + ) -> ThothResult<Vec<Publisher>> { + use crate::schema::publisher::dsl::*; + + if org_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut connection = db.get()?; + let org_ids: Vec<Option<String>> = org_ids.into_iter().map(Some).collect(); + + publisher + .filter(zitadel_id.eq_any(org_ids)) + .load::<Publisher>(&mut connection) + .map_err(Into::into) } +} - crud_methods!(publisher::table, publisher::dsl::publisher); +impl PublisherId for Publisher { + fn publisher_id(&self, _db: &PgPool) -> ThothResult<Uuid> { + Ok(self.publisher_id) + } +} + +impl PublisherId for PatchPublisher { + fn publisher_id(&self, _db: &PgPool) -> ThothResult<Uuid> { + Ok(self.publisher_id) + } } impl HistoryEntry for Publisher { type NewHistoryEntity = NewPublisherHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { publisher_id: self.publisher_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -141,27 +175,3 @@ impl DbInsert for NewPublisherHistory { db_insert!(publisher_history::table); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_publisher_pk() { - let publisher: Publisher = Default::default(); - assert_eq!(publisher.pk(), publisher.publisher_id); - } - - #[test] - fn test_new_publisher_history_from_publisher() { - let publisher: Publisher = Default::default(); - let account_id: Uuid = Default::default(); - let new_publisher_history = publisher.new_history_entry(&account_id); - assert_eq!(new_publisher_history.publisher_id, publisher.publisher_id); - assert_eq!(new_publisher_history.account_id, account_id); - assert_eq!( - new_publisher_history.data, - serde_json::Value::String(serde_json::to_string(&publisher).unwrap()) - ); - } -} diff --git a/thoth-api/src/model/publisher/mod.rs b/thoth-api/src/model/publisher/mod.rs index bb98f002..475f47c9 100644 --- a/thoth-api/src/model/publisher/mod.rs +++ b/thoth-api/src/model/publisher/mod.rs @@ -4,7 +4,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::types::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::publisher; @@ -28,11 +28,14 @@ pub enum PublisherField { PublisherShortname, #[strum(serialize = "URL")] PublisherUrl, + ZitadelId, + AccessibilityStatement, + AccessibilityReportUrl, CreatedAt, UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Publisher { @@ -40,13 +43,16 @@ pub struct Publisher { pub publisher_name: String, pub publisher_shortname: Option<String>, pub publisher_url: Option<String>, + pub zitadel_id: Option<String>, + pub accessibility_statement: Option<String>, + pub accessibility_report_url: Option<String>, pub created_at: Timestamp, pub updated_at: Timestamp, } #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new organisation that produces and distributes works"), diesel(table_name = publisher) )] @@ -54,11 +60,14 @@ pub struct NewPublisher { pub publisher_name: String, pub publisher_shortname: Option<String>, pub publisher_url: Option<String>, + pub zitadel_id: Option<String>, + pub accessibility_statement: Option<String>, + pub accessibility_report_url: Option<String>, } #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing organisation that produces and distributes works"), diesel(table_name = publisher, treat_none_as_null = true) )] @@ -67,25 +76,28 @@ pub struct PatchPublisher { pub publisher_name: String, pub publisher_shortname: Option<String>, pub publisher_url: Option<String>, + pub zitadel_id: Option<String>, + pub accessibility_statement: Option<String>, + pub accessibility_report_url: Option<String>, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct PublisherHistory { pub publisher_history_id: Uuid, pub publisher_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = publisher_history) )] pub struct NewPublisherHistory { pub publisher_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -106,56 +118,11 @@ impl fmt::Display for Publisher { } } -#[test] -fn test_publisherfield_default() { - let pubfield: PublisherField = Default::default(); - assert_eq!(pubfield, PublisherField::PublisherName); -} - -#[test] -fn test_publisherfield_display() { - assert_eq!(format!("{}", PublisherField::PublisherId), "ID"); - assert_eq!(format!("{}", PublisherField::PublisherName), "Name"); - assert_eq!( - format!("{}", PublisherField::PublisherShortname), - "ShortName" - ); - assert_eq!(format!("{}", PublisherField::PublisherUrl), "URL"); - assert_eq!(format!("{}", PublisherField::CreatedAt), "CreatedAt"); - assert_eq!(format!("{}", PublisherField::UpdatedAt), "UpdatedAt"); -} - -#[test] -fn test_publisherfield_fromstr() { - use std::str::FromStr; - assert_eq!( - PublisherField::from_str("ID").unwrap(), - PublisherField::PublisherId - ); - assert_eq!( - PublisherField::from_str("Name").unwrap(), - PublisherField::PublisherName - ); - assert_eq!( - PublisherField::from_str("ShortName").unwrap(), - PublisherField::PublisherShortname - ); - assert_eq!( - PublisherField::from_str("URL").unwrap(), - PublisherField::PublisherUrl - ); - assert_eq!( - PublisherField::from_str("CreatedAt").unwrap(), - PublisherField::CreatedAt - ); - assert_eq!( - PublisherField::from_str("UpdatedAt").unwrap(), - PublisherField::UpdatedAt - ); - assert!(PublisherField::from_str("PublisherID").is_err()); - assert!(PublisherField::from_str("Website").is_err()); - assert!(PublisherField::from_str("Imprint").is_err()); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +pub mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::PublisherPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/publisher/policy.rs b/thoth-api/src/model/publisher/policy.rs new file mode 100644 index 00000000..57c39ef7 --- /dev/null +++ b/thoth-api/src/model/publisher/policy.rs @@ -0,0 +1,40 @@ +use crate::model::publisher::{NewPublisher, PatchPublisher, Publisher}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Publisher`. +/// +/// Publisher records define tenancy boundaries. As such, write access is restricted to superusers. +pub struct PublisherPolicy; + +impl CreatePolicy<NewPublisher> for PublisherPolicy { + fn can_create<C: PolicyContext>(ctx: &C, _data: &NewPublisher, _params: ()) -> ThothResult<()> { + ctx.require_superuser()?; + Ok(()) + } +} + +impl UpdatePolicy<Publisher, PatchPublisher> for PublisherPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Publisher, + patch: &PatchPublisher, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + if patch.zitadel_id != current.zitadel_id { + ctx.require_superuser()?; + } + + Ok(()) + } +} + +impl DeletePolicy<Publisher> for PublisherPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, _current: &Publisher) -> ThothResult<()> { + ctx.require_superuser()?; + Ok(()) + } +} diff --git a/thoth-api/src/model/publisher/tests.rs b/thoth-api/src/model/publisher/tests.rs new file mode 100644 index 00000000..c1787a2e --- /dev/null +++ b/thoth-api/src/model/publisher/tests.rs @@ -0,0 +1,520 @@ +use super::*; + +mod defaults { + use super::*; + + #[test] + fn publisherfield_default_is_publisher_name() { + let pubfield: PublisherField = Default::default(); + assert_eq!(pubfield, PublisherField::PublisherName); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn publisherfield_display_formats_expected_strings() { + assert_eq!(format!("{}", PublisherField::PublisherId), "ID"); + assert_eq!(format!("{}", PublisherField::PublisherName), "Name"); + assert_eq!( + format!("{}", PublisherField::PublisherShortname), + "ShortName" + ); + assert_eq!(format!("{}", PublisherField::PublisherUrl), "URL"); + assert_eq!(format!("{}", PublisherField::ZitadelId), "ZitadelId"); + assert_eq!(format!("{}", PublisherField::CreatedAt), "CreatedAt"); + assert_eq!(format!("{}", PublisherField::UpdatedAt), "UpdatedAt"); + } + + #[test] + fn publisherfield_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + PublisherField::from_str("ID").unwrap(), + PublisherField::PublisherId + ); + assert_eq!( + PublisherField::from_str("Name").unwrap(), + PublisherField::PublisherName + ); + assert_eq!( + PublisherField::from_str("ShortName").unwrap(), + PublisherField::PublisherShortname + ); + assert_eq!( + PublisherField::from_str("URL").unwrap(), + PublisherField::PublisherUrl + ); + assert_eq!( + PublisherField::from_str("ZitadelId").unwrap(), + PublisherField::ZitadelId + ); + assert_eq!( + PublisherField::from_str("CreatedAt").unwrap(), + PublisherField::CreatedAt + ); + assert_eq!( + PublisherField::from_str("UpdatedAt").unwrap(), + PublisherField::UpdatedAt + ); + assert!(PublisherField::from_str("PublisherID").is_err()); + assert!(PublisherField::from_str("Website").is_err()); + assert!(PublisherField::from_str("Imprint").is_err()); + } + + #[test] + fn publisher_display_formats_name() { + let publisher = Publisher { + publisher_name: "Test Publisher".to_string(), + ..Default::default() + }; + assert_eq!(format!("{publisher}"), "Test Publisher"); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let publisher: Publisher = Default::default(); + assert_eq!(publisher.pk(), publisher.publisher_id); + } + + #[test] + fn history_entry_serializes_model() { + let publisher: Publisher = Default::default(); + let user_id = "123456".to_string(); + let new_publisher_history = publisher.new_history_entry(&user_id); + assert_eq!(new_publisher_history.publisher_id, publisher.publisher_id); + assert_eq!(new_publisher_history.user_id, user_id); + assert_eq!( + new_publisher_history.data, + serde_json::Value::String(serde_json::to_string(&publisher).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::publisher::policy::PublisherPolicy; + use crate::model::tests::db::{ + create_publisher, setup_test_db, test_context_with_user, test_superuser, + test_user_with_role, + }; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_requires_superuser_for_create_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let new_publisher = NewPublisher { + publisher_name: "Policy Publisher".to_string(), + publisher_shortname: None, + publisher_url: None, + zitadel_id: Some(format!("org-{}", Uuid::new_v4())), + accessibility_statement: None, + accessibility_report_url: None, + }; + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("publisher-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + assert!(PublisherPolicy::can_create(&ctx, &new_publisher, ()).is_err()); + assert!(PublisherPolicy::can_delete(&ctx, &publisher).is_err()); + + let super_ctx = test_context_with_user(pool.clone(), test_superuser("publisher-super")); + assert!(PublisherPolicy::can_create(&super_ctx, &new_publisher, ()).is_ok()); + assert!(PublisherPolicy::can_delete(&super_ctx, &publisher).is_ok()); + } + + #[test] + fn crud_policy_requires_superuser_for_zitadel_change() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("publisher-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let patch_same = PatchPublisher { + publisher_id: publisher.publisher_id, + publisher_name: publisher.publisher_name.clone(), + publisher_shortname: publisher.publisher_shortname.clone(), + publisher_url: publisher.publisher_url.clone(), + zitadel_id: publisher.zitadel_id.clone(), + accessibility_statement: publisher.accessibility_statement.clone(), + accessibility_report_url: publisher.accessibility_report_url.clone(), + }; + + let patch_changed = PatchPublisher { + publisher_id: publisher.publisher_id, + publisher_name: publisher.publisher_name.clone(), + publisher_shortname: publisher.publisher_shortname.clone(), + publisher_url: publisher.publisher_url.clone(), + zitadel_id: Some(format!("org-{}", Uuid::new_v4())), + accessibility_statement: publisher.accessibility_statement.clone(), + accessibility_report_url: publisher.accessibility_report_url.clone(), + }; + + assert!(PublisherPolicy::can_update(&ctx, &publisher, &patch_same, ()).is_ok()); + assert!(PublisherPolicy::can_update(&ctx, &publisher, &patch_changed, ()).is_err()); + + let super_ctx = test_context_with_user(pool.clone(), test_superuser("publisher-super")); + assert!(PublisherPolicy::can_update(&super_ctx, &publisher, &patch_changed, ()).is_ok()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + use uuid::Uuid; + + use crate::model::tests::db::{create_publisher, setup_test_db, test_context}; + use crate::model::Crud; + + fn make_publisher(pool: &crate::db::PgPool, name: String) -> Publisher { + let new_publisher = NewPublisher { + publisher_name: name, + publisher_shortname: None, + publisher_url: None, + zitadel_id: Some(format!("org-{}", Uuid::new_v4())), + accessibility_statement: None, + accessibility_report_url: None, + }; + + Publisher::create(pool, &new_publisher).expect("Failed to create publisher") + } + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let fetched = Publisher::from_id(pool.as_ref(), &publisher.publisher_id) + .expect("Failed to fetch publisher"); + assert_eq!(publisher.publisher_id, fetched.publisher_id); + + let patch = PatchPublisher { + publisher_id: publisher.publisher_id, + publisher_name: format!("Updated {}", Uuid::new_v4()), + publisher_shortname: Some("UPD".to_string()), + publisher_url: Some("https://example.com".to_string()), + zitadel_id: publisher.zitadel_id.clone(), + accessibility_statement: publisher.accessibility_statement.clone(), + accessibility_report_url: publisher.accessibility_report_url.clone(), + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = publisher + .update(&ctx, &patch) + .expect("Failed to update publisher"); + assert_eq!(updated.publisher_name, patch.publisher_name); + + let deleted = updated + .delete(pool.as_ref()) + .expect("Failed to delete publisher"); + assert!(Publisher::from_id(pool.as_ref(), &deleted.publisher_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + create_publisher(pool.as_ref()); + create_publisher(pool.as_ref()); + + let order = PublisherOrderBy { + field: PublisherField::PublisherId, + direction: Direction::Asc, + }; + + let first = Publisher::all( + pool.as_ref(), + 1, + 0, + None, + order.clone(), + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch publishers"); + let second = Publisher::all( + pool.as_ref(), + 1, + 1, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch publishers"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].publisher_id, second[0].publisher_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + create_publisher(pool.as_ref()); + create_publisher(pool.as_ref()); + + let count = Publisher::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count publishers"); + assert_eq!(count, 2); + } + + #[test] + fn crud_count_filters_by_publisher_ids() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + create_publisher(pool.as_ref()); + + let count = Publisher::count( + pool.as_ref(), + None, + vec![publisher.publisher_id], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count publishers by id"); + assert_eq!(count, 1); + } + + #[test] + fn crud_count_filters_by_name() { + let (_guard, pool) = setup_test_db(); + + let marker = format!("Filter {}", Uuid::new_v4()); + make_publisher(pool.as_ref(), format!("Press {marker}")); + make_publisher(pool.as_ref(), "Other Press".to_string()); + + let count = Publisher::count( + pool.as_ref(), + Some(marker), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count publishers by name filter"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_filter_matches_name() { + let (_guard, pool) = setup_test_db(); + + let marker = format!("Filter {}", Uuid::new_v4()); + let matches = make_publisher(pool.as_ref(), format!("Press {marker}")); + make_publisher(pool.as_ref(), "Other Press".to_string()); + + let order = PublisherOrderBy { + field: PublisherField::PublisherId, + direction: Direction::Asc, + }; + + let filtered = Publisher::all( + pool.as_ref(), + 10, + 0, + Some(marker), + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter publishers"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].publisher_id, matches.publisher_id); + } + + #[test] + fn crud_filter_publisher_ids_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other = create_publisher(pool.as_ref()); + + let filtered = Publisher::all( + pool.as_ref(), + 10, + 0, + None, + PublisherOrderBy { + field: PublisherField::PublisherId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter publishers by ids"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].publisher_id, publisher.publisher_id); + assert_ne!(filtered[0].publisher_id, other.publisher_id); + } + + #[test] + fn crud_by_zitadel_ids_returns_matches() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + + let results = Publisher::by_zitadel_ids(pool.as_ref(), vec![org_id]) + .expect("Failed to fetch publishers by zitadel id"); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].publisher_id, publisher.publisher_id); + } + + #[test] + fn crud_by_zitadel_ids_returns_empty_for_empty_input() { + let (_guard, pool) = setup_test_db(); + + let results = Publisher::by_zitadel_ids(pool.as_ref(), vec![]) + .expect("Failed to fetch publishers by zitadel id"); + + assert!(results.is_empty()); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let first = create_publisher(pool.as_ref()); + let second = create_publisher(pool.as_ref()); + let mut ids = [first.publisher_id, second.publisher_id]; + ids.sort(); + + let asc = Publisher::all( + pool.as_ref(), + 2, + 0, + None, + PublisherOrderBy { + field: PublisherField::PublisherId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order publishers (asc)"); + + let desc = Publisher::all( + pool.as_ref(), + 2, + 0, + None, + PublisherOrderBy { + field: PublisherField::PublisherId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order publishers (desc)"); + + assert_eq!(asc[0].publisher_id, ids[0]); + assert_eq!(desc[0].publisher_id, ids[1]); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + create_publisher(pool.as_ref()); + create_publisher(pool.as_ref()); + + let fields: Vec<fn() -> PublisherField> = vec![ + || PublisherField::PublisherId, + || PublisherField::PublisherName, + || PublisherField::PublisherShortname, + || PublisherField::PublisherUrl, + || PublisherField::ZitadelId, + || PublisherField::AccessibilityStatement, + || PublisherField::AccessibilityReportUrl, + || PublisherField::CreatedAt, + || PublisherField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Publisher::all( + pool.as_ref(), + 10, + 0, + None, + PublisherOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order publishers"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/reference/crud.rs b/thoth-api/src/model/reference/crud.rs index 960aabca..213b119a 100644 --- a/thoth-api/src/model/reference/crud.rs +++ b/thoth-api/src/model/reference/crud.rs @@ -2,12 +2,11 @@ use super::{ NewReference, NewReferenceHistory, PatchReference, Reference, ReferenceField, ReferenceHistory, ReferenceOrderBy, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; use crate::schema::{reference, reference_history}; -use crate::{crud_methods, db_insert}; use diesel::{ - BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, + BoolExpressionMethods, Connection, ExpressionMethods, PgTextExpressionMethods, QueryDsl, + RunQueryDsl, }; use thoth_errors::ThothResult; use uuid::Uuid; @@ -19,6 +18,7 @@ impl Crud for Reference { type FilterParameter1 = (); type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.reference_id @@ -36,6 +36,7 @@ impl Crud for Reference { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Reference>> { use crate::schema::reference::dsl::*; let mut connection = db.get()?; @@ -45,106 +46,71 @@ impl Crud for Reference { .into_boxed(); query = match order.field { - ReferenceField::ReferenceId => match order.direction { - Direction::Asc => query.order(reference_id.asc()), - Direction::Desc => query.order(reference_id.desc()), - }, - ReferenceField::WorkId => match order.direction { - Direction::Asc => query.order(work_id.asc()), - Direction::Desc => query.order(work_id.desc()), - }, - ReferenceField::ReferenceOrdinal => match order.direction { - Direction::Asc => query.order(reference_ordinal.asc()), - Direction::Desc => query.order(reference_ordinal.desc()), - }, - ReferenceField::Doi => match order.direction { - Direction::Asc => query.order(doi.asc()), - Direction::Desc => query.order(doi.desc()), - }, - ReferenceField::UnstructuredCitation => match order.direction { - Direction::Asc => query.order(unstructured_citation.asc()), - Direction::Desc => query.order(unstructured_citation.desc()), - }, - ReferenceField::Issn => match order.direction { - Direction::Asc => query.order(issn.asc()), - Direction::Desc => query.order(issn.desc()), - }, - ReferenceField::Isbn => match order.direction { - Direction::Asc => query.order(isbn.asc()), - Direction::Desc => query.order(isbn.desc()), - }, - ReferenceField::JournalTitle => match order.direction { - Direction::Asc => query.order(journal_title.asc()), - Direction::Desc => query.order(journal_title.desc()), - }, - ReferenceField::ArticleTitle => match order.direction { - Direction::Asc => query.order(article_title.asc()), - Direction::Desc => query.order(article_title.desc()), - }, - ReferenceField::SeriesTitle => match order.direction { - Direction::Asc => query.order(series_title.asc()), - Direction::Desc => query.order(series_title.desc()), - }, - ReferenceField::VolumeTitle => match order.direction { - Direction::Asc => query.order(volume_title.asc()), - Direction::Desc => query.order(volume_title.desc()), - }, - ReferenceField::Edition => match order.direction { - Direction::Asc => query.order(edition.asc()), - Direction::Desc => query.order(edition.desc()), - }, - ReferenceField::Author => match order.direction { - Direction::Asc => query.order(author.asc()), - Direction::Desc => query.order(author.desc()), - }, - ReferenceField::Volume => match order.direction { - Direction::Asc => query.order(volume.asc()), - Direction::Desc => query.order(volume.desc()), - }, - ReferenceField::Issue => match order.direction { - Direction::Asc => query.order(issue.asc()), - Direction::Desc => query.order(issue.desc()), - }, - ReferenceField::FirstPage => match order.direction { - Direction::Asc => query.order(first_page.asc()), - Direction::Desc => query.order(first_page.desc()), - }, - ReferenceField::ComponentNumber => match order.direction { - Direction::Asc => query.order(component_number.asc()), - Direction::Desc => query.order(component_number.desc()), - }, - ReferenceField::StandardDesignator => match order.direction { - Direction::Asc => query.order(standard_designator.asc()), - Direction::Desc => query.order(standard_designator.desc()), - }, - ReferenceField::StandardsBodyName => match order.direction { - Direction::Asc => query.order(standards_body_name.asc()), - Direction::Desc => query.order(standards_body_name.desc()), - }, - ReferenceField::StandardsBodyAcronym => match order.direction { - Direction::Asc => query.order(standards_body_acronym.asc()), - Direction::Desc => query.order(standards_body_acronym.desc()), - }, - ReferenceField::Url => match order.direction { - Direction::Asc => query.order(url.asc()), - Direction::Desc => query.order(url.desc()), - }, - ReferenceField::PublicationDate => match order.direction { - Direction::Asc => query.order(publication_date.asc()), - Direction::Desc => query.order(publication_date.desc()), - }, - ReferenceField::RetrievalDate => match order.direction { - Direction::Asc => query.order(retrieval_date.asc()), - Direction::Desc => query.order(retrieval_date.desc()), - }, - ReferenceField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - ReferenceField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + ReferenceField::ReferenceId => { + apply_directional_order!(query, order.direction, order, reference_id) + } + ReferenceField::WorkId => { + apply_directional_order!(query, order.direction, order, work_id) + } + ReferenceField::ReferenceOrdinal => { + apply_directional_order!(query, order.direction, order, reference_ordinal) + } + ReferenceField::Doi => apply_directional_order!(query, order.direction, order, doi), + ReferenceField::UnstructuredCitation => { + apply_directional_order!(query, order.direction, order, unstructured_citation) + } + ReferenceField::Issn => apply_directional_order!(query, order.direction, order, issn), + ReferenceField::Isbn => apply_directional_order!(query, order.direction, order, isbn), + ReferenceField::JournalTitle => { + apply_directional_order!(query, order.direction, order, journal_title) + } + ReferenceField::ArticleTitle => { + apply_directional_order!(query, order.direction, order, article_title) + } + ReferenceField::SeriesTitle => { + apply_directional_order!(query, order.direction, order, series_title) + } + ReferenceField::VolumeTitle => { + apply_directional_order!(query, order.direction, order, volume_title) + } + ReferenceField::Edition => { + apply_directional_order!(query, order.direction, order, edition) + } + ReferenceField::Author => { + apply_directional_order!(query, order.direction, order, author) + } + ReferenceField::Volume => { + apply_directional_order!(query, order.direction, order, volume) + } + ReferenceField::Issue => apply_directional_order!(query, order.direction, order, issue), + ReferenceField::FirstPage => { + apply_directional_order!(query, order.direction, order, first_page) + } + ReferenceField::ComponentNumber => { + apply_directional_order!(query, order.direction, order, component_number) + } + ReferenceField::StandardDesignator => { + apply_directional_order!(query, order.direction, order, standard_designator) + } + ReferenceField::StandardsBodyName => { + apply_directional_order!(query, order.direction, order, standards_body_name) + } + ReferenceField::StandardsBodyAcronym => { + apply_directional_order!(query, order.direction, order, standards_body_acronym) + } + ReferenceField::Url => apply_directional_order!(query, order.direction, order, url), + ReferenceField::PublicationDate => { + apply_directional_order!(query, order.direction, order, publication_date) + } + ReferenceField::RetrievalDate => { + apply_directional_order!(query, order.direction, order, retrieval_date) + } + ReferenceField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + ReferenceField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if !publishers.is_empty() { query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); @@ -186,6 +152,7 @@ impl Crud for Reference { _: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::reference::dsl::*; let mut connection = db.get()?; @@ -227,19 +194,20 @@ impl Crud for Reference { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } crud_methods!(reference::table, reference::dsl::reference); } +publisher_id_impls!(Reference, NewReference, PatchReference, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Reference { type NewHistoryEntity = NewReferenceHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { reference_id: self.reference_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -251,26 +219,25 @@ impl DbInsert for NewReferenceHistory { db_insert!(reference_history::table); } -#[cfg(test)] -mod tests { - use super::*; +impl Reorder for Reference { + db_change_ordinal!( + reference::table, + reference::reference_ordinal, + "reference_reference_ordinal_work_id_uniq" + ); - #[test] - fn test_reference_pk() { - let reference: Reference = Default::default(); - assert_eq!(reference.pk(), reference.reference_id); - } - - #[test] - fn test_new_publisher_history_from_publisher() { - let reference: Reference = Default::default(); - let account_id: Uuid = Default::default(); - let new_reference_history = reference.new_history_entry(&account_id); - assert_eq!(new_reference_history.reference_id, reference.reference_id); - assert_eq!(new_reference_history.account_id, account_id); - assert_eq!( - new_reference_history.data, - serde_json::Value::String(serde_json::to_string(&reference).unwrap()) - ); + fn get_other_objects( + &self, + connection: &mut diesel::PgConnection, + ) -> ThothResult<Vec<(Uuid, i32)>> { + reference::table + .select((reference::reference_id, reference::reference_ordinal)) + .filter( + reference::work_id + .eq(self.work_id) + .and(reference::reference_id.ne(self.reference_id)), + ) + .load::<(Uuid, i32)>(connection) + .map_err(Into::into) } } diff --git a/thoth-api/src/model/reference/mod.rs b/thoth-api/src/model/reference/mod.rs index f1ba9cb6..b4874242 100644 --- a/thoth-api/src/model/reference/mod.rs +++ b/thoth-api/src/model/reference/mod.rs @@ -2,7 +2,7 @@ use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::types::inputs::Direction; use crate::model::{Doi, Isbn, Timestamp}; #[cfg(feature = "backend")] use crate::schema::reference; @@ -45,7 +45,7 @@ pub enum ReferenceField { UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Reference { @@ -78,7 +78,7 @@ pub struct Reference { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new citation to a written text"), diesel(table_name = reference) )] @@ -109,7 +109,7 @@ pub struct NewReference { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing citation to a written text"), diesel(table_name = reference, treat_none_as_null = true) )] @@ -139,23 +139,23 @@ pub struct PatchReference { pub retrieval_date: Option<NaiveDate>, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct ReferenceHistory { pub reference_history_id: Uuid, pub reference_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = reference_history) )] pub struct NewReferenceHistory { pub reference_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -170,11 +170,11 @@ pub struct ReferenceOrderBy { pub direction: Direction, } -#[test] -fn test_referencefield_default() { - let reffield: ReferenceField = Default::default(); - assert_eq!(reffield, ReferenceField::ReferenceOrdinal); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::ReferencePolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/reference/policy.rs b/thoth-api/src/model/reference/policy.rs new file mode 100644 index 00000000..e8749151 --- /dev/null +++ b/thoth-api/src/model/reference/policy.rs @@ -0,0 +1,45 @@ +use crate::model::reference::{NewReference, PatchReference, Reference}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Reference`. +/// +/// For now this policy enforces the tenant boundary only: +/// - authentication +/// - publisher membership derived from the entity / input via `PublisherId` +pub struct ReferencePolicy; + +impl CreatePolicy<NewReference> for ReferencePolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewReference, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Reference, PatchReference> for ReferencePolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Reference, + patch: &PatchReference, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy<Reference> for ReferencePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Reference) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} + +impl MovePolicy<Reference> for ReferencePolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &Reference) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/reference/tests.rs b/thoth-api/src/model/reference/tests.rs new file mode 100644 index 00000000..236dfde2 --- /dev/null +++ b/thoth-api/src/model/reference/tests.rs @@ -0,0 +1,865 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_reference( + pool: &crate::db::PgPool, + work_id: Uuid, + reference_ordinal: i32, + unstructured_citation: Option<String>, +) -> Reference { + let new_reference = NewReference { + work_id, + reference_ordinal, + doi: None, + unstructured_citation, + issn: None, + isbn: None, + journal_title: None, + article_title: None, + series_title: None, + volume_title: None, + edition: None, + author: None, + volume: None, + issue: None, + first_page: None, + component_number: None, + standard_designator: None, + standards_body_name: None, + standards_body_acronym: None, + url: None, + publication_date: None, + retrieval_date: None, + }; + + Reference::create(pool, &new_reference).expect("Failed to create reference") +} + +mod defaults { + use super::*; + + #[test] + fn referencefield_default_is_reference_ordinal() { + let reffield: ReferenceField = Default::default(); + assert_eq!(reffield, ReferenceField::ReferenceOrdinal); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let reference: Reference = Default::default(); + assert_eq!(reference.pk(), reference.reference_id); + } + + #[test] + fn history_entry_serializes_model() { + let reference: Reference = Default::default(); + let user_id = "123456".to_string(); + let new_reference_history = reference.new_history_entry(&user_id); + assert_eq!(new_reference_history.reference_id, reference.reference_id); + assert_eq!(new_reference_history.user_id, user_id); + assert_eq!( + new_reference_history.data, + serde_json::Value::String(serde_json::to_string(&reference).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::reference::policy::ReferencePolicy; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("reference-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let new_reference = NewReference { + work_id: work.work_id, + reference_ordinal: 1, + doi: None, + unstructured_citation: Some("Policy Citation".to_string()), + issn: None, + isbn: None, + journal_title: None, + article_title: None, + series_title: None, + volume_title: None, + edition: None, + author: None, + volume: None, + issue: None, + first_page: None, + component_number: None, + standard_designator: None, + standards_body_name: None, + standards_body_acronym: None, + url: None, + publication_date: None, + retrieval_date: None, + }; + + let reference = Reference::create(pool.as_ref(), &new_reference).expect("Failed to create"); + let patch = PatchReference { + reference_id: reference.reference_id, + work_id: reference.work_id, + reference_ordinal: 2, + doi: None, + unstructured_citation: Some("Updated Policy".to_string()), + issn: reference.issn.clone(), + isbn: reference.isbn.clone(), + journal_title: reference.journal_title.clone(), + article_title: reference.article_title.clone(), + series_title: reference.series_title.clone(), + volume_title: reference.volume_title.clone(), + edition: reference.edition, + author: reference.author.clone(), + volume: reference.volume.clone(), + issue: reference.issue.clone(), + first_page: reference.first_page.clone(), + component_number: reference.component_number.clone(), + standard_designator: reference.standard_designator.clone(), + standards_body_name: reference.standards_body_name.clone(), + standards_body_acronym: reference.standards_body_acronym.clone(), + url: reference.url.clone(), + publication_date: reference.publication_date, + retrieval_date: reference.retrieval_date, + }; + + assert!(ReferencePolicy::can_create(&ctx, &new_reference, ()).is_ok()); + assert!(ReferencePolicy::can_update(&ctx, &reference, &patch, ()).is_ok()); + assert!(ReferencePolicy::can_delete(&ctx, &reference).is_ok()); + assert!(ReferencePolicy::can_move(&ctx, &reference).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let reference = make_reference( + pool.as_ref(), + work.work_id, + 1, + Some("Policy Citation".to_string()), + ); + let patch = PatchReference { + reference_id: reference.reference_id, + work_id: reference.work_id, + reference_ordinal: 2, + doi: None, + unstructured_citation: Some("Updated Policy".to_string()), + issn: reference.issn.clone(), + isbn: reference.isbn.clone(), + journal_title: reference.journal_title.clone(), + article_title: reference.article_title.clone(), + series_title: reference.series_title.clone(), + volume_title: reference.volume_title.clone(), + edition: reference.edition, + author: reference.author.clone(), + volume: reference.volume.clone(), + issue: reference.issue.clone(), + first_page: reference.first_page.clone(), + component_number: reference.component_number.clone(), + standard_designator: reference.standard_designator.clone(), + standards_body_name: reference.standards_body_name.clone(), + standards_body_acronym: reference.standards_body_acronym.clone(), + url: reference.url.clone(), + publication_date: reference.publication_date, + retrieval_date: reference.retrieval_date, + }; + + let user = test_user_with_role("reference-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_reference = NewReference { + work_id: work.work_id, + reference_ordinal: 1, + doi: None, + unstructured_citation: Some("Policy Citation".to_string()), + issn: None, + isbn: None, + journal_title: None, + article_title: None, + series_title: None, + volume_title: None, + edition: None, + author: None, + volume: None, + issue: None, + first_page: None, + component_number: None, + standard_designator: None, + standards_body_name: None, + standards_body_acronym: None, + url: None, + publication_date: None, + retrieval_date: None, + }; + + assert!(ReferencePolicy::can_create(&ctx, &new_reference, ()).is_err()); + assert!(ReferencePolicy::can_update(&ctx, &reference, &patch, ()).is_err()); + assert!(ReferencePolicy::can_delete(&ctx, &reference).is_err()); + assert!(ReferencePolicy::can_move(&ctx, &reference).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + use std::str::FromStr; + + use chrono::NaiveDate; + + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context, + }; + use crate::model::{Crud, Doi, Isbn, Reorder}; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let new_reference = NewReference { + work_id: work.work_id, + reference_ordinal: 1, + doi: None, + unstructured_citation: Some("Initial citation".to_string()), + issn: None, + isbn: None, + journal_title: None, + article_title: None, + series_title: None, + volume_title: None, + edition: None, + author: None, + volume: None, + issue: None, + first_page: None, + component_number: None, + standard_designator: None, + standards_body_name: None, + standards_body_acronym: None, + url: None, + publication_date: None, + retrieval_date: None, + }; + + let reference = Reference::create(pool.as_ref(), &new_reference).expect("Failed to create"); + let fetched = + Reference::from_id(pool.as_ref(), &reference.reference_id).expect("Failed to fetch"); + assert_eq!(reference.reference_id, fetched.reference_id); + + let patch = PatchReference { + reference_id: reference.reference_id, + work_id: reference.work_id, + reference_ordinal: 2, + doi: None, + unstructured_citation: Some("Updated citation".to_string()), + issn: reference.issn.clone(), + isbn: reference.isbn.clone(), + journal_title: reference.journal_title.clone(), + article_title: reference.article_title.clone(), + series_title: reference.series_title.clone(), + volume_title: reference.volume_title.clone(), + edition: reference.edition, + author: reference.author.clone(), + volume: reference.volume.clone(), + issue: reference.issue.clone(), + first_page: reference.first_page.clone(), + component_number: reference.component_number.clone(), + standard_designator: reference.standard_designator.clone(), + standards_body_name: reference.standards_body_name.clone(), + standards_body_acronym: reference.standards_body_acronym.clone(), + url: reference.url.clone(), + publication_date: reference.publication_date, + retrieval_date: reference.retrieval_date, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = reference.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.reference_ordinal, patch.reference_ordinal); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Reference::from_id(pool.as_ref(), &deleted.reference_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_reference( + pool.as_ref(), + work.work_id, + 1, + Some(format!("Citation {}", Uuid::new_v4())), + ); + make_reference( + pool.as_ref(), + work.work_id, + 2, + Some(format!("Citation {}", Uuid::new_v4())), + ); + + let order = ReferenceOrderBy { + field: ReferenceField::ReferenceId, + direction: Direction::Asc, + }; + + let first = Reference::all( + pool.as_ref(), + 1, + 0, + None, + order.clone(), + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch references"); + let second = Reference::all( + pool.as_ref(), + 1, + 1, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch references"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].reference_id, second[0].reference_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_reference( + pool.as_ref(), + work.work_id, + 1, + Some(format!("Citation {}", Uuid::new_v4())), + ); + make_reference( + pool.as_ref(), + work.work_id, + 2, + Some(format!("Citation {}", Uuid::new_v4())), + ); + + let count = Reference::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count references"); + assert_eq!(count, 2); + } + + #[test] + fn crud_count_filters_by_publishers() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + make_reference( + pool.as_ref(), + work.work_id, + 1, + Some(format!("Citation {}", Uuid::new_v4())), + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let other_work = create_work(pool.as_ref(), &other_imprint); + make_reference( + pool.as_ref(), + other_work.work_id, + 1, + Some(format!("Other {}", Uuid::new_v4())), + ); + + let count = Reference::count( + pool.as_ref(), + None, + vec![publisher.publisher_id], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count references by publisher"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_filter_matches_unstructured_citation() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let marker = format!("Marker {}", Uuid::new_v4()); + let matches = make_reference( + pool.as_ref(), + work.work_id, + 1, + Some(format!("Citation {marker}")), + ); + make_reference( + pool.as_ref(), + work.work_id, + 2, + Some("Other Citation".to_string()), + ); + + let filtered = Reference::all( + pool.as_ref(), + 10, + 0, + Some(marker), + ReferenceOrderBy { + field: ReferenceField::ReferenceId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter references"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].reference_id, matches.reference_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let first = make_reference( + pool.as_ref(), + work.work_id, + 1, + Some(format!("Citation {}", Uuid::new_v4())), + ); + let second = make_reference( + pool.as_ref(), + work.work_id, + 2, + Some(format!("Citation {}", Uuid::new_v4())), + ); + let mut ids = [first.reference_id, second.reference_id]; + ids.sort(); + + let asc = Reference::all( + pool.as_ref(), + 2, + 0, + None, + ReferenceOrderBy { + field: ReferenceField::ReferenceId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order references (asc)"); + + let desc = Reference::all( + pool.as_ref(), + 2, + 0, + None, + ReferenceOrderBy { + field: ReferenceField::ReferenceId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order references (desc)"); + + assert_eq!(asc[0].reference_id, ids[0]); + assert_eq!(desc[0].reference_id, ids[1]); + } + + #[test] + fn crud_filter_ignores_empty_filter() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_reference( + pool.as_ref(), + work.work_id, + 1, + Some(format!("Citation {}", Uuid::new_v4())), + ); + make_reference( + pool.as_ref(), + work.work_id, + 2, + Some(format!("Citation {}", Uuid::new_v4())), + ); + + let filtered = Reference::all( + pool.as_ref(), + 10, + 0, + Some(String::new()), + ReferenceOrderBy { + field: ReferenceField::ReferenceId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch references"); + + assert_eq!(filtered.len(), 2); + } + + #[test] + fn crud_filter_parent_work_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + + let matches = make_reference( + pool.as_ref(), + work.work_id, + 1, + Some(format!("Citation {}", Uuid::new_v4())), + ); + make_reference( + pool.as_ref(), + other_work.work_id, + 1, + Some(format!("Citation {}", Uuid::new_v4())), + ); + + let filtered = Reference::all( + pool.as_ref(), + 10, + 0, + None, + ReferenceOrderBy { + field: ReferenceField::ReferenceId, + direction: Direction::Asc, + }, + vec![], + Some(work.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter references by work"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].reference_id, matches.reference_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let matches = make_reference( + pool.as_ref(), + work.work_id, + 1, + Some("Publisher Citation".to_string()), + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let other_work = create_work(pool.as_ref(), &other_imprint); + make_reference( + pool.as_ref(), + other_work.work_id, + 1, + Some("Other Citation".to_string()), + ); + + let filtered = Reference::all( + pool.as_ref(), + 10, + 0, + None, + ReferenceOrderBy { + field: ReferenceField::ReferenceId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter references by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].reference_id, matches.reference_id); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let first_reference = NewReference { + work_id: work.work_id, + reference_ordinal: 1, + doi: Some(Doi::from_str("https://doi.org/10.1234/REF.ONE").unwrap()), + unstructured_citation: Some("First Citation".to_string()), + issn: Some("1234-5678".to_string()), + isbn: Some(Isbn::from_str("9780131103627").unwrap()), + journal_title: Some("Journal A".to_string()), + article_title: Some("Article A".to_string()), + series_title: Some("Series A".to_string()), + volume_title: Some("Volume A".to_string()), + edition: Some(1), + author: Some("Author A".to_string()), + volume: Some("10".to_string()), + issue: Some("1".to_string()), + first_page: Some("1".to_string()), + component_number: Some("A".to_string()), + standard_designator: Some("STD-A".to_string()), + standards_body_name: Some("Standards Org".to_string()), + standards_body_acronym: Some("SO".to_string()), + url: Some("https://example.com/a".to_string()), + publication_date: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), + retrieval_date: Some(NaiveDate::from_ymd_opt(2021, 1, 1).unwrap()), + }; + + let second_reference = NewReference { + work_id: work.work_id, + reference_ordinal: 2, + doi: Some(Doi::from_str("https://doi.org/10.1234/REF.TWO").unwrap()), + unstructured_citation: Some("Second Citation".to_string()), + issn: Some("8765-4321".to_string()), + isbn: Some(Isbn::from_str("9780262033848").unwrap()), + journal_title: Some("Journal B".to_string()), + article_title: Some("Article B".to_string()), + series_title: Some("Series B".to_string()), + volume_title: Some("Volume B".to_string()), + edition: Some(2), + author: Some("Author B".to_string()), + volume: Some("20".to_string()), + issue: Some("2".to_string()), + first_page: Some("10".to_string()), + component_number: Some("B".to_string()), + standard_designator: Some("STD-B".to_string()), + standards_body_name: Some("Standards Org B".to_string()), + standards_body_acronym: Some("SOB".to_string()), + url: Some("https://example.com/b".to_string()), + publication_date: Some(NaiveDate::from_ymd_opt(2019, 1, 1).unwrap()), + retrieval_date: Some(NaiveDate::from_ymd_opt(2022, 1, 1).unwrap()), + }; + + Reference::create(pool.as_ref(), &first_reference).expect("Failed to create reference"); + Reference::create(pool.as_ref(), &second_reference).expect("Failed to create reference"); + + let fields: Vec<fn() -> ReferenceField> = vec![ + || ReferenceField::ReferenceId, + || ReferenceField::WorkId, + || ReferenceField::ReferenceOrdinal, + || ReferenceField::Doi, + || ReferenceField::UnstructuredCitation, + || ReferenceField::Issn, + || ReferenceField::Isbn, + || ReferenceField::JournalTitle, + || ReferenceField::ArticleTitle, + || ReferenceField::SeriesTitle, + || ReferenceField::VolumeTitle, + || ReferenceField::Edition, + || ReferenceField::Author, + || ReferenceField::Volume, + || ReferenceField::Issue, + || ReferenceField::FirstPage, + || ReferenceField::ComponentNumber, + || ReferenceField::StandardDesignator, + || ReferenceField::StandardsBodyName, + || ReferenceField::StandardsBodyAcronym, + || ReferenceField::Url, + || ReferenceField::PublicationDate, + || ReferenceField::RetrievalDate, + || ReferenceField::CreatedAt, + || ReferenceField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Reference::all( + pool.as_ref(), + 10, + 0, + None, + ReferenceOrderBy { + field: field(), + direction, + }, + vec![], + Some(work.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order references"); + + assert_eq!(results.len(), 2); + } + } + } + + #[test] + fn crud_count_with_filter_matches_reference() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let marker = format!("Citation {}", Uuid::new_v4()); + make_reference(pool.as_ref(), work.work_id, 1, Some(marker.clone())); + make_reference( + pool.as_ref(), + work.work_id, + 2, + Some("Other Citation".to_string()), + ); + + let count = Reference::count( + pool.as_ref(), + Some(marker), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count filtered references"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_change_ordinal_reorders_references() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let first = make_reference( + pool.as_ref(), + work.work_id, + 1, + Some("Citation One".to_string()), + ); + let second = make_reference( + pool.as_ref(), + work.work_id, + 2, + Some("Citation Two".to_string()), + ); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = first + .change_ordinal(&ctx, first.reference_ordinal, 2) + .expect("Failed to change reference ordinal"); + + let refreshed_first = + Reference::from_id(pool.as_ref(), &updated.reference_id).expect("Failed to fetch"); + let refreshed_second = + Reference::from_id(pool.as_ref(), &second.reference_id).expect("Failed to fetch"); + + assert_eq!(refreshed_first.reference_ordinal, 2); + assert_eq!(refreshed_second.reference_ordinal, 1); + } +} diff --git a/thoth-api/src/model/series/crud.rs b/thoth-api/src/model/series/crud.rs index 610486fc..ca045704 100644 --- a/thoth-api/src/model/series/crud.rs +++ b/thoth-api/src/model/series/crud.rs @@ -2,10 +2,8 @@ use super::{ NewSeries, NewSeriesHistory, PatchSeries, Series, SeriesField, SeriesHistory, SeriesOrderBy, SeriesType, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{series, series_history}; -use crate::{crud_methods, db_insert}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, }; @@ -19,6 +17,7 @@ impl Crud for Series { type FilterParameter1 = SeriesType; type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.series_id @@ -36,6 +35,7 @@ impl Crud for Series { series_types: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Series>> { use crate::schema::series::dsl::*; let mut connection = db.get()?; @@ -45,46 +45,36 @@ impl Crud for Series { .into_boxed(); query = match order.field { - SeriesField::SeriesId => match order.direction { - Direction::Asc => query.order(series_id.asc()), - Direction::Desc => query.order(series_id.desc()), - }, - SeriesField::SeriesType => match order.direction { - Direction::Asc => query.order(series_type.asc()), - Direction::Desc => query.order(series_type.desc()), - }, - SeriesField::SeriesName => match order.direction { - Direction::Asc => query.order(series_name.asc()), - Direction::Desc => query.order(series_name.desc()), - }, - SeriesField::IssnPrint => match order.direction { - Direction::Asc => query.order(issn_print.asc()), - Direction::Desc => query.order(issn_print.desc()), - }, - SeriesField::IssnDigital => match order.direction { - Direction::Asc => query.order(issn_digital.asc()), - Direction::Desc => query.order(issn_digital.desc()), - }, - SeriesField::SeriesUrl => match order.direction { - Direction::Asc => query.order(series_url.asc()), - Direction::Desc => query.order(series_url.desc()), - }, - SeriesField::SeriesDescription => match order.direction { - Direction::Asc => query.order(series_description.asc()), - Direction::Desc => query.order(series_description.desc()), - }, - SeriesField::SeriesCfpUrl => match order.direction { - Direction::Asc => query.order(series_cfp_url.asc()), - Direction::Desc => query.order(series_cfp_url.desc()), - }, - SeriesField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - SeriesField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + SeriesField::SeriesId => { + apply_directional_order!(query, order.direction, order, series_id) + } + SeriesField::SeriesType => { + apply_directional_order!(query, order.direction, order, series_type) + } + SeriesField::SeriesName => { + apply_directional_order!(query, order.direction, order, series_name) + } + SeriesField::IssnPrint => { + apply_directional_order!(query, order.direction, order, issn_print) + } + SeriesField::IssnDigital => { + apply_directional_order!(query, order.direction, order, issn_digital) + } + SeriesField::SeriesUrl => { + apply_directional_order!(query, order.direction, order, series_url) + } + SeriesField::SeriesDescription => { + apply_directional_order!(query, order.direction, order, series_description) + } + SeriesField::SeriesCfpUrl => { + apply_directional_order!(query, order.direction, order, series_cfp_url) + } + SeriesField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + SeriesField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if !publishers.is_empty() { query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); @@ -116,6 +106,7 @@ impl Crud for Series { series_types: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::series::dsl::*; let mut connection = db.get()?; @@ -150,21 +141,21 @@ impl Crud for Series { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - let imprint = crate::model::imprint::Imprint::from_id(db, &self.imprint_id)?; - <crate::model::imprint::Imprint as Crud>::publisher_id(&imprint, db) - } - crud_methods!(series::table, series::dsl::series); } +publisher_id_impls!(Series, NewSeries, PatchSeries, |s, db| { + let imprint = crate::model::imprint::Imprint::from_id(db, &s.imprint_id)?; + <crate::model::imprint::Imprint as PublisherId>::publisher_id(&imprint, db) +}); + impl HistoryEntry for Series { type NewHistoryEntity = NewSeriesHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { series_id: self.series_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -175,27 +166,3 @@ impl DbInsert for NewSeriesHistory { db_insert!(series_history::table); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_series_pk() { - let series: Series = Default::default(); - assert_eq!(series.pk(), series.series_id); - } - - #[test] - fn test_new_series_history_from_series() { - let series: Series = Default::default(); - let account_id: Uuid = Default::default(); - let new_series_history = series.new_history_entry(&account_id); - assert_eq!(new_series_history.series_id, series.series_id); - assert_eq!(new_series_history.account_id, account_id); - assert_eq!( - new_series_history.data, - serde_json::Value::String(serde_json::to_string(&series).unwrap()) - ); - } -} diff --git a/thoth-api/src/model/series/mod.rs b/thoth-api/src/model/series/mod.rs index c41fc655..69e4c525 100644 --- a/thoth-api/src/model/series/mod.rs +++ b/thoth-api/src/model/series/mod.rs @@ -1,11 +1,9 @@ use serde::{Deserialize, Serialize}; -use std::fmt; use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; -use crate::model::imprint::ImprintWithPublisher; +use crate::graphql::types::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::series; @@ -14,7 +12,7 @@ use crate::schema::series_history; #[cfg_attr( feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), graphql(description = "Type of a series"), ExistingTypePath = "crate::schema::sql_types::SeriesType" )] @@ -67,7 +65,7 @@ pub enum SeriesField { SeriesCfpUrl, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Series { @@ -84,24 +82,9 @@ pub struct Series { pub series_cfp_url: Option<String>, } -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SeriesWithImprint { - pub series_id: Uuid, - pub series_type: SeriesType, - pub series_name: String, - pub issn_print: Option<String>, - pub issn_digital: Option<String>, - pub series_url: Option<String>, - pub series_description: Option<String>, - pub series_cfp_url: Option<String>, - pub updated_at: Timestamp, - pub imprint: ImprintWithPublisher, -} - #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new periodical of publications"), diesel(table_name = series) )] @@ -118,7 +101,7 @@ pub struct NewSeries { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing periodical of publications"), diesel(table_name = series, treat_none_as_null = true) )] @@ -134,19 +117,19 @@ pub struct PatchSeries { pub imprint_id: Uuid, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct SeriesHistory { pub series_history_id: Uuid, pub series_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } -#[cfg_attr(feature = "backend", derive(Insertable), diesel(table_name = series_history))] +#[cfg_attr(feature = "backend", derive(diesel::Insertable), diesel(table_name = series_history))] pub struct NewSeriesHistory { pub series_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -161,157 +144,11 @@ pub struct SeriesOrderBy { pub direction: Direction, } -impl fmt::Display for SeriesWithImprint { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.series_name)?; - - let issns: Vec<String> = vec![ - self.issn_print.as_ref().cloned(), - self.issn_digital.as_ref().cloned(), - ] - .into_iter() - .flatten() - .collect(); - - if !issns.is_empty() { - write!(f, " ({})", issns.join(", "))?; - } - - Ok(()) - } -} - -#[test] -fn test_seriestype_default() { - let seriestype: SeriesType = Default::default(); - assert_eq!(seriestype, SeriesType::BookSeries); -} - -#[test] -fn test_seriesfield_default() { - let seriesfield: SeriesField = Default::default(); - assert_eq!(seriesfield, SeriesField::SeriesName); -} - -#[test] -fn test_seriestype_display() { - assert_eq!(format!("{}", SeriesType::Journal), "Journal"); - assert_eq!(format!("{}", SeriesType::BookSeries), "Book Series"); -} - -#[test] -fn test_seriesfield_display() { - assert_eq!(format!("{}", SeriesField::SeriesId), "ID"); - assert_eq!(format!("{}", SeriesField::SeriesType), "SeriesType"); - assert_eq!(format!("{}", SeriesField::SeriesName), "Series"); - assert_eq!(format!("{}", SeriesField::IssnPrint), "ISSNPrint"); - assert_eq!(format!("{}", SeriesField::IssnDigital), "ISSNDigital"); - assert_eq!(format!("{}", SeriesField::SeriesUrl), "SeriesURL"); - assert_eq!( - format!("{}", SeriesField::SeriesDescription), - "SeriesDescription" - ); - assert_eq!(format!("{}", SeriesField::SeriesCfpUrl), "SeriesCFPURL"); - assert_eq!(format!("{}", SeriesField::CreatedAt), "CreatedAt"); - assert_eq!(format!("{}", SeriesField::UpdatedAt), "UpdatedAt"); -} - -#[test] -fn test_seriestype_fromstr() { - use std::str::FromStr; - assert_eq!( - SeriesType::from_str("Journal").unwrap(), - SeriesType::Journal - ); - assert_eq!( - SeriesType::from_str("Book Series").unwrap(), - SeriesType::BookSeries - ); - - assert!(SeriesType::from_str("bookseries").is_err()); - assert!(SeriesType::from_str("Collection").is_err()); -} - -#[test] -fn test_seriesfield_fromstr() { - use std::str::FromStr; - assert_eq!(SeriesField::from_str("ID").unwrap(), SeriesField::SeriesId); - assert_eq!( - SeriesField::from_str("SeriesType").unwrap(), - SeriesField::SeriesType - ); - assert_eq!( - SeriesField::from_str("Series").unwrap(), - SeriesField::SeriesName - ); - assert_eq!( - SeriesField::from_str("ISSNPrint").unwrap(), - SeriesField::IssnPrint - ); - assert_eq!( - SeriesField::from_str("ISSNDigital").unwrap(), - SeriesField::IssnDigital - ); - assert_eq!( - SeriesField::from_str("SeriesURL").unwrap(), - SeriesField::SeriesUrl - ); - assert_eq!( - SeriesField::from_str("SeriesDescription").unwrap(), - SeriesField::SeriesDescription - ); - assert_eq!( - SeriesField::from_str("SeriesCFPURL").unwrap(), - SeriesField::SeriesCfpUrl - ); - assert_eq!( - SeriesField::from_str("CreatedAt").unwrap(), - SeriesField::CreatedAt - ); - assert_eq!( - SeriesField::from_str("UpdatedAt").unwrap(), - SeriesField::UpdatedAt - ); - assert!(SeriesField::from_str("SeriesID").is_err()); - assert!(SeriesField::from_str("Publisher").is_err()); - assert!(SeriesField::from_str("Issues").is_err()); -} - -#[test] -fn test_display_with_issns() { - let series = SeriesWithImprint { - series_name: String::from("Test Series"), - issn_print: Some(String::from("1234-5678")), - issn_digital: Some(String::from("8765-4321")), - ..Default::default() - }; - - let formatted = format!("{}", series); - assert_eq!(formatted, "Test Series (1234-5678, 8765-4321)"); -} - -#[test] -fn test_display_with_single_issn() { - let series = SeriesWithImprint { - series_name: String::from("Test Series"), - issn_print: Some(String::from("1234-5678")), - ..Default::default() - }; - - let formatted = format!("{}", series); - assert_eq!(formatted, "Test Series (1234-5678)"); -} - -#[test] -fn test_display_without_issns() { - let series = SeriesWithImprint { - series_name: String::from("Test Series"), - ..Default::default() - }; - - let formatted = format!("{}", series); - assert_eq!(formatted, "Test Series"); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::SeriesPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/series/policy.rs b/thoth-api/src/model/series/policy.rs new file mode 100644 index 00000000..60d63a93 --- /dev/null +++ b/thoth-api/src/model/series/policy.rs @@ -0,0 +1,38 @@ +use crate::model::series::{NewSeries, PatchSeries, Series}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Series`. +/// +/// For now this policy enforces the tenant boundary only: +/// - authentication +/// - publisher membership derived from the entity / input via `PublisherId` +pub struct SeriesPolicy; + +impl CreatePolicy<NewSeries> for SeriesPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewSeries, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Series, PatchSeries> for SeriesPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Series, + patch: &PatchSeries, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy<Series> for SeriesPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Series) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/series/tests.rs b/thoth-api/src/model/series/tests.rs new file mode 100644 index 00000000..ccc91a68 --- /dev/null +++ b/thoth-api/src/model/series/tests.rs @@ -0,0 +1,732 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_new_series( + imprint_id: Uuid, + series_type: SeriesType, + series_name: impl Into<String>, +) -> NewSeries { + NewSeries { + series_type, + series_name: series_name.into(), + issn_print: None, + issn_digital: None, + series_url: None, + series_description: None, + series_cfp_url: None, + imprint_id, + } +} + +fn make_patch_series( + series: &Series, + series_type: SeriesType, + series_name: impl Into<String>, +) -> PatchSeries { + PatchSeries { + series_id: series.series_id, + series_type, + series_name: series_name.into(), + issn_print: series.issn_print.clone(), + issn_digital: series.issn_digital.clone(), + series_url: series.series_url.clone(), + series_description: series.series_description.clone(), + series_cfp_url: series.series_cfp_url.clone(), + imprint_id: series.imprint_id, + } +} + +fn make_series( + pool: &crate::db::PgPool, + imprint_id: Uuid, + series_type: SeriesType, + name: String, +) -> Series { + let new_series = make_new_series(imprint_id, series_type, name); + + Series::create(pool, &new_series).expect("Failed to create series") +} + +mod defaults { + use super::*; + + #[test] + fn seriestype_default_is_book_series() { + let seriestype: SeriesType = Default::default(); + assert_eq!(seriestype, SeriesType::BookSeries); + } + + #[test] + fn seriesfield_default_is_series_name() { + let seriesfield: SeriesField = Default::default(); + assert_eq!(seriesfield, SeriesField::SeriesName); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn seriestype_display_formats_expected_strings() { + assert_eq!(format!("{}", SeriesType::Journal), "Journal"); + assert_eq!(format!("{}", SeriesType::BookSeries), "Book Series"); + } + + #[test] + fn seriesfield_display_formats_expected_strings() { + assert_eq!(format!("{}", SeriesField::SeriesId), "ID"); + assert_eq!(format!("{}", SeriesField::SeriesType), "SeriesType"); + assert_eq!(format!("{}", SeriesField::SeriesName), "Series"); + assert_eq!(format!("{}", SeriesField::IssnPrint), "ISSNPrint"); + assert_eq!(format!("{}", SeriesField::IssnDigital), "ISSNDigital"); + assert_eq!(format!("{}", SeriesField::SeriesUrl), "SeriesURL"); + assert_eq!( + format!("{}", SeriesField::SeriesDescription), + "SeriesDescription" + ); + assert_eq!(format!("{}", SeriesField::SeriesCfpUrl), "SeriesCFPURL"); + assert_eq!(format!("{}", SeriesField::CreatedAt), "CreatedAt"); + assert_eq!(format!("{}", SeriesField::UpdatedAt), "UpdatedAt"); + } + + #[test] + fn seriestype_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + SeriesType::from_str("Journal").unwrap(), + SeriesType::Journal + ); + assert_eq!( + SeriesType::from_str("Book Series").unwrap(), + SeriesType::BookSeries + ); + + assert!(SeriesType::from_str("bookseries").is_err()); + assert!(SeriesType::from_str("Collection").is_err()); + } + + #[test] + fn seriesfield_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!(SeriesField::from_str("ID").unwrap(), SeriesField::SeriesId); + assert_eq!( + SeriesField::from_str("SeriesType").unwrap(), + SeriesField::SeriesType + ); + assert_eq!( + SeriesField::from_str("Series").unwrap(), + SeriesField::SeriesName + ); + assert_eq!( + SeriesField::from_str("ISSNPrint").unwrap(), + SeriesField::IssnPrint + ); + assert_eq!( + SeriesField::from_str("ISSNDigital").unwrap(), + SeriesField::IssnDigital + ); + assert_eq!( + SeriesField::from_str("SeriesURL").unwrap(), + SeriesField::SeriesUrl + ); + assert_eq!( + SeriesField::from_str("SeriesDescription").unwrap(), + SeriesField::SeriesDescription + ); + assert_eq!( + SeriesField::from_str("SeriesCFPURL").unwrap(), + SeriesField::SeriesCfpUrl + ); + assert_eq!( + SeriesField::from_str("CreatedAt").unwrap(), + SeriesField::CreatedAt + ); + assert_eq!( + SeriesField::from_str("UpdatedAt").unwrap(), + SeriesField::UpdatedAt + ); + assert!(SeriesField::from_str("SeriesID").is_err()); + assert!(SeriesField::from_str("Publisher").is_err()); + assert!(SeriesField::from_str("Issues").is_err()); + } +} + +#[cfg(feature = "backend")] +mod conversions { + use super::*; + use crate::model::tests::db::setup_test_db; + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn seriestype_graphql_roundtrip() { + assert_graphql_enum_roundtrip(SeriesType::Journal); + } + + #[test] + fn seriestype_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<SeriesType, crate::schema::sql_types::SeriesType>( + pool.as_ref(), + "'journal'::series_type", + SeriesType::Journal, + ); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let series: Series = Default::default(); + assert_eq!(series.pk(), series.series_id); + } + + #[test] + fn history_entry_serializes_model() { + let series: Series = Default::default(); + let user_id = "123456".to_string(); + let new_series_history = series.new_history_entry(&user_id); + assert_eq!(new_series_history.series_id, series.series_id); + assert_eq!(new_series_history.user_id, user_id); + assert_eq!( + new_series_history.data, + serde_json::Value::String(serde_json::to_string(&series).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::series::policy::SeriesPolicy; + use crate::model::tests::db::{ + create_imprint, create_publisher, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("series-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let new_series = make_new_series(imprint.imprint_id, SeriesType::Journal, "Policy Series"); + + let series = Series::create(pool.as_ref(), &new_series).expect("Failed to create"); + let patch = make_patch_series(&series, series.series_type, "Updated Policy Series"); + + assert!(SeriesPolicy::can_create(&ctx, &new_series, ()).is_ok()); + assert!(SeriesPolicy::can_update(&ctx, &series, &patch, ()).is_ok()); + assert!(SeriesPolicy::can_delete(&ctx, &series).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let series = make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + "Policy Series".to_string(), + ); + let patch = make_patch_series(&series, series.series_type, "Updated Policy Series"); + + let user = test_user_with_role("series-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_series = make_new_series(imprint.imprint_id, SeriesType::Journal, "Policy Series"); + + assert!(SeriesPolicy::can_create(&ctx, &new_series, ()).is_err()); + assert!(SeriesPolicy::can_update(&ctx, &series, &patch, ()).is_err()); + assert!(SeriesPolicy::can_delete(&ctx, &series).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::model::tests::db::{create_imprint, create_publisher, setup_test_db, test_context}; + use crate::model::Crud; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + + let new_series = make_new_series( + imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + + let series = Series::create(pool.as_ref(), &new_series).expect("Failed to create"); + let fetched = Series::from_id(pool.as_ref(), &series.series_id).expect("Failed to fetch"); + assert_eq!(series.series_id, fetched.series_id); + + let patch = make_patch_series(&series, SeriesType::BookSeries, "Updated Series"); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = series.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.series_name, patch.series_name); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Series::from_id(pool.as_ref(), &deleted.series_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + + let order = SeriesOrderBy { + field: SeriesField::SeriesId, + direction: Direction::Asc, + }; + + let first = Series::all( + pool.as_ref(), + 1, + 0, + None, + order.clone(), + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch series"); + let second = Series::all( + pool.as_ref(), + 1, + 1, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch series"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].series_id, second[0].series_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + + let count = Series::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count series"); + assert_eq!(count, 2); + } + + #[test] + fn crud_count_filters_by_series_type() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::BookSeries, + format!("Series {}", Uuid::new_v4()), + ); + + let count = Series::count( + pool.as_ref(), + None, + vec![], + vec![SeriesType::Journal], + vec![], + None, + None, + ) + .expect("Failed to count series by type"); + assert_eq!(count, 1); + } + + #[test] + fn crud_count_filters_by_publishers() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + make_series( + pool.as_ref(), + other_imprint.imprint_id, + SeriesType::Journal, + format!("Other {}", Uuid::new_v4()), + ); + + let count = Series::count( + pool.as_ref(), + None, + vec![publisher.publisher_id], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count series by publisher"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_count_filters_by_name() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let marker = format!("Filter {}", Uuid::new_v4()); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {marker}"), + ); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + "Other Series".to_string(), + ); + + let count = Series::count( + pool.as_ref(), + Some(marker), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count series by name filter"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_filter_matches_series_name() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let marker = format!("Filter {}", Uuid::new_v4()); + let matches = make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {marker}"), + ); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + "Other Series".to_string(), + ); + + let filtered = Series::all( + pool.as_ref(), + 10, + 0, + Some(marker), + SeriesOrderBy { + field: SeriesField::SeriesId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter series"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].series_id, matches.series_id); + } + + #[test] + fn crud_filter_param_limits_series_types() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let matches = make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::BookSeries, + format!("Series {}", Uuid::new_v4()), + ); + + let filtered = Series::all( + pool.as_ref(), + 10, + 0, + None, + SeriesOrderBy { + field: SeriesField::SeriesId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![SeriesType::Journal], + vec![], + None, + None, + ) + .expect("Failed to filter series by type"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].series_id, matches.series_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let matches = make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + make_series( + pool.as_ref(), + other_imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + + let filtered = Series::all( + pool.as_ref(), + 10, + 0, + None, + SeriesOrderBy { + field: SeriesField::SeriesId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter series by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].series_id, matches.series_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let first = make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + let second = make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + let mut ids = [first.series_id, second.series_id]; + ids.sort(); + + let asc = Series::all( + pool.as_ref(), + 2, + 0, + None, + SeriesOrderBy { + field: SeriesField::SeriesId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order series (asc)"); + + let desc = Series::all( + pool.as_ref(), + 2, + 0, + None, + SeriesOrderBy { + field: SeriesField::SeriesId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order series (desc)"); + + assert_eq!(asc[0].series_id, ids[0]); + assert_eq!(desc[0].series_id, ids[1]); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::Journal, + format!("Series {}", Uuid::new_v4()), + ); + make_series( + pool.as_ref(), + imprint.imprint_id, + SeriesType::BookSeries, + format!("Series {}", Uuid::new_v4()), + ); + + let fields: Vec<fn() -> SeriesField> = vec![ + || SeriesField::SeriesId, + || SeriesField::SeriesType, + || SeriesField::SeriesName, + || SeriesField::IssnPrint, + || SeriesField::IssnDigital, + || SeriesField::SeriesUrl, + || SeriesField::SeriesDescription, + || SeriesField::SeriesCfpUrl, + || SeriesField::CreatedAt, + || SeriesField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Series::all( + pool.as_ref(), + 10, + 0, + None, + SeriesOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order series"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/subject/crud.rs b/thoth-api/src/model/subject/crud.rs index 9c63fc98..6b206c85 100644 --- a/thoth-api/src/model/subject/crud.rs +++ b/thoth-api/src/model/subject/crud.rs @@ -1,12 +1,13 @@ use super::{ NewSubject, NewSubjectHistory, PatchSubject, Subject, SubjectField, SubjectHistory, SubjectType, }; -use crate::graphql::model::SubjectOrderBy; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::graphql::types::inputs::SubjectOrderBy; +use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; use crate::schema::{subject, subject_history}; -use crate::{crud_methods, db_insert}; -use diesel::{ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl}; +use diesel::{ + BoolExpressionMethods, Connection, ExpressionMethods, PgTextExpressionMethods, QueryDsl, + RunQueryDsl, +}; use thoth_errors::ThothResult; use uuid::Uuid; @@ -17,6 +18,7 @@ impl Crud for Subject { type FilterParameter1 = SubjectType; type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.subject_id @@ -34,6 +36,7 @@ impl Crud for Subject { subject_types: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<Vec<Subject>> { use crate::schema::subject::dsl::*; let mut connection = db.get()?; @@ -43,34 +46,27 @@ impl Crud for Subject { .into_boxed(); query = match order.field { - SubjectField::SubjectId => match order.direction { - Direction::Asc => query.order(subject_id.asc()), - Direction::Desc => query.order(subject_id.desc()), - }, - SubjectField::WorkId => match order.direction { - Direction::Asc => query.order(work_id.asc()), - Direction::Desc => query.order(work_id.desc()), - }, - SubjectField::SubjectType => match order.direction { - Direction::Asc => query.order(subject_type.asc()), - Direction::Desc => query.order(subject_type.desc()), - }, - SubjectField::SubjectCode => match order.direction { - Direction::Asc => query.order(subject_code.asc()), - Direction::Desc => query.order(subject_code.desc()), - }, - SubjectField::SubjectOrdinal => match order.direction { - Direction::Asc => query.order(subject_ordinal.asc()), - Direction::Desc => query.order(subject_ordinal.desc()), - }, - SubjectField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - SubjectField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + SubjectField::SubjectId => { + apply_directional_order!(query, order.direction, order, subject_id) + } + SubjectField::WorkId => { + apply_directional_order!(query, order.direction, order, work_id) + } + SubjectField::SubjectType => { + apply_directional_order!(query, order.direction, order, subject_type) + } + SubjectField::SubjectCode => { + apply_directional_order!(query, order.direction, order, subject_code) + } + SubjectField::SubjectOrdinal => { + apply_directional_order!(query, order.direction, order, subject_ordinal) + } + SubjectField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + SubjectField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if !publishers.is_empty() { query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); @@ -99,6 +95,7 @@ impl Crud for Subject { subject_types: Vec<Self::FilterParameter1>, _: Vec<Self::FilterParameter2>, _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, ) -> ThothResult<i32> { use crate::schema::subject::dsl::*; let mut connection = db.get()?; @@ -120,20 +117,20 @@ impl Crud for Subject { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } - crud_methods!(subject::table, subject::dsl::subject); } +publisher_id_impls!(Subject, NewSubject, PatchSubject, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Subject { type NewHistoryEntity = NewSubjectHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { subject_id: self.subject_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -145,26 +142,26 @@ impl DbInsert for NewSubjectHistory { db_insert!(subject_history::table); } -#[cfg(test)] -mod tests { - use super::*; +impl Reorder for Subject { + db_change_ordinal!( + subject::table, + subject::subject_ordinal, + "subject_ordinal_type_uniq" + ); - #[test] - fn test_subject_pk() { - let subject: Subject = Default::default(); - assert_eq!(subject.pk(), subject.subject_id); - } - - #[test] - fn test_new_subject_history_from_subject() { - let subject: Subject = Default::default(); - let account_id: Uuid = Default::default(); - let new_subject_history = subject.new_history_entry(&account_id); - assert_eq!(new_subject_history.subject_id, subject.subject_id); - assert_eq!(new_subject_history.account_id, account_id); - assert_eq!( - new_subject_history.data, - serde_json::Value::String(serde_json::to_string(&subject).unwrap()) - ); + fn get_other_objects( + &self, + connection: &mut diesel::PgConnection, + ) -> ThothResult<Vec<(Uuid, i32)>> { + subject::table + .select((subject::subject_id, subject::subject_ordinal)) + .filter( + subject::work_id + .eq(self.work_id) + .and(subject::subject_type.eq(self.subject_type)) + .and(subject::subject_id.ne(self.subject_id)), + ) + .load::<(Uuid, i32)>(connection) + .map_err(Into::into) } } diff --git a/thoth-api/src/model/subject/mod.rs b/thoth-api/src/model/subject/mod.rs index 100f084d..509cdf31 100644 --- a/thoth-api/src/model/subject/mod.rs +++ b/thoth-api/src/model/subject/mod.rs @@ -8,12 +8,10 @@ use crate::model::Timestamp; use crate::schema::subject; #[cfg(feature = "backend")] use crate::schema::subject_history; -use thoth_errors::ThothError; -use thoth_errors::ThothResult; #[cfg_attr( feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), graphql(description = "Type of a subject (e.g. the subject category scheme being used)"), ExistingTypePath = "crate::schema::sql_types::SubjectType" )] @@ -60,7 +58,7 @@ pub enum SubjectField { UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Subject { @@ -75,7 +73,7 @@ pub struct Subject { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new significant discipline or term related to a work"), diesel(table_name = subject) )] @@ -88,7 +86,7 @@ pub struct NewSubject { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing significant discipline or term related to a work"), diesel(table_name = subject, treat_none_as_null = true) )] @@ -100,38 +98,26 @@ pub struct PatchSubject { pub subject_ordinal: i32, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct SubjectHistory { pub subject_history_id: Uuid, pub subject_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = subject_history) )] pub struct NewSubjectHistory { pub subject_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } -pub fn check_subject(subject_type: &SubjectType, code: &str) -> ThothResult<()> { - if matches!(subject_type, SubjectType::Thema) - && thema::THEMA_CODES.binary_search(&code).is_err() - { - return Err(ThothError::InvalidSubjectCode { - input: code.to_string(), - subject_type: subject_type.to_string(), - }); - } - Ok(()) -} - impl Default for Subject { fn default() -> Subject { Subject { @@ -146,59 +132,12 @@ impl Default for Subject { } } -#[test] -fn test_subjecttype_default() { - let subjecttype: SubjectType = Default::default(); - assert_eq!(subjecttype, SubjectType::Keyword); -} - -#[test] -fn test_subjecttype_display() { - assert_eq!(format!("{}", SubjectType::Bic), "BIC"); - assert_eq!(format!("{}", SubjectType::Bisac), "BISAC"); - assert_eq!(format!("{}", SubjectType::Thema), "Thema"); - assert_eq!(format!("{}", SubjectType::Lcc), "LCC"); - assert_eq!(format!("{}", SubjectType::Custom), "Custom"); - assert_eq!(format!("{}", SubjectType::Keyword), "Keyword"); -} - -#[test] -fn test_subjecttype_fromstr() { - use std::str::FromStr; - assert_eq!(SubjectType::from_str("BIC").unwrap(), SubjectType::Bic); - assert_eq!(SubjectType::from_str("BISAC").unwrap(), SubjectType::Bisac); - assert_eq!(SubjectType::from_str("Thema").unwrap(), SubjectType::Thema); - assert_eq!(SubjectType::from_str("LCC").unwrap(), SubjectType::Lcc); - assert_eq!( - SubjectType::from_str("Custom").unwrap(), - SubjectType::Custom - ); - assert_eq!( - SubjectType::from_str("Keyword").unwrap(), - SubjectType::Keyword - ); - - assert!(SubjectType::from_str("bic").is_err()); - assert!(SubjectType::from_str("Library of Congress Subject Code").is_err()); -} - -#[test] -fn test_check_subject() { - // Valid codes for specific schemas - assert!(check_subject(&SubjectType::Bic, "HRQX9").is_ok()); - assert!(check_subject(&SubjectType::Bisac, "BIB004060").is_ok()); - assert!(check_subject(&SubjectType::Thema, "ATXZ1").is_ok()); - - // Custom fields: no validity restrictions - assert!(check_subject(&SubjectType::Custom, "A custom subject").is_ok()); - assert!(check_subject(&SubjectType::Keyword, "keyword").is_ok()); - - // Invalid codes for specific schemas: only validate Thema - assert!(check_subject(&SubjectType::Bic, "ABCD0").is_ok()); - assert!(check_subject(&SubjectType::Bisac, "BLA123456").is_ok()); - assert!(check_subject(&SubjectType::Thema, "AHBW").is_err()); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; mod thema; +#[cfg(feature = "backend")] +pub(crate) use policy::SubjectPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/subject/policy.rs b/thoth-api/src/model/subject/policy.rs new file mode 100644 index 00000000..c111f2d5 --- /dev/null +++ b/thoth-api/src/model/subject/policy.rs @@ -0,0 +1,71 @@ +use crate::model::subject::{thema::THEMA_CODES, NewSubject, PatchSubject, Subject, SubjectType}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Subject`. +/// +/// For now this policy enforces the tenant boundary only: +/// - authentication +/// - publisher membership derived from the entity / input via `PublisherId` +pub struct SubjectPolicy; + +fn check_subject(subject_type: &SubjectType, code: &str) -> ThothResult<()> { + if matches!(subject_type, SubjectType::Thema) && THEMA_CODES.binary_search(&code).is_err() { + return Err(ThothError::InvalidSubjectCode { + input: code.to_string(), + subject_type: subject_type.to_string(), + }); + } + Ok(()) +} + +impl CreatePolicy<NewSubject> for SubjectPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewSubject, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + check_subject(&data.subject_type, &data.subject_code) + } +} + +impl UpdatePolicy<Subject, PatchSubject> for SubjectPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Subject, + patch: &PatchSubject, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + check_subject(&patch.subject_type, &patch.subject_code) + } +} + +impl DeletePolicy<Subject> for SubjectPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Subject) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} + +impl MovePolicy<Subject> for SubjectPolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &Subject) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} + +#[test] +fn test_check_subject() { + // Valid codes for specific schemas + assert!(check_subject(&SubjectType::Bic, "HRQX9").is_ok()); + assert!(check_subject(&SubjectType::Bisac, "BIB004060").is_ok()); + assert!(check_subject(&SubjectType::Thema, "ATXZ1").is_ok()); + + // Custom fields: no validity restrictions + assert!(check_subject(&SubjectType::Custom, "A custom subject").is_ok()); + assert!(check_subject(&SubjectType::Keyword, "keyword").is_ok()); + + // Invalid codes for specific schemas: only validate Thema + assert!(check_subject(&SubjectType::Bic, "ABCD0").is_ok()); + assert!(check_subject(&SubjectType::Bisac, "BLA123456").is_ok()); + assert!(check_subject(&SubjectType::Thema, "AHBW").is_err()); +} diff --git a/thoth-api/src/model/subject/tests.rs b/thoth-api/src/model/subject/tests.rs new file mode 100644 index 00000000..f625d732 --- /dev/null +++ b/thoth-api/src/model/subject/tests.rs @@ -0,0 +1,785 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_subject( + pool: &crate::db::PgPool, + work_id: Uuid, + subject_type: SubjectType, + subject_code: String, + subject_ordinal: i32, +) -> Subject { + let new_subject = NewSubject { + work_id, + subject_type, + subject_code, + subject_ordinal, + }; + + Subject::create(pool, &new_subject).expect("Failed to create subject") +} + +mod defaults { + use super::*; + + #[test] + fn subjecttype_default_is_keyword() { + let subjecttype: SubjectType = Default::default(); + assert_eq!(subjecttype, SubjectType::Keyword); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn subjecttype_display_formats_expected_strings() { + assert_eq!(format!("{}", SubjectType::Bic), "BIC"); + assert_eq!(format!("{}", SubjectType::Bisac), "BISAC"); + assert_eq!(format!("{}", SubjectType::Thema), "Thema"); + assert_eq!(format!("{}", SubjectType::Lcc), "LCC"); + assert_eq!(format!("{}", SubjectType::Custom), "Custom"); + assert_eq!(format!("{}", SubjectType::Keyword), "Keyword"); + } + + #[test] + fn subjecttype_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!(SubjectType::from_str("BIC").unwrap(), SubjectType::Bic); + assert_eq!(SubjectType::from_str("BISAC").unwrap(), SubjectType::Bisac); + assert_eq!(SubjectType::from_str("Thema").unwrap(), SubjectType::Thema); + assert_eq!(SubjectType::from_str("LCC").unwrap(), SubjectType::Lcc); + assert_eq!( + SubjectType::from_str("Custom").unwrap(), + SubjectType::Custom + ); + assert_eq!( + SubjectType::from_str("Keyword").unwrap(), + SubjectType::Keyword + ); + + assert!(SubjectType::from_str("bic").is_err()); + assert!(SubjectType::from_str("Library of Congress Subject Code").is_err()); + } +} + +#[cfg(feature = "backend")] +mod conversions { + use super::*; + use crate::model::tests::db::setup_test_db; + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn subjecttype_graphql_roundtrip() { + assert_graphql_enum_roundtrip(SubjectType::Bisac); + } + + #[test] + fn subjecttype_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::<SubjectType, crate::schema::sql_types::SubjectType>( + pool.as_ref(), + "'bisac'::subject_type", + SubjectType::Bisac, + ); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let subject: Subject = Default::default(); + assert_eq!(subject.pk(), subject.subject_id); + } + + #[test] + fn history_entry_serializes_model() { + let subject: Subject = Default::default(); + let user_id = "1234567".to_string(); + let new_subject_history = subject.new_history_entry(&user_id); + assert_eq!(new_subject_history.subject_id, subject.subject_id); + assert_eq!(new_subject_history.user_id, user_id); + assert_eq!( + new_subject_history.data, + serde_json::Value::String(serde_json::to_string(&subject).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::subject::policy::SubjectPolicy; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("subject-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let new_subject = NewSubject { + work_id: work.work_id, + subject_type: SubjectType::Thema, + subject_code: "ATXZ1".to_string(), + subject_ordinal: 1, + }; + + let subject = Subject::create(pool.as_ref(), &new_subject).expect("Failed to create"); + let patch = PatchSubject { + subject_id: subject.subject_id, + work_id: subject.work_id, + subject_type: subject.subject_type, + subject_code: subject.subject_code.clone(), + subject_ordinal: 2, + }; + + assert!(SubjectPolicy::can_create(&ctx, &new_subject, ()).is_ok()); + assert!(SubjectPolicy::can_update(&ctx, &subject, &patch, ()).is_ok()); + assert!(SubjectPolicy::can_delete(&ctx, &subject).is_ok()); + assert!(SubjectPolicy::can_move(&ctx, &subject).is_ok()); + } + + #[test] + fn crud_policy_rejects_invalid_thema_code() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("subject-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let new_subject = NewSubject { + work_id: work.work_id, + subject_type: SubjectType::Thema, + subject_code: "INVALID".to_string(), + subject_ordinal: 1, + }; + + assert!(SubjectPolicy::can_create(&ctx, &new_subject, ()).is_err()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let subject = make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Thema, + "ATXZ1".to_string(), + 1, + ); + let patch = PatchSubject { + subject_id: subject.subject_id, + work_id: subject.work_id, + subject_type: subject.subject_type, + subject_code: subject.subject_code.clone(), + subject_ordinal: 2, + }; + + let user = test_user_with_role("subject-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_subject = NewSubject { + work_id: work.work_id, + subject_type: SubjectType::Thema, + subject_code: "ATXZ1".to_string(), + subject_ordinal: 1, + }; + + assert!(SubjectPolicy::can_create(&ctx, &new_subject, ()).is_err()); + assert!(SubjectPolicy::can_update(&ctx, &subject, &patch, ()).is_err()); + assert!(SubjectPolicy::can_delete(&ctx, &subject).is_err()); + assert!(SubjectPolicy::can_move(&ctx, &subject).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + use crate::graphql::types::inputs::{Direction, SubjectOrderBy}; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context, + }; + use crate::model::{Crud, Reorder}; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let new_subject = NewSubject { + work_id: work.work_id, + subject_type: SubjectType::Keyword, + subject_code: "Test Subject".to_string(), + subject_ordinal: 1, + }; + + let subject = Subject::create(pool.as_ref(), &new_subject).expect("Failed to create"); + let fetched = + Subject::from_id(pool.as_ref(), &subject.subject_id).expect("Failed to fetch"); + assert_eq!(subject.subject_id, fetched.subject_id); + + let patch = PatchSubject { + subject_id: subject.subject_id, + work_id: subject.work_id, + subject_type: SubjectType::Custom, + subject_code: "Updated Subject".to_string(), + subject_ordinal: 2, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = subject.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.subject_code, patch.subject_code); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(Subject::from_id(pool.as_ref(), &deleted.subject_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject A".to_string(), + 1, + ); + make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject B".to_string(), + 2, + ); + + let order = SubjectOrderBy { + field: SubjectField::SubjectId, + direction: Direction::Asc, + }; + + let first = Subject::all( + pool.as_ref(), + 1, + 0, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch subjects"); + let second = Subject::all( + pool.as_ref(), + 1, + 1, + None, + SubjectOrderBy { + field: SubjectField::SubjectId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch subjects"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].subject_id, second[0].subject_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject A".to_string(), + 1, + ); + make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject B".to_string(), + 2, + ); + + let count = Subject::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count subjects"); + assert_eq!(count, 2); + } + + #[test] + fn crud_count_filters_by_subject_type() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject A".to_string(), + 1, + ); + make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Custom, + "Subject B".to_string(), + 2, + ); + + let count = Subject::count( + pool.as_ref(), + None, + vec![], + vec![SubjectType::Keyword], + vec![], + None, + None, + ) + .expect("Failed to count subjects by type"); + assert_eq!(count, 1); + } + + #[test] + fn crud_count_filters_by_subject_code() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "ABC123".to_string(), + 1, + ); + make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "XYZ999".to_string(), + 2, + ); + + let count = Subject::count( + pool.as_ref(), + Some("ABC".to_string()), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count subjects by code"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_filter_matches_subject_code() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let marker = "Keyword-123"; + let matches = make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + marker.to_string(), + 1, + ); + make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Other Subject".to_string(), + 2, + ); + + let filtered = Subject::all( + pool.as_ref(), + 10, + 0, + Some("Keyword-123".to_string()), + SubjectOrderBy { + field: SubjectField::SubjectId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter subjects"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].subject_id, matches.subject_id); + } + + #[test] + fn crud_filter_parent_work_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + + let matches = make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject A".to_string(), + 1, + ); + make_subject( + pool.as_ref(), + other_work.work_id, + SubjectType::Keyword, + "Subject B".to_string(), + 2, + ); + + let filtered = Subject::all( + pool.as_ref(), + 10, + 0, + None, + SubjectOrderBy { + field: SubjectField::SubjectId, + direction: Direction::Asc, + }, + vec![], + Some(work.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter subjects by work"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].subject_id, matches.subject_id); + } + + #[test] + fn crud_filter_param_limits_subject_types() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let matches = make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject A".to_string(), + 1, + ); + make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Custom, + "Subject B".to_string(), + 2, + ); + + let filtered = Subject::all( + pool.as_ref(), + 10, + 0, + None, + SubjectOrderBy { + field: SubjectField::SubjectId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![SubjectType::Keyword], + vec![], + None, + None, + ) + .expect("Failed to filter subjects by type"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].subject_id, matches.subject_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let matches = make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject A".to_string(), + 1, + ); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let other_work = create_work(pool.as_ref(), &other_imprint); + make_subject( + pool.as_ref(), + other_work.work_id, + SubjectType::Keyword, + "Subject B".to_string(), + 1, + ); + + let filtered = Subject::all( + pool.as_ref(), + 10, + 0, + None, + SubjectOrderBy { + field: SubjectField::SubjectId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter subjects by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].subject_id, matches.subject_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let first = make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject A".to_string(), + 1, + ); + let second = make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject B".to_string(), + 2, + ); + let mut ids = [first.subject_id, second.subject_id]; + ids.sort(); + + let asc = Subject::all( + pool.as_ref(), + 2, + 0, + None, + SubjectOrderBy { + field: SubjectField::SubjectId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order subjects (asc)"); + + let desc = Subject::all( + pool.as_ref(), + 2, + 0, + None, + SubjectOrderBy { + field: SubjectField::SubjectId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order subjects (desc)"); + + assert_eq!(asc[0].subject_id, ids[0]); + assert_eq!(desc[0].subject_id, ids[1]); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject A".to_string(), + 1, + ); + make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Custom, + "Subject B".to_string(), + 2, + ); + + let fields: Vec<fn() -> SubjectField> = vec![ + || SubjectField::SubjectId, + || SubjectField::WorkId, + || SubjectField::SubjectType, + || SubjectField::SubjectCode, + || SubjectField::SubjectOrdinal, + || SubjectField::CreatedAt, + || SubjectField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Subject::all( + pool.as_ref(), + 10, + 0, + None, + SubjectOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order subjects"); + + assert_eq!(results.len(), 2); + } + } + } + + #[test] + fn crud_change_ordinal_reorders_subjects() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let first = make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject A".to_string(), + 1, + ); + let second = make_subject( + pool.as_ref(), + work.work_id, + SubjectType::Keyword, + "Subject B".to_string(), + 2, + ); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = first + .change_ordinal(&ctx, first.subject_ordinal, 2) + .expect("Failed to change subject ordinal"); + + let refreshed_first = + Subject::from_id(pool.as_ref(), &updated.subject_id).expect("Failed to fetch"); + let refreshed_second = + Subject::from_id(pool.as_ref(), &second.subject_id).expect("Failed to fetch"); + + assert_eq!(refreshed_first.subject_ordinal, 2); + assert_eq!(refreshed_second.subject_ordinal, 1); + } +} diff --git a/thoth-api/src/model/tests.rs b/thoth-api/src/model/tests.rs new file mode 100644 index 00000000..2c97e2a5 --- /dev/null +++ b/thoth-api/src/model/tests.rs @@ -0,0 +1,919 @@ +use super::*; + +#[cfg(feature = "backend")] +use crate::db::PgPool; + +#[cfg(feature = "backend")] +pub(crate) mod db { + use std::collections::HashMap; + use std::env; + use std::fs::OpenOptions; + use std::sync::{Arc, OnceLock}; + use std::time::Duration; + + use diesel::pg::PgConnection; + use diesel::r2d2::ConnectionManager; + use diesel::RunQueryDsl; + use fs2::FileExt; + use uuid::Uuid; + use zitadel::actix::introspection::IntrospectedUser; + + use crate::db::{init_pool, run_migrations, PgPool}; + use crate::graphql::Context; + use crate::model::contribution::{Contribution, ContributionType, NewContribution}; + use crate::model::contributor::{Contributor, NewContributor}; + use crate::model::imprint::{Imprint, NewImprint}; + use crate::model::institution::{CountryCode, Institution, NewInstitution}; + use crate::model::publication::{NewPublication, Publication, PublicationType}; + use crate::model::publisher::{NewPublisher, Publisher}; + use crate::model::series::{NewSeries, Series, SeriesType}; + use crate::model::work::{NewWork, Work, WorkStatus, WorkType}; + use crate::model::Crud; + use crate::policy::Role; + use crate::storage::{create_cloudfront_client, create_s3_client, CloudFrontClient, S3Client}; + + static MIGRATIONS: OnceLock<Result<(), String>> = OnceLock::new(); + static POOL: OnceLock<Arc<PgPool>> = OnceLock::new(); + static CLIENTS: OnceLock<(Arc<S3Client>, Arc<CloudFrontClient>)> = OnceLock::new(); + + pub(crate) struct TestDbGuard { + _file: std::fs::File, + } + + pub(crate) fn test_lock() -> TestDbGuard { + let mut path = env::temp_dir(); + path.push("thoth_test_db.lock"); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .unwrap_or_else(|err| panic!("Failed to open lock file {path:?}: {err}")); + file.lock_exclusive() + .unwrap_or_else(|err| panic!("Failed to lock test DB file {path:?}: {err}")); + TestDbGuard { _file: file } + } + + pub(crate) fn test_db_url() -> String { + dotenv::dotenv().ok(); + env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set for backend tests") + } + + pub(crate) fn db_pool() -> Arc<PgPool> { + let url = test_db_url(); + let migrations = MIGRATIONS + .get_or_init(|| run_migrations(&url).map_err(|err| err.to_string())) + .clone(); + migrations.expect("Failed to run migrations for test DB"); + let pool = POOL.get_or_init(|| Arc::new(init_pool(&url))); + pool.clone() + } + + pub(crate) fn failing_pool() -> PgPool { + let manager = ConnectionManager::<PgConnection>::new( + "postgres://invalid:invalid@localhost:1/invalid", + ); + diesel::r2d2::Pool::builder() + .max_size(1) + .connection_timeout(Duration::from_millis(100)) + .build_unchecked(manager) + } + + fn test_clients() -> (Arc<S3Client>, Arc<CloudFrontClient>) { + let (s3_client, cloudfront_client) = CLIENTS.get_or_init(|| { + std::thread::spawn(|| { + let runtime = + tokio::runtime::Runtime::new().expect("Failed to build Tokio runtime"); + runtime.block_on(async { + let s3 = + create_s3_client("test-access-key", "test-secret-key", "us-east-1").await; + let cloudfront = + create_cloudfront_client("test-access-key", "test-secret-key", "us-east-1") + .await; + (Arc::new(s3), Arc::new(cloudfront)) + }) + }) + .join() + .expect("Failed to initialize AWS clients") + }); + (Arc::clone(s3_client), Arc::clone(cloudfront_client)) + } + + pub(crate) fn reset_db(pool: &PgPool) -> Result<(), diesel::result::Error> { + let mut connection = pool.get().expect("Failed to get DB connection"); + let sql = r#" +DO $$ +DECLARE + tbls TEXT; +BEGIN + SELECT string_agg(format('%I.%I', schemaname, tablename), ', ') + INTO tbls + FROM pg_tables + WHERE schemaname = 'public' + AND tablename != '__diesel_schema_migrations'; + + IF tbls IS NOT NULL THEN + EXECUTE 'TRUNCATE TABLE ' || tbls || ' RESTART IDENTITY CASCADE'; + END IF; +END $$; +"#; + diesel::sql_query(sql).execute(&mut connection).map(|_| ()) + } + + pub(crate) fn setup_test_db() -> (TestDbGuard, Arc<PgPool>) { + let guard = test_lock(); + let pool = db_pool(); + reset_db(&pool).expect("Failed to reset DB"); + (guard, pool) + } + + fn test_user(user_id: &str) -> IntrospectedUser { + IntrospectedUser { + user_id: user_id.to_string(), + username: None, + name: None, + given_name: None, + family_name: None, + preferred_username: None, + email: None, + email_verified: None, + locale: None, + project_roles: None, + metadata: None, + } + } + + pub(crate) fn test_context(pool: Arc<PgPool>, user_id: &str) -> Context { + let (s3_client, cloudfront_client) = test_clients(); + Context::new(pool, Some(test_user(user_id)), s3_client, cloudfront_client) + } + + pub(crate) fn test_user_with_role(user_id: &str, role: Role, org_id: &str) -> IntrospectedUser { + let mut scoped = HashMap::new(); + scoped.insert(org_id.to_string(), "role".to_string()); + let mut project_roles = HashMap::new(); + project_roles.insert(role.as_ref().to_string(), scoped); + + IntrospectedUser { + user_id: user_id.to_string(), + username: None, + name: None, + given_name: None, + family_name: None, + preferred_username: None, + email: None, + email_verified: None, + locale: None, + project_roles: Some(project_roles), + metadata: None, + } + } + + pub(crate) fn test_superuser(user_id: &str) -> IntrospectedUser { + let mut project_roles = HashMap::new(); + project_roles.insert(Role::Superuser.as_ref().to_string(), HashMap::new()); + + IntrospectedUser { + user_id: user_id.to_string(), + username: None, + name: None, + given_name: None, + family_name: None, + preferred_username: None, + email: None, + email_verified: None, + locale: None, + project_roles: Some(project_roles), + metadata: None, + } + } + + pub(crate) fn test_context_with_user(pool: Arc<PgPool>, user: IntrospectedUser) -> Context { + let (s3_client, cloudfront_client) = test_clients(); + Context::new(pool, Some(user), s3_client, cloudfront_client) + } + + pub(crate) fn test_context_anonymous(pool: Arc<PgPool>) -> Context { + let (s3_client, cloudfront_client) = test_clients(); + Context::new(pool, None, s3_client, cloudfront_client) + } + + pub(crate) fn create_publisher(pool: &PgPool) -> Publisher { + let org_id = format!("org-{}", Uuid::new_v4()); + let new_publisher = NewPublisher { + publisher_name: format!("DB Publisher {}", Uuid::new_v4()), + publisher_shortname: None, + publisher_url: None, + zitadel_id: Some(org_id), + accessibility_statement: None, + accessibility_report_url: None, + }; + + Publisher::create(pool, &new_publisher).expect("Failed to create publisher in DB") + } + + pub(crate) fn create_imprint(pool: &PgPool, publisher: &Publisher) -> Imprint { + let new_imprint = NewImprint { + publisher_id: publisher.publisher_id, + imprint_name: format!("DB Imprint {}", Uuid::new_v4()), + imprint_url: None, + crossmark_doi: None, + s3_bucket: None, + cdn_domain: None, + cloudfront_dist_id: None, + default_currency: None, + default_place: None, + default_locale: None, + }; + + Imprint::create(pool, &new_imprint).expect("Failed to create imprint in DB") + } + + pub(crate) fn create_contributor(pool: &PgPool) -> Contributor { + let suffix = Uuid::new_v4(); + let new_contributor = NewContributor { + first_name: Some("Test".to_string()), + last_name: format!("Contributor {suffix}"), + full_name: format!("Test Contributor {suffix}"), + orcid: None, + website: None, + }; + + Contributor::create(pool, &new_contributor).expect("Failed to create contributor in DB") + } + + pub(crate) fn create_institution(pool: &PgPool) -> Institution { + let new_institution = NewInstitution { + institution_name: format!("Institution {}", Uuid::new_v4()), + institution_doi: None, + ror: None, + country_code: Some(CountryCode::Gbr), + }; + + Institution::create(pool, &new_institution).expect("Failed to create institution in DB") + } + + pub(crate) fn create_series(pool: &PgPool, imprint: &Imprint) -> Series { + let new_series = NewSeries { + series_type: SeriesType::Journal, + series_name: format!("Series {}", Uuid::new_v4()), + issn_print: None, + issn_digital: None, + series_url: None, + series_description: None, + series_cfp_url: None, + imprint_id: imprint.imprint_id, + }; + + Series::create(pool, &new_series).expect("Failed to create series in DB") + } + + pub(crate) fn create_work(pool: &PgPool, imprint: &Imprint) -> Work { + let new_work = NewWork { + work_type: WorkType::Monograph, + work_status: WorkStatus::Forthcoming, + reference: None, + edition: Some(1), + imprint_id: imprint.imprint_id, + doi: None, + publication_date: None, + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: None, + last_page: None, + page_interval: None, + }; + + Work::create(pool, &new_work).expect("Failed to create work in DB") + } + + pub(crate) fn create_contribution( + pool: &PgPool, + work: &Work, + contributor: &Contributor, + ) -> Contribution { + let new_contribution = NewContribution { + work_id: work.work_id, + contributor_id: contributor.contributor_id, + contribution_type: ContributionType::Author, + main_contribution: true, + first_name: contributor.first_name.clone(), + last_name: contributor.last_name.clone(), + full_name: contributor.full_name.clone(), + contribution_ordinal: 1, + }; + + Contribution::create(pool, &new_contribution).expect("Failed to create contribution in DB") + } + + pub(crate) fn create_publication(pool: &PgPool, work: &Work) -> Publication { + let new_publication = NewPublication { + publication_type: PublicationType::Paperback, + work_id: work.work_id, + isbn: None, + width_mm: None, + width_in: None, + height_mm: None, + height_in: None, + depth_mm: None, + depth_in: None, + weight_g: None, + weight_oz: None, + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + }; + + Publication::create(pool, &new_publication).expect("Failed to create publication in DB") + } +} + +#[cfg(feature = "backend")] +pub(crate) fn assert_graphql_enum_roundtrip<E>(value: E) +where + E: juniper::FromInputValue<juniper::DefaultScalarValue> + + juniper::ToInputValue<juniper::DefaultScalarValue> + + juniper::GraphQLType<juniper::DefaultScalarValue> + + juniper::GraphQLValue<juniper::DefaultScalarValue, Context = (), TypeInfo = ()> + + PartialEq + + std::fmt::Debug + + Clone, + <E as juniper::FromInputValue<juniper::DefaultScalarValue>>::Error: std::fmt::Debug, +{ + let _ = <E as juniper::GraphQLType<juniper::DefaultScalarValue>>::name(&()); + let mut registry = juniper::Registry::new(Default::default()); + let _ = <E as juniper::GraphQLType<juniper::DefaultScalarValue>>::meta(&(), &mut registry); + let _ = <E as juniper::GraphQLValue<juniper::DefaultScalarValue>>::type_name(&value, &()); + + let input = value.to_input_value(); + let parsed = E::from_input_value(&input).expect("GraphQL enum should parse"); + assert_eq!(parsed, value); +} + +#[cfg(feature = "backend")] +pub(crate) fn assert_db_enum_to_sql<E, ST>(pool: &PgPool, value: &E) +where + E: diesel::serialize::ToSql<ST, diesel::pg::Pg> + + diesel::serialize::ToSql<diesel::sql_types::Nullable<ST>, diesel::pg::Pg> + + std::fmt::Debug, + ST: diesel::sql_types::SingleValue + diesel::sql_types::SqlType, + diesel::pg::Pg: diesel::sql_types::HasSqlType<ST> + + diesel::sql_types::HasSqlType<diesel::sql_types::Nullable<ST>>, +{ + use diesel::pg::PgMetadataLookup; + use diesel::query_builder::bind_collector::RawBytesBindCollector; + use diesel::query_builder::BindCollector; + + let mut connection = pool.get().expect("Failed to get DB connection"); + let mut collector = RawBytesBindCollector::<diesel::pg::Pg>::new(); + let metadata_lookup: &mut dyn PgMetadataLookup = &mut *connection; + collector + .push_bound_value::<ST, _>(value, metadata_lookup) + .expect("Failed to serialize DB enum"); + collector + .push_bound_value::<diesel::sql_types::Nullable<ST>, _>(value, metadata_lookup) + .expect("Failed to serialize DB enum (nullable)"); +} + +#[cfg(feature = "backend")] +pub(crate) fn assert_db_enum_as_expression<E, ST>(value: E) +where + E: diesel::expression::AsExpression<ST> + + diesel::expression::AsExpression<diesel::sql_types::Nullable<ST>> + + Copy, + for<'a> &'a E: diesel::expression::AsExpression<ST> + + diesel::expression::AsExpression<diesel::sql_types::Nullable<ST>>, + for<'a> &'a &'a E: diesel::expression::AsExpression<ST> + + diesel::expression::AsExpression<diesel::sql_types::Nullable<ST>>, + ST: diesel::sql_types::SqlType + + diesel::expression::TypedExpressionType + + diesel::sql_types::SingleValue, +{ + let _ = <E as diesel::expression::AsExpression<ST>>::as_expression(value); + let _ = <E as diesel::expression::AsExpression<diesel::sql_types::Nullable<ST>>>::as_expression( + value, + ); + let value_ref = &value; + let _ = <&E as diesel::expression::AsExpression<ST>>::as_expression(value_ref); + let _ = + <&E as diesel::expression::AsExpression<diesel::sql_types::Nullable<ST>>>::as_expression( + value_ref, + ); + let value_ref_ref = &value_ref; + let _ = <&&E as diesel::expression::AsExpression<ST>>::as_expression(value_ref_ref); + let _ = + <&&E as diesel::expression::AsExpression<diesel::sql_types::Nullable<ST>>>::as_expression( + value_ref_ref, + ); +} + +#[cfg(feature = "backend")] +pub(crate) fn assert_db_enum_queryable<E, ST>(value: E) +where + E: diesel::Queryable<ST, diesel::pg::Pg, Row = E> + Copy, +{ + let _ = <E as diesel::Queryable<ST, diesel::pg::Pg>>::build(value) + .expect("Failed to build DB enum via Queryable"); +} + +#[cfg(feature = "backend")] +pub(crate) fn assert_db_enum_roundtrip<E, ST>(pool: &PgPool, literal: &str, expected: E) +where + E: diesel::deserialize::FromSqlRow<ST, diesel::pg::Pg> + + diesel::serialize::ToSql<ST, diesel::pg::Pg> + + diesel::serialize::ToSql<diesel::sql_types::Nullable<ST>, diesel::pg::Pg> + + diesel::expression::AsExpression<ST> + + diesel::expression::AsExpression<diesel::sql_types::Nullable<ST>> + + diesel::Queryable<ST, diesel::pg::Pg, Row = E> + + Copy + + PartialEq + + std::fmt::Debug + + 'static, + for<'a> &'a E: diesel::expression::AsExpression<ST> + + diesel::expression::AsExpression<diesel::sql_types::Nullable<ST>>, + for<'a> &'a &'a E: diesel::expression::AsExpression<ST> + + diesel::expression::AsExpression<diesel::sql_types::Nullable<ST>>, + ST: diesel::sql_types::SingleValue + + diesel::sql_types::SqlType + + diesel::expression::TypedExpressionType, + diesel::pg::Pg: diesel::sql_types::HasSqlType<ST>, +{ + use diesel::dsl::sql; + use diesel::prelude::*; + + assert_db_enum_as_expression::<E, ST>(expected); + assert_db_enum_queryable::<E, ST>(expected); + assert_db_enum_to_sql::<E, ST>(pool, &expected); + + let mut connection = pool.get().expect("Failed to get DB connection"); + let fetched: E = diesel::select(sql::<ST>(literal)) + .get_result(&mut connection) + .expect("Failed to roundtrip DB enum"); + + assert_eq!(fetched, expected); +} + +mod publisher_ids { + use crate::model::tests::db::{create_imprint, create_publisher, create_work, setup_test_db}; + use crate::model::work_relation::{NewWorkRelation, RelationType, WorkRelation}; + use crate::model::{Crud, PublisherId, PublisherIds}; + + #[test] + fn publisher_id_zitadel_id_resolves_from_related_publisher() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let zitadel_id = work + .zitadel_id(pool.as_ref()) + .expect("Failed to resolve publisher zitadel id"); + assert_eq!(zitadel_id, publisher.zitadel_id.clone().unwrap()); + } + + #[test] + fn publisher_ids_zitadel_ids_returns_sorted_unique_ids() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let relator = create_work(pool.as_ref(), &imprint); + let related = create_work(pool.as_ref(), &other_imprint); + + let new_relation = NewWorkRelation { + relator_work_id: relator.work_id, + related_work_id: related.work_id, + relation_type: RelationType::HasPart, + relation_ordinal: 1, + }; + let relation = + WorkRelation::create(pool.as_ref(), &new_relation).expect("Failed to create relation"); + + let mut expected = vec![ + publisher.zitadel_id.clone().unwrap(), + other_publisher.zitadel_id.clone().unwrap(), + ]; + expected.sort(); + + let ids = relation + .zitadel_ids(pool.as_ref()) + .expect("Failed to resolve publisher zitadel ids"); + assert_eq!(ids, expected); + } +} + +#[cfg(feature = "backend")] +mod db_errors { + use crate::model::publisher::Publisher; + use crate::model::tests::db::failing_pool; + use crate::model::Crud; + use uuid::Uuid; + + #[test] + fn failing_pool_returns_error() { + let pool = failing_pool(); + let result = Publisher::from_id(&pool, &Uuid::new_v4()); + assert!(result.is_err()); + } +} + +#[test] +fn test_doi_default() { + let doi: Doi = Default::default(); + assert_eq!(doi, Doi("".to_string())); +} + +#[test] +fn test_isbn_default() { + let isbn: Isbn = Default::default(); + assert_eq!(isbn, Isbn("".to_string())); +} + +#[test] +fn test_orcid_default() { + let orcid: Orcid = Default::default(); + assert_eq!(orcid, Orcid("".to_string())); +} + +#[test] +fn test_ror_default() { + let ror: Ror = Default::default(); + assert_eq!(ror, Ror("".to_string())); +} + +#[test] +fn test_timestamp_default() { + let stamp: Timestamp = Default::default(); + assert_eq!( + stamp, + Timestamp(TimeZone::timestamp_opt(&Utc, 0, 0).unwrap()) + ); +} + +#[test] +fn test_doi_display() { + let doi = Doi("https://doi.org/10.12345/Test-Suffix.01".to_string()); + assert_eq!(format!("{doi}"), "10.12345/Test-Suffix.01"); +} + +#[test] +fn test_isbn_display() { + let isbn = Isbn("978-3-16-148410-0".to_string()); + assert_eq!(format!("{isbn}"), "978-3-16-148410-0"); +} + +#[test] +fn test_orcid_display() { + let orcid = Orcid("https://orcid.org/0000-0002-1234-5678".to_string()); + assert_eq!(format!("{orcid}"), "0000-0002-1234-5678"); +} + +#[test] +fn test_ror_display() { + let ror = Ror("https://ror.org/0abcdef12".to_string()); + assert_eq!(format!("{ror}"), "0abcdef12"); +} + +#[test] +fn test_timestamp_display() { + let stamp: Timestamp = Default::default(); + assert_eq!(format!("{stamp}"), "1970-01-01 00:00:00"); +} + +#[test] +fn test_doi_fromstr() { + let standardised = Doi("https://doi.org/10.12345/Test-Suffix.01".to_string()); + assert_eq!( + Doi::from_str("https://doi.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("http://doi.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("doi.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("HTTPS://DOI.ORG/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("Https://DOI.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("https://www.doi.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("http://www.doi.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("www.doi.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("https://dx.doi.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("http://dx.doi.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("dx.doi.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("https://www.dx.doi.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("http://www.dx.doi.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert_eq!( + Doi::from_str("www.dx.doi.org/10.12345/Test-Suffix.01").unwrap(), + standardised + ); + assert!(Doi::from_str("htts://doi.org/10.12345/Test-Suffix.01").is_err()); + assert!(Doi::from_str("https://10.12345/Test-Suffix.01").is_err()); + assert!(Doi::from_str("https://test.org/10.12345/Test-Suffix.01").is_err()); + assert!(Doi::from_str("http://test.org/10.12345/Test-Suffix.01").is_err()); + assert!(Doi::from_str("test.org/10.12345/Test-Suffix.01").is_err()); + assert!(Doi::from_str("//doi.org/10.12345/Test-Suffix.01").is_err()); + assert!(Doi::from_str("https://doi-org/10.12345/Test-Suffix.01").is_err()); + assert!(Doi::from_str("10.https://doi.org/12345/Test-Suffix.01").is_err()); + assert!(Doi::from_str("http://dx.doi.org/10.2990/1471-5457(2005)24[2:tmpwac]2.0.co;2").is_ok()); + assert!(Doi::from_str( + "https://doi.org/10.1002/(SICI)1098-2736(199908)36:6<637::AID-TEA4>3.0.CO;2-9" + ) + .is_ok()); + assert!(Doi::from_str( + "https://doi.org/10.1002/(sici)1096-8644(1996)23+<91::aid-ajpa4>3.0.co;2-c" + ) + .is_ok()); +} + +#[test] +fn doi_fromstr_rejects_empty_input() { + assert!(matches!(Doi::from_str(""), Err(ThothError::DoiEmptyError))); +} + +#[test] +fn doi_fromstr_rejects_invalid_input() { + let result = Doi::from_str("not-a-doi"); + assert!(matches!(result, Err(ThothError::DoiParseError(_)))); +} + +#[test] +fn test_isbn_fromstr() { + // Note the `isbn2` crate contains tests of valid/invalid ISBN values - + // this focuses on testing that a valid ISBN in any format is standardised + let standardised = Isbn("978-3-16-148410-0".to_string()); + assert_eq!(Isbn::from_str("978-3-16-148410-0").unwrap(), standardised); + assert_eq!(Isbn::from_str("9783161484100").unwrap(), standardised); + assert_eq!(Isbn::from_str("978 3 16 148410 0").unwrap(), standardised); + assert_eq!(Isbn::from_str("978 3 16-148410-0").unwrap(), standardised); + assert_eq!(Isbn::from_str("9-7-831614-8-4-100").unwrap(), standardised); + assert_eq!( + Isbn::from_str(" 97831 614 84 100 ").unwrap(), + standardised + ); + assert_eq!( + Isbn::from_str("---97--831614----8-4100--").unwrap(), + standardised + ); + assert!(Isbn::from_str("978-3-16-148410-1").is_err()); + assert!(Isbn::from_str("1234567890123").is_err()); + assert!(Isbn::from_str("0-684-84328-5").is_err()); + assert!(Isbn::from_str("abcdef").is_err()); +} + +#[test] +fn isbn_fromstr_rejects_empty_input() { + assert!(matches!( + Isbn::from_str(""), + Err(ThothError::IsbnEmptyError) + )); +} + +#[test] +fn isbn_fromstr_rejects_garbage_input() { + let result = Isbn::from_str("not-an-isbn"); + assert!(matches!(result, Err(ThothError::IsbnParseError(_)))); +} + +#[test] +fn test_orcid_fromstr() { + let standardised = Orcid("https://orcid.org/0000-0002-1234-5678".to_string()); + assert_eq!( + Orcid::from_str("https://orcid.org/0000-0002-1234-5678").unwrap(), + standardised + ); + assert_eq!( + Orcid::from_str("http://orcid.org/0000-0002-1234-5678").unwrap(), + standardised + ); + assert_eq!( + Orcid::from_str("orcid.org/0000-0002-1234-5678").unwrap(), + standardised + ); + assert_eq!( + Orcid::from_str("0000-0002-1234-5678").unwrap(), + standardised + ); + assert_eq!( + Orcid::from_str("HTTPS://ORCID.ORG/0000-0002-1234-5678").unwrap(), + standardised + ); + assert_eq!( + Orcid::from_str("Https://ORCiD.org/0000-0002-1234-5678").unwrap(), + standardised + ); + assert_eq!( + Orcid::from_str("https://www.orcid.org/0000-0002-1234-5678").unwrap(), + standardised + ); + assert_eq!( + Orcid::from_str("http://www.orcid.org/0000-0002-1234-5678").unwrap(), + standardised + ); + assert_eq!( + Orcid::from_str("www.orcid.org/0000-0002-1234-5678").unwrap(), + standardised + ); + assert!(Orcid::from_str("htts://orcid.org/0000-0002-1234-5678").is_err()); + assert!(Orcid::from_str("https://0000-0002-1234-5678").is_err()); + assert!(Orcid::from_str("https://test.org/0000-0002-1234-5678").is_err()); + assert!(Orcid::from_str("http://test.org/0000-0002-1234-5678").is_err()); + assert!(Orcid::from_str("test.org/0000-0002-1234-5678").is_err()); + assert!(Orcid::from_str("//orcid.org/0000-0002-1234-5678").is_err()); + assert!(Orcid::from_str("https://orcid-org/0000-0002-1234-5678").is_err()); + assert!(Orcid::from_str("0000-0002-1234-5678https://orcid.org/").is_err()); + assert!(Orcid::from_str("0009-0002-1234-567X").is_ok()); +} + +#[test] +fn orcid_fromstr_rejects_empty_input() { + assert!(matches!( + Orcid::from_str(""), + Err(ThothError::OrcidEmptyError) + )); +} + +#[test] +fn orcid_fromstr_rejects_invalid_input() { + let result = Orcid::from_str("0000-0002-1234-567"); + assert!(matches!(result, Err(ThothError::OrcidParseError(_)))); +} + +#[test] +fn test_ror_fromstr() { + let standardised = Ror("https://ror.org/0abcdef12".to_string()); + assert_eq!( + Ror::from_str("https://ror.org/0abcdef12").unwrap(), + standardised + ); + assert_eq!( + Ror::from_str("http://ror.org/0abcdef12").unwrap(), + standardised + ); + assert_eq!(Ror::from_str("ror.org/0abcdef12").unwrap(), standardised); + assert_eq!(Ror::from_str("0abcdef12").unwrap(), standardised); + assert_eq!( + Ror::from_str("HTTPS://ROR.ORG/0abcdef12").unwrap(), + standardised + ); + assert_eq!( + Ror::from_str("Https://Ror.org/0abcdef12").unwrap(), + standardised + ); + assert_eq!( + Ror::from_str("https://www.ror.org/0abcdef12").unwrap(), + standardised + ); + // Testing shows that while leading http://ror and https://www.ror + // resolve successfully, leading www.ror and http://www.ror do not. + assert!(Ror::from_str("http://www.ror.org/0abcdef12").is_err()); + assert!(Ror::from_str("www.ror.org/0abcdef12").is_err()); + assert!(Ror::from_str("htts://ror.org/0abcdef12").is_err()); + assert!(Ror::from_str("https://0abcdef12").is_err()); + assert!(Ror::from_str("https://test.org/0abcdef12").is_err()); + assert!(Ror::from_str("http://test.org/0abcdef12").is_err()); + assert!(Ror::from_str("test.org/0abcdef12").is_err()); + assert!(Ror::from_str("//ror.org/0abcdef12").is_err()); + assert!(Ror::from_str("https://ror-org/0abcdef12").is_err()); + assert!(Ror::from_str("0abcdef12https://ror.org/").is_err()); +} + +#[test] +fn ror_fromstr_rejects_empty_input() { + assert!(matches!(Ror::from_str(""), Err(ThothError::RorEmptyError))); +} + +#[test] +fn ror_fromstr_rejects_invalid_input() { + let result = Ror::from_str("not-a-ror"); + assert!(matches!(result, Err(ThothError::RorParseError(_)))); +} + +#[test] +fn test_isbn_to_hyphenless_string() { + let hyphenless_isbn = Isbn("978-3-16-148410-0".to_string()).to_hyphenless_string(); + assert_eq!(hyphenless_isbn, "9783161484100"); +} + +#[test] +fn test_orcid_to_hyphenless_string() { + let hyphenless_orcid = + Orcid("https://orcid.org/0000-0002-1234-5678".to_string()).to_hyphenless_string(); + assert_eq!(hyphenless_orcid, "0000000212345678"); +} + +#[test] +fn test_doi_with_domain() { + let doi = "https://doi.org/10.12345/Test-Suffix.01"; + assert_eq!(format!("{}", Doi(doi.to_string()).with_domain()), doi); +} + +#[test] +fn test_orcid_with_domain() { + let orcid = "https://orcid.org/0000-0002-1234-5678"; + assert_eq!(format!("{}", Orcid(orcid.to_string()).with_domain()), orcid); +} + +#[test] +fn test_ror_with_domain() { + let ror = "https://ror.org/0abcdef12"; + assert_eq!(format!("{}", Ror(ror.to_string()).with_domain()), ror); +} + +#[test] +fn test_timestamp_parse_from_rfc3339_valid() { + let input = "1999-12-31T23:59:00Z"; + let timestamp = Timestamp::parse_from_rfc3339(input); + assert!(timestamp.is_ok()); + + let expected = Timestamp(Utc.with_ymd_and_hms(1999, 12, 31, 23, 59, 0).unwrap()); + assert_eq!(timestamp.unwrap(), expected); +} + +#[test] +fn test_timestamp_parse_from_rfc3339_invalid_format() { + let input = "1999-12-31 23:59:00"; // Missing 'T' and 'Z' + let timestamp = Timestamp::parse_from_rfc3339(input); + assert!(timestamp.is_err()); +} + +#[test] +fn test_timestamp_parse_from_rfc3339_invalid_date() { + let input = "1999-02-30T23:59:00Z"; // Invalid date + let timestamp = Timestamp::parse_from_rfc3339(input); + assert!(timestamp.is_err()); +} + +#[test] +fn test_timestamp_to_rfc3339() { + let timestamp = Timestamp(Utc.with_ymd_and_hms(1999, 12, 31, 23, 59, 0).unwrap()); + assert_eq!(timestamp.to_rfc3339(), "1999-12-31T23:59:00+00:00"); +} + +#[test] +fn test_timestamp_round_trip_rfc3339_conversion() { + let original_string = "2023-11-13T12:34:56Z"; + let timestamp = Timestamp::parse_from_rfc3339(original_string).unwrap(); + let converted_string = timestamp.to_rfc3339(); + + let round_trip_timestamp = Timestamp::parse_from_rfc3339(&converted_string).unwrap(); + assert_eq!(timestamp, round_trip_timestamp); +} diff --git a/thoth-api/src/model/title/crud.rs b/thoth-api/src/model/title/crud.rs new file mode 100644 index 00000000..ff717db2 --- /dev/null +++ b/thoth-api/src/model/title/crud.rs @@ -0,0 +1,159 @@ +use super::{ + LocaleCode, NewTitle, NewTitleHistory, PatchTitle, Title, TitleField, TitleHistory, + TitleOrderBy, +}; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; +use crate::schema::{title_history, work_title}; +use diesel::{ + BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, +}; +use thoth_errors::ThothResult; +use uuid::Uuid; + +impl Title { + pub(crate) fn canonical_from_work_id( + db: &crate::db::PgPool, + work_id: &Uuid, + ) -> ThothResult<Self> { + let mut connection = db.get()?; + work_title::table + .filter(work_title::work_id.eq(work_id)) + .filter(work_title::canonical.eq(true)) + .first::<Title>(&mut connection) + .map_err(Into::into) + } +} + +impl Crud for Title { + type NewEntity = NewTitle; + type PatchEntity = PatchTitle; + type OrderByEntity = TitleOrderBy; + type FilterParameter1 = LocaleCode; + type FilterParameter2 = (); + type FilterParameter3 = (); + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.title_id + } + + fn all( + db: &crate::db::PgPool, + limit: i32, + offset: i32, + filter: Option<String>, + order: Self::OrderByEntity, + _: Vec<Uuid>, + parent_id_1: Option<Uuid>, + _: Option<Uuid>, + locale_codes: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<Vec<Title>> { + use crate::schema::work_title::dsl::*; + + let mut connection = db.get()?; + let mut query = work_title + .select(crate::schema::work_title::all_columns) + .into_boxed(); + + query = match order.field { + TitleField::TitleId => { + apply_directional_order!(query, order.direction, order, title_id) + } + TitleField::WorkId => apply_directional_order!(query, order.direction, order, work_id), + TitleField::LocaleCode => { + apply_directional_order!(query, order.direction, order, locale_code) + } + TitleField::FullTitle => { + apply_directional_order!(query, order.direction, order, full_title) + } + TitleField::Title => apply_directional_order!(query, order.direction, order, title), + TitleField::Subtitle => { + apply_directional_order!(query, order.direction, order, subtitle) + } + TitleField::Canonical => { + apply_directional_order!(query, order.direction, order, canonical) + } + }; + + if let Some(filter) = filter { + query = query.filter( + full_title + .ilike(format!("%{filter}%")) + .or(title.ilike(format!("%{filter}%"))) + .or(subtitle.ilike(format!("%{filter}%"))), + ); + } + + if let Some(pid) = parent_id_1 { + query = query.filter(work_id.eq(pid)); + } + + if !locale_codes.is_empty() { + query = query.filter(locale_code.eq_any(locale_codes)); + } + + query + .limit(limit.into()) + .offset(offset.into()) + .load::<Title>(&mut connection) + .map_err(Into::into) + } + + fn count( + db: &crate::db::PgPool, + filter: Option<String>, + _: Vec<Uuid>, + _: Vec<Self::FilterParameter1>, + _: Vec<Self::FilterParameter2>, + _: Option<Self::FilterParameter3>, + _: Option<Self::FilterParameter4>, + ) -> ThothResult<i32> { + use crate::schema::work_title::dsl::{full_title, subtitle, title, work_title}; + + let mut connection = db.get()?; + let mut query = work_title.into_boxed(); + + if let Some(filter) = filter { + query = query.filter( + full_title + .ilike(format!("%{filter}%")) + .or(title.ilike(format!("%{filter}%"))) + .or(subtitle.ilike(format!("%{filter}%"))), + ); + } + + query + .count() + .get_result::<i64>(&mut connection) + .map(|t| t.to_string().parse::<i32>().unwrap()) + .map_err(Into::into) + } + + crud_methods!(work_title::table, work_title::dsl::work_title); +} + +publisher_id_impls!(Title, NewTitle, PatchTitle, |s, db| { + let work = crate::model::work::Work::from_id(db, &s.work_id)?; + <crate::model::work::Work as PublisherId>::publisher_id(&work, db) +}); + +impl HistoryEntry for Title { + type NewHistoryEntity = NewTitleHistory; + + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { + Self::NewHistoryEntity { + title_id: self.title_id, + user_id: user_id.to_string(), + data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), + } + } +} + +impl DbInsert for NewTitleHistory { + type MainEntity = TitleHistory; + + db_insert!(title_history::table); +} diff --git a/thoth-api/src/model/title/mod.rs b/thoth-api/src/model/title/mod.rs new file mode 100644 index 00000000..b3401bf6 --- /dev/null +++ b/thoth-api/src/model/title/mod.rs @@ -0,0 +1,204 @@ +use crate::markup::{convert_to_jats, ConversionLimit, MarkupFormat}; +use crate::model::locale::LocaleCode; +use serde::{Deserialize, Serialize}; +use thoth_errors::ThothResult; +use uuid::Uuid; + +use crate::graphql::types::inputs::Direction; + +#[cfg(feature = "backend")] +use crate::schema::title_history; +#[cfg(feature = "backend")] +use crate::schema::work_title; + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLEnum), + graphql(description = "Field to use when sorting title list") +)] +pub enum TitleField { + TitleId, + WorkId, + FullTitle, + Title, + Subtitle, + Canonical, + LocaleCode, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject), + graphql(description = "Field and order to use when sorting titles list") +)] +pub struct TitleOrderBy { + pub field: TitleField, + pub direction: Direction, +} + +impl Default for TitleOrderBy { + fn default() -> Self { + Self { + field: TitleField::Canonical, + direction: Direction::Desc, + } + } +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Title { + pub title_id: Uuid, + pub work_id: Uuid, + pub full_title: String, + pub title: String, + pub subtitle: Option<String>, + pub canonical: bool, + pub locale_code: LocaleCode, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::Insertable, Clone), + graphql(description = "Set of values required to define a new work's title"), + diesel(table_name = work_title) +)] +#[derive(Default)] +pub struct NewTitle { + pub work_id: Uuid, + pub locale_code: LocaleCode, + pub full_title: String, + pub title: String, + pub subtitle: Option<String>, + pub canonical: bool, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::AsChangeset, Clone), + graphql(description = "Set of values required to update an existing work's title"), + diesel(table_name = work_title, treat_none_as_null = true) +)] +pub struct PatchTitle { + pub title_id: Uuid, + pub work_id: Uuid, + pub locale_code: LocaleCode, + pub full_title: String, + pub title: String, + pub subtitle: Option<String>, + pub canonical: bool, +} + +#[cfg_attr( + feature = "backend", + derive(diesel::Insertable), + diesel(table_name = title_history) +)] +pub struct NewTitleHistory { + pub title_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +pub struct TitleHistory { + pub title_history_id: Uuid, + pub title_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, + pub timestamp: chrono::DateTime<chrono::Utc>, +} + +pub trait TitleProperties { + fn title(&self) -> &str; + fn subtitle(&self) -> Option<&str>; + fn full_title(&self) -> &str; + fn locale_code(&self) -> &LocaleCode; + fn canonical(&self) -> bool; + fn compile_fulltitle(&self) -> String { + self.subtitle().map_or_else( + || self.title().to_string(), + |_subtitle| { + let _title = self.title(); + let _title = if _title.is_empty() { + "Untitled" + } else { + _title + }; + if _title.ends_with('?') + || _title.ends_with('!') + || _title.ends_with(':') + || _title.ends_with('.') + { + format!("{_title} {_subtitle}") + } else { + format!("{_title}: {_subtitle}") + } + }, + ) + } + fn set_title(&mut self, value: String); + fn set_subtitle(&mut self, value: Option<String>); + fn set_full_title(&mut self, value: String); +} + +macro_rules! title_properties { + ($t:ty) => { + impl TitleProperties for $t { + fn title(&self) -> &str { + &self.title + } + fn subtitle(&self) -> Option<&str> { + self.subtitle.as_deref() + } + fn full_title(&self) -> &str { + &self.full_title + } + fn locale_code(&self) -> &LocaleCode { + &self.locale_code + } + fn canonical(&self) -> bool { + self.canonical + } + fn set_title(&mut self, value: String) { + self.title = value; + } + fn set_subtitle(&mut self, value: Option<String>) { + self.subtitle = value; + } + fn set_full_title(&mut self, value: String) { + self.full_title = value; + } + } + }; +} + +title_properties!(Title); +title_properties!(NewTitle); +title_properties!(PatchTitle); + +pub(crate) fn convert_title_to_jats<T>(data: &mut T, format: MarkupFormat) -> ThothResult<()> +where + T: TitleProperties, +{ + let title = convert_to_jats(data.title().to_owned(), format, ConversionLimit::Title)?; + let subtitle = data + .subtitle() + .map(|s| convert_to_jats(s.to_owned(), format, ConversionLimit::Title)) + .transpose()?; + let full_title = convert_to_jats(data.full_title().to_owned(), format, ConversionLimit::Title)?; + + data.set_title(title); + data.set_subtitle(subtitle); + data.set_full_title(full_title); + Ok(()) +} + +#[cfg(feature = "backend")] +pub mod crud; +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::TitlePolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/title/policy.rs b/thoth-api/src/model/title/policy.rs new file mode 100644 index 00000000..46ee4286 --- /dev/null +++ b/thoth-api/src/model/title/policy.rs @@ -0,0 +1,70 @@ +use crate::markup::MarkupFormat; +use crate::model::title::{NewTitle, PatchTitle, Title}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use crate::schema::work_title; + +use diesel::dsl::{exists, select}; +use diesel::prelude::*; +use thoth_errors::{ThothError, ThothResult}; +use uuid::Uuid; + +/// Write policies for `Title`. +/// +/// For now this policy enforces the tenant boundary only: +/// - authentication +/// - publisher membership derived from the entity / input via `PublisherId` +pub struct TitlePolicy; + +fn has_canonical_title(db: &crate::db::PgPool, work_id: &Uuid) -> ThothResult<bool> { + let mut connection = db.get()?; + let query = work_title::table + .filter(work_title::work_id.eq(work_id)) + .filter(work_title::canonical.eq(true)); + + let result: bool = select(exists(query)).get_result(&mut connection)?; + Ok(result) +} + +impl CreatePolicy<NewTitle, Option<MarkupFormat>> for TitlePolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewTitle, + markup: Option<MarkupFormat>, + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + + // Title creation requires a markup format. + markup.ok_or(ThothError::MissingMarkupFormat)?; + + // Canonical titles: only one canonical title is allowed per work. + if data.canonical && has_canonical_title(ctx.db(), &data.work_id)? { + return Err(ThothError::CanonicalTitleExistsError); + } + + Ok(()) + } +} + +impl UpdatePolicy<Title, PatchTitle, Option<MarkupFormat>> for TitlePolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Title, + patch: &PatchTitle, + markup: Option<MarkupFormat>, + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + // Title updates require a markup format. + markup.ok_or(ThothError::MissingMarkupFormat)?; + + Ok(()) + } +} + +impl DeletePolicy<Title> for TitlePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Title) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/title/tests.rs b/thoth-api/src/model/title/tests.rs new file mode 100644 index 00000000..29db38f7 --- /dev/null +++ b/thoth-api/src/model/title/tests.rs @@ -0,0 +1,818 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_title( + pool: &crate::db::PgPool, + work_id: Uuid, + full_title: String, + locale_code: LocaleCode, +) -> Title { + let new_title = NewTitle { + work_id, + locale_code, + full_title, + title: "Test Title".to_string(), + subtitle: None, + canonical: false, + }; + + Title::create(pool, &new_title).expect("Failed to create title") +} + +mod conversions { + use super::*; + use crate::markup::MarkupFormat; + + #[test] + fn convert_title_to_jats_updates_fields() { + let mut title = Title { + title: "<title>My Title".to_string(), + subtitle: Some("Sub".to_string()), + full_title: "My Title: Sub".to_string(), + locale_code: LocaleCode::En, + canonical: false, + ..Default::default() + }; + + convert_title_to_jats(&mut title, MarkupFormat::JatsXml) + .expect("Failed to convert title to JATS"); + + assert_eq!(title.title(), "My Title"); + assert_eq!( + TitleProperties::subtitle(&title), + Some("Sub") + ); + assert_eq!(title.full_title(), "My Title: Sub"); + } +} + +mod helpers { + use super::*; + + #[test] + fn compile_fulltitle_formats_with_subtitle_and_punctuation() { + let mut title = Title { + title: "Hello".to_string(), + subtitle: Some("World".to_string()), + full_title: "".to_string(), + locale_code: LocaleCode::En, + canonical: false, + ..Default::default() + }; + + assert_eq!(title.compile_fulltitle(), "Hello: World"); + + title.title = "Hello?".to_string(); + assert_eq!(title.compile_fulltitle(), "Hello? World"); + + title.title = "".to_string(); + assert_eq!(title.compile_fulltitle(), "Untitled: World"); + + title.subtitle = None; + title.title = "Solo".to_string(); + assert_eq!(title.compile_fulltitle(), "Solo"); + } + + #[test] + fn titleproperties_accessors_and_setters_work() { + let mut title = Title { + title: "Main".to_string(), + subtitle: Some("Sub".to_string()), + full_title: "Main: Sub".to_string(), + locale_code: LocaleCode::En, + canonical: true, + ..Default::default() + }; + + assert_eq!(title.title(), "Main"); + assert_eq!(TitleProperties::subtitle(&title), Some("Sub")); + assert_eq!(title.full_title(), "Main: Sub"); + assert_eq!(title.locale_code(), &LocaleCode::En); + assert!(title.canonical()); + + title.set_title("Updated".to_string()); + title.set_subtitle(None); + title.set_full_title("Updated".to_string()); + + assert_eq!(title.title(), "Updated"); + assert_eq!(TitleProperties::subtitle(&title), None); + assert_eq!(title.full_title(), "Updated"); + + let new_title = NewTitle { + work_id: Uuid::new_v4(), + locale_code: LocaleCode::En, + full_title: "New Title".to_string(), + title: "New".to_string(), + subtitle: None, + canonical: true, + }; + + assert_eq!(new_title.locale_code(), &LocaleCode::En); + assert!(new_title.canonical()); + + let patch_title = PatchTitle { + title_id: Uuid::new_v4(), + work_id: Uuid::new_v4(), + locale_code: LocaleCode::Fr, + full_title: "Patch Title".to_string(), + title: "Patch".to_string(), + subtitle: None, + canonical: false, + }; + + assert_eq!(patch_title.locale_code(), &LocaleCode::Fr); + assert!(!patch_title.canonical()); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::markup::MarkupFormat; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::title::policy::TitlePolicy; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + use thoth_errors::ThothError; + + #[test] + fn crud_policy_allows_publisher_user_with_markup() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("title-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let new_title = NewTitle { + work_id: work.work_id, + locale_code: LocaleCode::En, + full_title: "Policy Title".to_string(), + title: "Policy".to_string(), + subtitle: None, + canonical: false, + }; + + let title = Title::create(pool.as_ref(), &new_title).expect("Failed to create"); + let patch = PatchTitle { + title_id: title.title_id, + work_id: title.work_id, + locale_code: title.locale_code, + full_title: "Updated Policy Title".to_string(), + title: "Updated".to_string(), + subtitle: None, + canonical: false, + }; + + assert!(TitlePolicy::can_create(&ctx, &new_title, Some(MarkupFormat::Html)).is_ok()); + assert!(TitlePolicy::can_update(&ctx, &title, &patch, Some(MarkupFormat::Html)).is_ok()); + assert!(TitlePolicy::can_delete(&ctx, &title).is_ok()); + } + + #[test] + fn crud_policy_requires_markup_format() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("title-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let new_title = NewTitle { + work_id: work.work_id, + locale_code: LocaleCode::En, + full_title: "Policy Title".to_string(), + title: "Policy".to_string(), + subtitle: None, + canonical: false, + }; + + let title = Title::create(pool.as_ref(), &new_title).expect("Failed to create"); + let patch = PatchTitle { + title_id: title.title_id, + work_id: title.work_id, + locale_code: title.locale_code, + full_title: "Updated Policy Title".to_string(), + title: "Updated".to_string(), + subtitle: None, + canonical: false, + }; + + assert!(TitlePolicy::can_create(&ctx, &new_title, None).is_err()); + assert!(TitlePolicy::can_update(&ctx, &title, &patch, None).is_err()); + } + + #[test] + fn crud_policy_rejects_duplicate_canonical_title() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("title-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let canonical_title = NewTitle { + work_id: work.work_id, + locale_code: LocaleCode::En, + full_title: "Canonical Title".to_string(), + title: "Canonical".to_string(), + subtitle: None, + canonical: true, + }; + Title::create(pool.as_ref(), &canonical_title).expect("Failed to create title"); + + let new_title = NewTitle { + work_id: work.work_id, + locale_code: LocaleCode::En, + full_title: "Second Canonical Title".to_string(), + title: "Second".to_string(), + subtitle: None, + canonical: true, + }; + + let result = TitlePolicy::can_create(&ctx, &new_title, Some(MarkupFormat::Html)); + assert!(matches!(result, Err(ThothError::CanonicalTitleExistsError))); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let title = make_title( + pool.as_ref(), + work.work_id, + "Policy Title".to_string(), + LocaleCode::En, + ); + let patch = PatchTitle { + title_id: title.title_id, + work_id: title.work_id, + locale_code: title.locale_code, + full_title: "Updated Policy Title".to_string(), + title: "Updated".to_string(), + subtitle: None, + canonical: false, + }; + + let user = test_user_with_role("title-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let new_title = NewTitle { + work_id: work.work_id, + locale_code: LocaleCode::En, + full_title: "Policy Title".to_string(), + title: "Policy".to_string(), + subtitle: None, + canonical: false, + }; + + assert!(TitlePolicy::can_create(&ctx, &new_title, Some(MarkupFormat::Html)).is_err()); + assert!(TitlePolicy::can_update(&ctx, &title, &patch, Some(MarkupFormat::Html)).is_err()); + assert!(TitlePolicy::can_delete(&ctx, &title).is_err()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context, + }; + use crate::model::Crud; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let new_title = NewTitle { + work_id: work.work_id, + locale_code: LocaleCode::En, + full_title: format!("Full Title {}", Uuid::new_v4()), + title: "Test Title".to_string(), + subtitle: None, + canonical: false, + }; + + let title = Title::create(pool.as_ref(), &new_title).expect("Failed to create title"); + let fetched = Title::from_id(pool.as_ref(), &title.title_id).expect("Failed to fetch"); + assert_eq!(title.title_id, fetched.title_id); + + let patch = PatchTitle { + title_id: title.title_id, + work_id: title.work_id, + locale_code: title.locale_code, + full_title: format!("Updated Full {}", Uuid::new_v4()), + title: "Updated Title".to_string(), + subtitle: Some("Updated Subtitle".to_string()), + canonical: true, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = title.update(&ctx, &patch).expect("Failed to update title"); + assert_eq!(updated.full_title, patch.full_title); + + let deleted = updated + .delete(pool.as_ref()) + .expect("Failed to delete title"); + assert!(Title::from_id(pool.as_ref(), &deleted.title_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_title( + pool.as_ref(), + work.work_id, + format!("Full Title {}", Uuid::new_v4()), + LocaleCode::En, + ); + make_title( + pool.as_ref(), + work.work_id, + format!("Full Title {}", Uuid::new_v4()), + LocaleCode::Fr, + ); + + let order = TitleOrderBy { + field: TitleField::TitleId, + direction: Direction::Asc, + }; + + let first = Title::all( + pool.as_ref(), + 1, + 0, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch titles"); + let second = Title::all( + pool.as_ref(), + 1, + 1, + None, + TitleOrderBy { + field: TitleField::TitleId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch titles"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].title_id, second[0].title_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + make_title( + pool.as_ref(), + work.work_id, + format!("Full Title {}", Uuid::new_v4()), + LocaleCode::En, + ); + make_title( + pool.as_ref(), + work.work_id, + format!("Full Title {}", Uuid::new_v4()), + LocaleCode::Fr, + ); + + let count = Title::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count titles"); + assert_eq!(count, 2); + } + + #[test] + fn crud_filter_matches_full_title() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let marker = format!("Filter {}", Uuid::new_v4()); + let matches = make_title( + pool.as_ref(), + work.work_id, + format!("Full Title {marker}"), + LocaleCode::En, + ); + make_title( + pool.as_ref(), + work.work_id, + "Other Title".to_string(), + LocaleCode::Fr, + ); + + let filtered = Title::all( + pool.as_ref(), + 10, + 0, + Some(marker), + TitleOrderBy { + field: TitleField::TitleId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter titles"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].title_id, matches.title_id); + } + + #[test] + fn crud_filter_param_limits_locale_codes() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let matches = make_title( + pool.as_ref(), + work.work_id, + format!("Full Title {}", Uuid::new_v4()), + LocaleCode::En, + ); + make_title( + pool.as_ref(), + work.work_id, + format!("Full Title {}", Uuid::new_v4()), + LocaleCode::Fr, + ); + + let filtered = Title::all( + pool.as_ref(), + 10, + 0, + None, + TitleOrderBy { + field: TitleField::TitleId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![LocaleCode::En], + vec![], + None, + None, + ) + .expect("Failed to filter titles by locale"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].title_id, matches.title_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let first = make_title( + pool.as_ref(), + work.work_id, + format!("Full Title {}", Uuid::new_v4()), + LocaleCode::En, + ); + let second = make_title( + pool.as_ref(), + work.work_id, + format!("Full Title {}", Uuid::new_v4()), + LocaleCode::Fr, + ); + let mut ids = [first.title_id, second.title_id]; + ids.sort(); + + let asc = Title::all( + pool.as_ref(), + 2, + 0, + None, + TitleOrderBy { + field: TitleField::TitleId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order titles (asc)"); + + let desc = Title::all( + pool.as_ref(), + 2, + 0, + None, + TitleOrderBy { + field: TitleField::TitleId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order titles (desc)"); + + assert_eq!(asc[0].title_id, ids[0]); + assert_eq!(desc[0].title_id, ids[1]); + } + + #[test] + fn crud_canonical_from_work_id_returns_title() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let title = Title::create( + pool.as_ref(), + &NewTitle { + work_id: work.work_id, + locale_code: LocaleCode::En, + full_title: "Canonical Title".to_string(), + title: "Canonical".to_string(), + subtitle: Some("Subtitle".to_string()), + canonical: true, + }, + ) + .expect("Failed to create title"); + + let fetched = Title::canonical_from_work_id(pool.as_ref(), &work.work_id) + .expect("Failed to fetch canonical title"); + + assert_eq!(fetched.title_id, title.title_id); + } + + #[test] + fn crud_filter_parent_work_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + + let matches = make_title( + pool.as_ref(), + work.work_id, + format!("Full Title {}", Uuid::new_v4()), + LocaleCode::En, + ); + make_title( + pool.as_ref(), + other_work.work_id, + format!("Full Title {}", Uuid::new_v4()), + LocaleCode::Fr, + ); + + let filtered = Title::all( + pool.as_ref(), + 10, + 0, + None, + TitleOrderBy { + field: TitleField::TitleId, + direction: Direction::Asc, + }, + vec![], + Some(work.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter titles by work"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].title_id, matches.title_id); + } + + #[test] + fn crud_filter_matches_subtitle() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let marker = format!("Subtitle {}", Uuid::new_v4()); + + let matches = Title::create( + pool.as_ref(), + &NewTitle { + work_id: work.work_id, + locale_code: LocaleCode::En, + full_title: "Full Title".to_string(), + title: "Title".to_string(), + subtitle: Some(marker.clone()), + canonical: false, + }, + ) + .expect("Failed to create title"); + make_title( + pool.as_ref(), + work.work_id, + "Other Title".to_string(), + LocaleCode::Fr, + ); + + let filtered = Title::all( + pool.as_ref(), + 10, + 0, + Some(marker), + TitleOrderBy { + field: TitleField::TitleId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter titles by subtitle"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].title_id, matches.title_id); + } + + #[test] + fn crud_count_with_filter_matches_title() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let marker = format!("Count {}", Uuid::new_v4()); + + make_title( + pool.as_ref(), + work.work_id, + format!("Full Title {marker}"), + LocaleCode::En, + ); + make_title( + pool.as_ref(), + work.work_id, + "Other Title".to_string(), + LocaleCode::Fr, + ); + + let count = Title::count( + pool.as_ref(), + Some(marker), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count filtered titles"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let other_work = create_work(pool.as_ref(), &imprint); + + Title::create( + pool.as_ref(), + &NewTitle { + work_id: work.work_id, + locale_code: LocaleCode::En, + full_title: "Full A".to_string(), + title: "Title A".to_string(), + subtitle: Some("Subtitle A".to_string()), + canonical: true, + }, + ) + .expect("Failed to create title"); + Title::create( + pool.as_ref(), + &NewTitle { + work_id: other_work.work_id, + locale_code: LocaleCode::Fr, + full_title: "Full B".to_string(), + title: "Title B".to_string(), + subtitle: Some("Subtitle B".to_string()), + canonical: false, + }, + ) + .expect("Failed to create title"); + + let fields: Vec TitleField> = vec![ + || TitleField::TitleId, + || TitleField::WorkId, + || TitleField::LocaleCode, + || TitleField::FullTitle, + || TitleField::Title, + || TitleField::Subtitle, + || TitleField::Canonical, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Title::all( + pool.as_ref(), + 10, + 0, + None, + TitleOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order titles"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/work/crud.rs b/thoth-api/src/model/work/crud.rs index 45009d8a..0b6fa208 100644 --- a/thoth-api/src/model/work/crud.rs +++ b/thoth-api/src/model/work/crud.rs @@ -2,14 +2,14 @@ use super::{ NewWork, NewWorkHistory, PatchWork, Work, WorkField, WorkHistory, WorkOrderBy, WorkStatus, WorkType, }; -use crate::graphql::model::TimeExpression; -use crate::graphql::utils::{Direction, Expression}; +use crate::graphql::types::inputs::Expression; +use crate::graphql::types::inputs::TimeExpression; use crate::model::work_relation::{RelationType, WorkRelation, WorkRelationOrderBy}; -use crate::model::{Crud, DbInsert, Doi, HistoryEntry}; -use crate::schema::{work, work_history}; -use crate::{crud_methods, db_insert}; +use crate::model::{Crud, DbInsert, Doi, HistoryEntry, PublisherId}; +use crate::schema::{work, work_abstract, work_history, work_title}; use diesel::{ - BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, + BoolExpressionMethods, ExpressionMethods, NullableExpressionMethods, PgTextExpressionMethods, + QueryDsl, RunQueryDsl, }; use thoth_errors::{ThothError, ThothResult}; use uuid::Uuid; @@ -21,13 +21,15 @@ impl Work { work_types: Vec, ) -> ThothResult { use crate::schema::work::dsl; - use diesel::sql_types::Nullable; - use diesel::sql_types::Text; + use diesel::{ + dsl::sql, + sql_types::{Nullable, Text}, + }; + let mut connection = db.get()?; // Allow case-insensitive searching (DOIs in database may have mixed casing) - define_sql_function!(fn lower(x: Nullable) -> Nullable); let mut query = dsl::work - .filter(lower(dsl::doi).eq(doi.to_lowercase_string())) + .filter(sql::>("lower(doi)").eq(doi.to_lowercase_string())) .into_boxed(); if !work_types.is_empty() { query = query.filter(dsl::work_type.eq_any(work_types)); @@ -62,23 +64,68 @@ impl Work { } pub fn can_be_chapter(&self, db: &crate::db::PgPool) -> ThothResult<()> { - use crate::schema::publication::dsl::*; + use crate::schema::{ + additional_resource, award, book_review, endorsement, publication, work, + work_featured_video, + }; let mut connection = db.get()?; - let isbn_count = publication - .filter(work_id.eq(self.work_id)) - .filter(isbn.is_not_null()) + + let isbn_count = publication::table + .filter(publication::work_id.eq(self.work_id)) + .filter(publication::isbn.is_not_null()) .count() .get_result::(&mut connection) .expect("Error loading publication ISBNs for work") .to_string() .parse::() .unwrap(); - // If a work has any publications with ISBNs, - // its type cannot be changed to Book Chapter. - if isbn_count == 0 { - Ok(()) - } else { + + if isbn_count > 0 { Err(ThothError::ChapterIsbnError) + } else { + let additional_resource_count = additional_resource::table + .filter(additional_resource::work_id.eq(self.work_id)) + .count() + .get_result::(&mut connection) + .expect("Error loading additional resources for work"); + let award_count = award::table + .filter(award::work_id.eq(self.work_id)) + .count() + .get_result::(&mut connection) + .expect("Error loading awards for work"); + let endorsement_count = endorsement::table + .filter(endorsement::work_id.eq(self.work_id)) + .count() + .get_result::(&mut connection) + .expect("Error loading endorsements for work"); + let review_count = book_review::table + .filter(book_review::work_id.eq(self.work_id)) + .count() + .get_result::(&mut connection) + .expect("Error loading reviews for work"); + let featured_video_count = work_featured_video::table + .filter(work_featured_video::work_id.eq(self.work_id)) + .count() + .get_result::(&mut connection) + .expect("Error loading featured videos for work"); + let resources_description_count = work::table + .filter(work::work_id.eq(self.work_id)) + .filter(work::resources_description.is_not_null()) + .count() + .get_result::(&mut connection) + .expect("Error loading resources description for work"); + + if additional_resource_count > 0 + || award_count > 0 + || endorsement_count > 0 + || review_count > 0 + || featured_video_count > 0 + || resources_description_count > 0 + { + Err(ThothError::ChapterBookMetadataError) + } else { + Ok(()) + } } } @@ -95,6 +142,7 @@ impl Work { vec![RelationType::HasChild], vec![], None, + None, ) .unwrap_or_default() .into_iter() @@ -110,6 +158,7 @@ impl Crud for Work { type FilterParameter1 = WorkType; type FilterParameter2 = WorkStatus; type FilterParameter3 = TimeExpression; + type FilterParameter4 = TimeExpression; fn pk(&self) -> Uuid { self.work_id @@ -126,9 +175,11 @@ impl Crud for Work { _: Option, work_types: Vec, work_statuses: Vec, - updated_at_with_relations: Option, + publication_date: Option, + updated_at_with_relations: Option, ) -> ThothResult> { use crate::schema::work::dsl; + let mut connection = db.get()?; let mut query = dsl::work .inner_join(crate::schema::imprint::table) @@ -136,150 +187,270 @@ impl Crud for Work { .into_boxed(); query = match order.field { - WorkField::WorkId => match order.direction { - Direction::Asc => query.order(dsl::work_id.asc()), - Direction::Desc => query.order(dsl::work_id.desc()), - }, - WorkField::WorkType => match order.direction { - Direction::Asc => query.order(dsl::work_type.asc()), - Direction::Desc => query.order(dsl::work_type.desc()), - }, - WorkField::WorkStatus => match order.direction { - Direction::Asc => query.order(dsl::work_status.asc()), - Direction::Desc => query.order(dsl::work_status.desc()), - }, - WorkField::FullTitle => match order.direction { - Direction::Asc => query.order(dsl::full_title.asc()), - Direction::Desc => query.order(dsl::full_title.desc()), - }, - WorkField::Title => match order.direction { - Direction::Asc => query.order(dsl::title.asc()), - Direction::Desc => query.order(dsl::title.desc()), - }, - WorkField::Subtitle => match order.direction { - Direction::Asc => query.order(dsl::subtitle.asc()), - Direction::Desc => query.order(dsl::subtitle.desc()), - }, - WorkField::Reference => match order.direction { - Direction::Asc => query.order(dsl::reference.asc()), - Direction::Desc => query.order(dsl::reference.desc()), - }, - WorkField::Edition => match order.direction { - Direction::Asc => query.order(dsl::edition.asc()), - Direction::Desc => query.order(dsl::edition.desc()), - }, - WorkField::Doi => match order.direction { - Direction::Asc => query.order(dsl::doi.asc()), - Direction::Desc => query.order(dsl::doi.desc()), - }, - WorkField::PublicationDate => match order.direction { - Direction::Asc => query.order(dsl::publication_date.asc()), - Direction::Desc => query.order(dsl::publication_date.desc()), - }, - WorkField::WithdrawnDate => match order.direction { - Direction::Asc => query.order(dsl::withdrawn_date.asc()), - Direction::Desc => query.order(dsl::withdrawn_date.desc()), - }, - WorkField::Place => match order.direction { - Direction::Asc => query.order(dsl::place.asc()), - Direction::Desc => query.order(dsl::place.desc()), - }, - WorkField::PageCount => match order.direction { - Direction::Asc => query.order(dsl::page_count.asc()), - Direction::Desc => query.order(dsl::page_count.desc()), - }, - WorkField::PageBreakdown => match order.direction { - Direction::Asc => query.order(dsl::page_breakdown.asc()), - Direction::Desc => query.order(dsl::page_breakdown.desc()), - }, - WorkField::FirstPage => match order.direction { - Direction::Asc => query.order(dsl::first_page.asc()), - Direction::Desc => query.order(dsl::first_page.desc()), - }, - WorkField::LastPage => match order.direction { - Direction::Asc => query.order(dsl::last_page.asc()), - Direction::Desc => query.order(dsl::last_page.desc()), - }, - WorkField::PageInterval => match order.direction { - Direction::Asc => query.order(dsl::page_breakdown.asc()), - Direction::Desc => query.order(dsl::page_breakdown.desc()), - }, - WorkField::ImageCount => match order.direction { - Direction::Asc => query.order(dsl::image_count.asc()), - Direction::Desc => query.order(dsl::image_count.desc()), - }, - WorkField::TableCount => match order.direction { - Direction::Asc => query.order(dsl::table_count.asc()), - Direction::Desc => query.order(dsl::table_count.desc()), - }, - WorkField::AudioCount => match order.direction { - Direction::Asc => query.order(dsl::audio_count.asc()), - Direction::Desc => query.order(dsl::audio_count.desc()), - }, - WorkField::VideoCount => match order.direction { - Direction::Asc => query.order(dsl::video_count.asc()), - Direction::Desc => query.order(dsl::video_count.desc()), - }, - WorkField::License => match order.direction { - Direction::Asc => query.order(dsl::license.asc()), - Direction::Desc => query.order(dsl::license.desc()), - }, - WorkField::CopyrightHolder => match order.direction { - Direction::Asc => query.order(dsl::copyright_holder.asc()), - Direction::Desc => query.order(dsl::copyright_holder.desc()), - }, - WorkField::LandingPage => match order.direction { - Direction::Asc => query.order(dsl::landing_page.asc()), - Direction::Desc => query.order(dsl::landing_page.desc()), - }, - WorkField::Lccn => match order.direction { - Direction::Asc => query.order(dsl::lccn.asc()), - Direction::Desc => query.order(dsl::lccn.desc()), - }, - WorkField::Oclc => match order.direction { - Direction::Asc => query.order(dsl::oclc.asc()), - Direction::Desc => query.order(dsl::oclc.desc()), - }, - WorkField::ShortAbstract => match order.direction { - Direction::Asc => query.order(dsl::short_abstract.asc()), - Direction::Desc => query.order(dsl::short_abstract.desc()), - }, - WorkField::LongAbstract => match order.direction { - Direction::Asc => query.order(dsl::long_abstract.asc()), - Direction::Desc => query.order(dsl::long_abstract.desc()), - }, - WorkField::GeneralNote => match order.direction { - Direction::Asc => query.order(dsl::general_note.asc()), - Direction::Desc => query.order(dsl::general_note.desc()), - }, - WorkField::BibliographyNote => match order.direction { - Direction::Asc => query.order(dsl::bibliography_note.asc()), - Direction::Desc => query.order(dsl::bibliography_note.desc()), - }, - WorkField::Toc => match order.direction { - Direction::Asc => query.order(dsl::toc.asc()), - Direction::Desc => query.order(dsl::toc.desc()), - }, - WorkField::CoverUrl => match order.direction { - Direction::Asc => query.order(dsl::cover_url.asc()), - Direction::Desc => query.order(dsl::cover_url.desc()), - }, - WorkField::CoverCaption => match order.direction { - Direction::Asc => query.order(dsl::cover_caption.asc()), - Direction::Desc => query.order(dsl::cover_caption.desc()), - }, - WorkField::CreatedAt => match order.direction { - Direction::Asc => query.order(dsl::created_at.asc()), - Direction::Desc => query.order(dsl::created_at.desc()), - }, - WorkField::UpdatedAt => match order.direction { - Direction::Asc => query.order(dsl::updated_at.asc()), - Direction::Desc => query.order(dsl::updated_at.desc()), - }, - WorkField::UpdatedAtWithRelations => match order.direction { - Direction::Asc => query.order(dsl::updated_at_with_relations.asc()), - Direction::Desc => query.order(dsl::updated_at_with_relations.desc()), - }, + WorkField::WorkId => { + apply_directional_order!(query, order.direction, order_by, dsl::work_id) + } + WorkField::WorkType => apply_directional_order!( + query, + order.direction, + order_by, + dsl::work_type, + dsl::work_id + ), + WorkField::WorkStatus => apply_directional_order!( + query, + order.direction, + order_by, + dsl::work_status, + dsl::work_id + ), + WorkField::FullTitle => { + let canonical_full_title = work_title::table + .select(work_title::full_title.nullable()) + .filter(work_title::work_id.eq(dsl::work_id)) + .filter(work_title::canonical.eq(true)) + .order(work_title::title_id.asc()) + .limit(1) + .single_value(); + apply_directional_order!( + query, + order.direction, + order_by, + canonical_full_title, + dsl::work_id + ) + } + WorkField::Title => { + let canonical_title = work_title::table + .select(work_title::title.nullable()) + .filter(work_title::work_id.eq(dsl::work_id)) + .filter(work_title::canonical.eq(true)) + .order(work_title::title_id.asc()) + .limit(1) + .single_value(); + apply_directional_order!( + query, + order.direction, + order_by, + canonical_title, + dsl::work_id + ) + } + WorkField::Subtitle => { + let canonical_subtitle = work_title::table + .select(work_title::subtitle) + .filter(work_title::work_id.eq(dsl::work_id)) + .filter(work_title::canonical.eq(true)) + .order(work_title::title_id.asc()) + .limit(1) + .single_value(); + apply_directional_order!( + query, + order.direction, + order_by, + canonical_subtitle, + dsl::work_id + ) + } + WorkField::Reference => apply_directional_order!( + query, + order.direction, + order_by, + dsl::reference, + dsl::work_id + ), + WorkField::Edition => apply_directional_order!( + query, + order.direction, + order_by, + dsl::edition, + dsl::work_id + ), + WorkField::Doi => { + apply_directional_order!(query, order.direction, order_by, dsl::doi, dsl::work_id) + } + WorkField::PublicationDate => apply_directional_order!( + query, + order.direction, + order_by, + dsl::publication_date, + dsl::work_id + ), + WorkField::WithdrawnDate => apply_directional_order!( + query, + order.direction, + order_by, + dsl::withdrawn_date, + dsl::work_id + ), + WorkField::Place => { + apply_directional_order!(query, order.direction, order_by, dsl::place, dsl::work_id) + } + WorkField::PageCount => apply_directional_order!( + query, + order.direction, + order_by, + dsl::page_count, + dsl::work_id + ), + WorkField::PageBreakdown => apply_directional_order!( + query, + order.direction, + order_by, + dsl::page_breakdown, + dsl::work_id + ), + WorkField::FirstPage => apply_directional_order!( + query, + order.direction, + order_by, + dsl::first_page, + dsl::work_id + ), + WorkField::LastPage => apply_directional_order!( + query, + order.direction, + order_by, + dsl::last_page, + dsl::work_id + ), + WorkField::PageInterval => apply_directional_order!( + query, + order.direction, + order_by, + dsl::page_interval, + dsl::work_id + ), + WorkField::ImageCount => apply_directional_order!( + query, + order.direction, + order_by, + dsl::image_count, + dsl::work_id + ), + WorkField::TableCount => apply_directional_order!( + query, + order.direction, + order_by, + dsl::table_count, + dsl::work_id + ), + WorkField::AudioCount => apply_directional_order!( + query, + order.direction, + order_by, + dsl::audio_count, + dsl::work_id + ), + WorkField::VideoCount => apply_directional_order!( + query, + order.direction, + order_by, + dsl::video_count, + dsl::work_id + ), + WorkField::License => apply_directional_order!( + query, + order.direction, + order_by, + dsl::license, + dsl::work_id + ), + WorkField::CopyrightHolder => apply_directional_order!( + query, + order.direction, + order_by, + dsl::copyright_holder, + dsl::work_id + ), + WorkField::LandingPage => apply_directional_order!( + query, + order.direction, + order_by, + dsl::landing_page, + dsl::work_id + ), + WorkField::Lccn => { + apply_directional_order!(query, order.direction, order_by, dsl::lccn, dsl::work_id) + } + WorkField::Oclc => { + apply_directional_order!(query, order.direction, order_by, dsl::oclc, dsl::work_id) + } + WorkField::ShortAbstract | WorkField::LongAbstract => { + let canonical_abstract = work_abstract::table + .select(work_abstract::content.nullable()) + .filter(work_abstract::work_id.eq(dsl::work_id)) + .filter(work_abstract::canonical.eq(true)) + .order(work_abstract::abstract_id.asc()) + .limit(1) + .single_value(); + apply_directional_order!( + query, + order.direction, + order_by, + canonical_abstract, + dsl::work_id + ) + } + WorkField::GeneralNote => apply_directional_order!( + query, + order.direction, + order_by, + dsl::general_note, + dsl::work_id + ), + WorkField::BibliographyNote => apply_directional_order!( + query, + order.direction, + order_by, + dsl::bibliography_note, + dsl::work_id + ), + WorkField::Toc => { + apply_directional_order!(query, order.direction, order_by, dsl::toc, dsl::work_id) + } + WorkField::ResourcesDescription => apply_directional_order!( + query, + order.direction, + order_by, + dsl::resources_description, + dsl::work_id + ), + WorkField::CoverUrl => apply_directional_order!( + query, + order.direction, + order_by, + dsl::cover_url, + dsl::work_id + ), + WorkField::CoverCaption => apply_directional_order!( + query, + order.direction, + order_by, + dsl::cover_caption, + dsl::work_id + ), + WorkField::CreatedAt => apply_directional_order!( + query, + order.direction, + order_by, + dsl::created_at, + dsl::work_id + ), + WorkField::UpdatedAt => apply_directional_order!( + query, + order.direction, + order_by, + dsl::updated_at, + dsl::work_id + ), + WorkField::UpdatedAtWithRelations => apply_directional_order!( + query, + order.direction, + order_by, + dsl::updated_at_with_relations, + dsl::work_id + ), }; if !publishers.is_empty() { query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); @@ -293,29 +464,44 @@ impl Crud for Work { if !work_statuses.is_empty() { query = query.filter(dsl::work_status.eq_any(work_statuses)); } - if let Some(updated) = updated_at_with_relations { - match updated.expression { - Expression::GreaterThan => { - query = query.filter(dsl::updated_at_with_relations.gt(updated.timestamp)) - } - Expression::LessThan => { - query = query.filter(dsl::updated_at_with_relations.lt(updated.timestamp)) - } - } - } + + apply_time_filter!( + query, + dsl::publication_date, + publication_date, + |ts: crate::model::Timestamp| ts.0.date_naive() + ); + apply_time_filter!( + query, + dsl::updated_at_with_relations, + updated_at_with_relations, + |ts: crate::model::Timestamp| ts.0 + ); + if let Some(filter) = filter { + let title_work_ids = work_title::table + .filter(work_title::full_title.ilike(format!("%{filter}%"))) + .select(work_title::work_id) + .load::(&mut connection)?; + + let abstract_work_ids = work_abstract::table + .filter(work_abstract::content.ilike(format!("%{filter}%"))) + .select(work_abstract::work_id) + .load::(&mut connection)?; + query = query.filter( - dsl::full_title + dsl::doi .ilike(format!("%{filter}%")) .or(dsl::doi.ilike(format!("%{filter}%"))) .or(dsl::reference.ilike(format!("%{filter}%"))) - .or(dsl::short_abstract.ilike(format!("%{filter}%"))) - .or(dsl::long_abstract.ilike(format!("%{filter}%"))) - .or(dsl::landing_page.ilike(format!("%{filter}%"))), + .or(dsl::landing_page.ilike(format!("%{filter}%"))) + .or(dsl::resources_description.ilike(format!("%{filter}%"))) + .or(dsl::work_id + .eq_any(title_work_ids) + .or(dsl::work_id.eq_any(abstract_work_ids))), ); } query - .then_order_by(dsl::work_id) .limit(limit.into()) .offset(offset.into()) .load::(&mut connection) @@ -328,7 +514,8 @@ impl Crud for Work { publishers: Vec, work_types: Vec, work_statuses: Vec, - updated_at_with_relations: Option, + publication_date: Option, + updated_at_with_relations: Option, ) -> ThothResult { use crate::schema::work::dsl; let mut connection = db.get()?; @@ -344,25 +531,39 @@ impl Crud for Work { if !work_statuses.is_empty() { query = query.filter(dsl::work_status.eq_any(work_statuses)); } - if let Some(updated) = updated_at_with_relations { - match updated.expression { - Expression::GreaterThan => { - query = query.filter(dsl::updated_at_with_relations.gt(updated.timestamp)) - } - Expression::LessThan => { - query = query.filter(dsl::updated_at_with_relations.lt(updated.timestamp)) - } - } - } + + apply_time_filter!( + query, + dsl::publication_date, + publication_date, + |ts: crate::model::Timestamp| ts.0.date_naive() + ); + apply_time_filter!( + query, + dsl::updated_at_with_relations, + updated_at_with_relations, + |ts: crate::model::Timestamp| ts.0 + ); + if let Some(filter) = filter { + let title_work_ids = work_title::table + .filter(work_title::full_title.ilike(format!("%{filter}%"))) + .select(work_title::work_id) + .load::(&mut connection)?; + + let abstract_work_ids = work_abstract::table + .filter(work_abstract::content.ilike(format!("%{filter}%"))) + .select(work_abstract::work_id) + .load::(&mut connection)?; + query = query.filter( - dsl::full_title + dsl::doi .ilike(format!("%{filter}%")) - .or(dsl::doi.ilike(format!("%{filter}%"))) .or(dsl::reference.ilike(format!("%{filter}%"))) - .or(dsl::short_abstract.ilike(format!("%{filter}%"))) - .or(dsl::long_abstract.ilike(format!("%{filter}%"))) - .or(dsl::landing_page.ilike(format!("%{filter}%"))), + .or(dsl::landing_page.ilike(format!("%{filter}%"))) + .or(dsl::resources_description.ilike(format!("%{filter}%"))) + .or(dsl::work_id.eq_any(title_work_ids)) + .or(dsl::work_id.eq_any(abstract_work_ids)), ); } @@ -377,21 +578,21 @@ impl Crud for Work { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult { - let imprint = crate::model::imprint::Imprint::from_id(db, &self.imprint_id)?; - ::publisher_id(&imprint, db) - } - crud_methods!(work::table, work::dsl::work); } +publisher_id_impls!(Work, NewWork, PatchWork, |s, db| { + let imprint = crate::model::imprint::Imprint::from_id(db, &s.imprint_id)?; + ::publisher_id(&imprint, db) +}); + impl HistoryEntry for Work { type NewHistoryEntity = NewWorkHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { work_id: self.work_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -402,27 +603,3 @@ impl DbInsert for NewWorkHistory { db_insert!(work_history::table); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_work_pk() { - let work: Work = Default::default(); - assert_eq!(work.pk(), work.work_id); - } - - #[test] - fn test_new_work_history_from_work() { - let work: Work = Default::default(); - let account_id: Uuid = Default::default(); - let new_work_history = work.new_history_entry(&account_id); - assert_eq!(new_work_history.work_id, work.work_id); - assert_eq!(new_work_history.account_id, account_id); - assert_eq!( - new_work_history.data, - serde_json::Value::String(serde_json::to_string(&work).unwrap()) - ); - } -} diff --git a/thoth-api/src/model/work/mod.rs b/thoth-api/src/model/work/mod.rs index bcbe4b85..cb5b0ea2 100644 --- a/thoth-api/src/model/work/mod.rs +++ b/thoth-api/src/model/work/mod.rs @@ -1,13 +1,4 @@ -use crate::graphql::utils::Direction; -use crate::model::contribution::Contribution; -use crate::model::funding::FundingWithInstitution; -use crate::model::imprint::ImprintWithPublisher; -use crate::model::issue::IssueWithSeries; -use crate::model::language::Language; -use crate::model::publication::Publication; -use crate::model::reference::Reference; -use crate::model::subject::Subject; -use crate::model::work_relation::WorkRelationWithRelatedWork; +use crate::graphql::types::inputs::Direction; use crate::model::Doi; use crate::model::Timestamp; #[cfg(feature = "backend")] @@ -16,7 +7,6 @@ use crate::schema::work; use crate::schema::work_history; use chrono::naive::NaiveDate; use serde::{Deserialize, Serialize}; -use std::fmt; use strum::Display; use strum::EnumString; use thoth_errors::{ThothError, ThothResult}; @@ -24,7 +14,7 @@ use uuid::Uuid; #[cfg_attr( feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), graphql(description = "Type of a work"), ExistingTypePath = "crate::schema::sql_types::WorkType" )] @@ -75,7 +65,7 @@ pub enum WorkType { #[cfg_attr( feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), graphql( description = "Publication status of a work throughout its lifecycle. For a visual representation of the workflow, refer to the work status flowchart https://github.com/thoth-pub/thoth/wiki/Thoth_Works#work-status-flowchart" ), @@ -178,6 +168,7 @@ pub enum WorkField { BibliographyNote, #[strum(serialize = "TOC")] Toc, + ResourcesDescription, #[strum(serialize = "CoverURL")] CoverUrl, CoverCaption, @@ -189,16 +180,13 @@ pub enum WorkField { UpdatedAtWithRelations, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Work { pub work_id: Uuid, pub work_type: WorkType, pub work_status: WorkStatus, - pub full_title: String, - pub title: String, - pub subtitle: Option, pub reference: Option, pub edition: Option, pub imprint_id: Uuid, @@ -217,11 +205,10 @@ pub struct Work { pub landing_page: Option, pub lccn: Option, pub oclc: Option, - pub short_abstract: Option, - pub long_abstract: Option, pub general_note: Option, pub bibliography_note: Option, pub toc: Option, + pub resources_description: Option, pub cover_url: Option, pub cover_caption: Option, pub created_at: Timestamp, @@ -231,67 +218,15 @@ pub struct Work { pub page_interval: Option, pub updated_at_with_relations: Timestamp, } - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct WorkWithRelations { - pub work_id: Uuid, - pub work_type: WorkType, - pub work_status: WorkStatus, - pub full_title: String, - pub title: String, - pub subtitle: Option, - pub reference: Option, - pub edition: Option, - pub doi: Option, - pub publication_date: Option, - pub withdrawn_date: Option, - pub place: Option, - pub page_count: Option, - pub page_breakdown: Option, - pub image_count: Option, - pub table_count: Option, - pub audio_count: Option, - pub video_count: Option, - pub license: Option, - pub copyright_holder: Option, - pub landing_page: Option, - pub lccn: Option, - pub oclc: Option, - pub short_abstract: Option, - pub long_abstract: Option, - pub general_note: Option, - pub bibliography_note: Option, - pub toc: Option, - pub cover_url: Option, - pub cover_caption: Option, - pub updated_at: Timestamp, - pub first_page: Option, - pub last_page: Option, - pub page_interval: Option, - pub contributions: Option>, - pub publications: Option>, - pub languages: Option>, - pub fundings: Option>, - pub subjects: Option>, - pub issues: Option>, - pub imprint: ImprintWithPublisher, - pub relations: Option>, - pub references: Option>, -} - #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new written text that can be published"), diesel(table_name = work) )] pub struct NewWork { pub work_type: WorkType, pub work_status: WorkStatus, - pub full_title: String, - pub title: String, - pub subtitle: Option, pub reference: Option, pub edition: Option, pub imprint_id: Uuid, @@ -310,11 +245,10 @@ pub struct NewWork { pub landing_page: Option, pub lccn: Option, pub oclc: Option, - pub short_abstract: Option, - pub long_abstract: Option, pub general_note: Option, pub bibliography_note: Option, pub toc: Option, + pub resources_description: Option, pub cover_url: Option, pub cover_caption: Option, pub first_page: Option, @@ -324,7 +258,7 @@ pub struct NewWork { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing written text that can be published"), diesel(table_name = work, treat_none_as_null = true) )] @@ -332,9 +266,6 @@ pub struct PatchWork { pub work_id: Uuid, pub work_type: WorkType, pub work_status: WorkStatus, - pub full_title: String, - pub title: String, - pub subtitle: Option, pub reference: Option, pub edition: Option, pub imprint_id: Uuid, @@ -353,11 +284,10 @@ pub struct PatchWork { pub landing_page: Option, pub lccn: Option, pub oclc: Option, - pub short_abstract: Option, - pub long_abstract: Option, pub general_note: Option, pub bibliography_note: Option, pub toc: Option, + pub resources_description: Option, pub cover_url: Option, pub cover_caption: Option, pub first_page: Option, @@ -365,19 +295,19 @@ pub struct PatchWork { pub page_interval: Option, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct WorkHistory { pub work_history_id: Uuid, pub work_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } -#[cfg_attr(feature = "backend", derive(Insertable), diesel(table_name = work_history))] +#[cfg_attr(feature = "backend", derive(diesel::Insertable), diesel(table_name = work_history))] pub struct NewWorkHistory { pub work_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -393,32 +323,12 @@ pub struct WorkOrderBy { } pub trait WorkProperties { - fn title(&self) -> &str; - fn subtitle(&self) -> Option<&str>; fn work_status(&self) -> &WorkStatus; fn publication_date(&self) -> &Option; fn withdrawn_date(&self) -> &Option; fn first_page(&self) -> Option<&str>; fn last_page(&self) -> Option<&str>; - fn compile_fulltitle(&self) -> String { - self.subtitle().map_or_else( - || self.title().to_string(), - |subtitle| { - let title = self.title(); - if title.ends_with('?') - || title.ends_with('!') - || title.ends_with(':') - || title.ends_with('.') - { - format!("{} {}", title, subtitle) - } else { - format!("{}: {}", title, subtitle) - } - }, - ) - } - fn compile_page_interval(&self) -> Option { self.first_page() .zip(self.last_page()) @@ -464,12 +374,6 @@ pub trait WorkProperties { macro_rules! work_properties { ($t:ty) => { impl WorkProperties for $t { - fn title(&self) -> &str { - &self.title - } - fn subtitle(&self) -> Option<&str> { - self.subtitle.as_deref() - } fn work_status(&self) -> &WorkStatus { &self.work_status } @@ -492,30 +396,12 @@ macro_rules! work_properties { work_properties!(Work); work_properties!(NewWork); work_properties!(PatchWork); -work_properties!(WorkWithRelations); - -impl WorkWithRelations { - pub fn publisher(&self) -> String { - self.imprint - .publisher - .publisher_shortname - .as_ref() - .map_or_else( - || self.imprint.publisher.publisher_name.to_string(), - |short_name| short_name.to_string(), - ) - } -} - impl From for PatchWork { fn from(w: Work) -> Self { Self { work_id: w.work_id, work_type: w.work_type, work_status: w.work_status, - full_title: w.full_title, - title: w.title, - subtitle: w.subtitle, reference: w.reference, edition: w.edition, imprint_id: w.imprint_id, @@ -534,11 +420,10 @@ impl From for PatchWork { landing_page: w.landing_page, lccn: w.lccn, oclc: w.oclc, - short_abstract: w.short_abstract, - long_abstract: w.long_abstract, general_note: w.general_note, bibliography_note: w.bibliography_note, toc: w.toc, + resources_description: w.resources_description, cover_url: w.cover_url, cover_caption: w.cover_caption, first_page: w.first_page, @@ -548,520 +433,11 @@ impl From for PatchWork { } } -impl fmt::Display for Work { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match &self.doi { - Some(doi) => write!(f, "{} - {}", self.full_title, doi), - None => write!(f, "{}", self.full_title), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_work() -> Work { - use std::str::FromStr; - Work { - work_id: Uuid::parse_str("00000000-0000-0000-AAAA-000000000001").unwrap(), - work_type: WorkType::Monograph, - work_status: WorkStatus::Active, - full_title: "Some title".to_string(), - title: "Some title".to_string(), - subtitle: None, - reference: None, - edition: Some(1), - imprint_id: Uuid::parse_str("00000000-0000-0000-BBBB-000000000002").unwrap(), - doi: Some(Doi::from_str("https://doi.org/10.00001/BOOK.0001").unwrap()), - publication_date: NaiveDate::from_ymd_opt(1999, 12, 31), - withdrawn_date: None, - place: Some("León, Spain".to_string()), - page_count: Some(123), - page_breakdown: None, - image_count: Some(22), - table_count: Some(3), - audio_count: None, - video_count: None, - license: Some("https://creativecommons.org/licenses/by/4.0/".to_string()), - copyright_holder: Some("Author1".to_string()), - landing_page: Some("https://book.page".to_string()), - lccn: None, - oclc: None, - short_abstract: Some("Short abstract".to_string()), - long_abstract: Some("Long abstract".to_string()), - general_note: None, - bibliography_note: None, - toc: None, - cover_url: Some("https://book.cover/image".to_string()), - cover_caption: None, - created_at: Default::default(), - updated_at: Default::default(), - first_page: None, - last_page: None, - page_interval: None, - updated_at_with_relations: Default::default(), - } - } - - #[test] - fn test_worktype_default() { - let worktype: WorkType = Default::default(); - assert_eq!(worktype, WorkType::Monograph); - } - - #[test] - fn test_workstatus_default() { - let workstatus: WorkStatus = Default::default(); - assert_eq!(workstatus, WorkStatus::Forthcoming); - } - - #[test] - fn test_workfield_default() { - let workfield: WorkField = Default::default(); - assert_eq!(workfield, WorkField::FullTitle); - } - - #[test] - fn test_worktype_display() { - assert_eq!(format!("{}", WorkType::BookChapter), "Book Chapter"); - assert_eq!(format!("{}", WorkType::Monograph), "Monograph"); - assert_eq!(format!("{}", WorkType::EditedBook), "Edited Book"); - assert_eq!(format!("{}", WorkType::Textbook), "Textbook"); - assert_eq!(format!("{}", WorkType::JournalIssue), "Journal Issue"); - assert_eq!(format!("{}", WorkType::BookSet), "Book Set"); - } - - #[test] - fn test_workstatus_display() { - assert_eq!(format!("{}", WorkStatus::Cancelled), "Cancelled"); - assert_eq!(format!("{}", WorkStatus::Forthcoming), "Forthcoming"); - assert_eq!( - format!("{}", WorkStatus::PostponedIndefinitely), - "Postponed Indefinitely" - ); - assert_eq!(format!("{}", WorkStatus::Active), "Active"); - assert_eq!(format!("{}", WorkStatus::Withdrawn), "Withdrawn"); - assert_eq!(format!("{}", WorkStatus::Superseded), "Superseded"); - } - - #[test] - fn test_workfield_display() { - assert_eq!(format!("{}", WorkField::WorkId), "ID"); - assert_eq!(format!("{}", WorkField::WorkType), "Type"); - assert_eq!(format!("{}", WorkField::WorkStatus), "WorkStatus"); - assert_eq!(format!("{}", WorkField::FullTitle), "Title"); - assert_eq!(format!("{}", WorkField::Title), "ShortTitle"); - assert_eq!(format!("{}", WorkField::Subtitle), "Subtitle"); - assert_eq!(format!("{}", WorkField::Reference), "Reference"); - assert_eq!(format!("{}", WorkField::Edition), "Edition"); - assert_eq!(format!("{}", WorkField::Doi), "DOI"); - assert_eq!(format!("{}", WorkField::PublicationDate), "PublicationDate"); - assert_eq!(format!("{}", WorkField::WithdrawnDate), "WithdrawnDate"); - assert_eq!(format!("{}", WorkField::Place), "Place"); - assert_eq!(format!("{}", WorkField::PageCount), "PageCount"); - assert_eq!(format!("{}", WorkField::PageBreakdown), "PageBreakdown"); - assert_eq!(format!("{}", WorkField::FirstPage), "FirstPage"); - assert_eq!(format!("{}", WorkField::LastPage), "LastPage"); - assert_eq!(format!("{}", WorkField::PageInterval), "PageInterval"); - assert_eq!(format!("{}", WorkField::ImageCount), "ImageCount"); - assert_eq!(format!("{}", WorkField::TableCount), "TableCount"); - assert_eq!(format!("{}", WorkField::AudioCount), "AudioCount"); - assert_eq!(format!("{}", WorkField::VideoCount), "VideoCount"); - assert_eq!(format!("{}", WorkField::License), "License"); - assert_eq!(format!("{}", WorkField::CopyrightHolder), "CopyrightHolder"); - assert_eq!(format!("{}", WorkField::LandingPage), "LandingPage"); - assert_eq!(format!("{}", WorkField::Lccn), "LCCN"); - assert_eq!(format!("{}", WorkField::Oclc), "OCLC"); - assert_eq!(format!("{}", WorkField::ShortAbstract), "ShortAbstract"); - assert_eq!(format!("{}", WorkField::LongAbstract), "LongAbstract"); - assert_eq!(format!("{}", WorkField::GeneralNote), "GeneralNote"); - assert_eq!( - format!("{}", WorkField::BibliographyNote), - "BibliographyNote" - ); - assert_eq!(format!("{}", WorkField::Toc), "TOC"); - assert_eq!(format!("{}", WorkField::CoverUrl), "CoverURL"); - assert_eq!(format!("{}", WorkField::CoverCaption), "CoverCaption"); - assert_eq!(format!("{}", WorkField::CreatedAt), "CreatedAt"); - assert_eq!(format!("{}", WorkField::UpdatedAt), "UpdatedAt"); - assert_eq!( - format!("{}", WorkField::UpdatedAtWithRelations), - "UpdatedAtWithRelations" - ); - } - - #[test] - fn test_worktype_fromstr() { - use std::str::FromStr; - assert_eq!( - WorkType::from_str("Book Chapter").unwrap(), - WorkType::BookChapter - ); - assert_eq!( - WorkType::from_str("Monograph").unwrap(), - WorkType::Monograph - ); - assert_eq!( - WorkType::from_str("Edited Book").unwrap(), - WorkType::EditedBook - ); - assert_eq!(WorkType::from_str("Textbook").unwrap(), WorkType::Textbook); - assert_eq!( - WorkType::from_str("Journal Issue").unwrap(), - WorkType::JournalIssue - ); - assert_eq!(WorkType::from_str("Book Set").unwrap(), WorkType::BookSet); - - assert!(WorkType::from_str("Book Section").is_err()); - assert!(WorkType::from_str("Manuscript").is_err()); - } - - #[test] - fn test_workstatus_fromstr() { - use std::str::FromStr; - assert_eq!( - WorkStatus::from_str("Cancelled").unwrap(), - WorkStatus::Cancelled - ); - assert_eq!( - WorkStatus::from_str("Forthcoming").unwrap(), - WorkStatus::Forthcoming - ); - assert_eq!( - WorkStatus::from_str("Postponed Indefinitely").unwrap(), - WorkStatus::PostponedIndefinitely - ); - assert_eq!(WorkStatus::from_str("Active").unwrap(), WorkStatus::Active); - assert_eq!( - WorkStatus::from_str("Withdrawn").unwrap(), - WorkStatus::Withdrawn - ); - assert_eq!( - WorkStatus::from_str("Superseded").unwrap(), - WorkStatus::Superseded - ); - - assert!(WorkStatus::from_str("Published").is_err()); - assert!(WorkStatus::from_str("Unpublished").is_err()); - } - - #[test] - fn test_workfield_fromstr() { - use std::str::FromStr; - assert_eq!(WorkField::from_str("ID").unwrap(), WorkField::WorkId); - assert_eq!(WorkField::from_str("Type").unwrap(), WorkField::WorkType); - assert_eq!( - WorkField::from_str("WorkStatus").unwrap(), - WorkField::WorkStatus - ); - assert_eq!(WorkField::from_str("Title").unwrap(), WorkField::FullTitle); - assert_eq!(WorkField::from_str("ShortTitle").unwrap(), WorkField::Title); - assert_eq!( - WorkField::from_str("Subtitle").unwrap(), - WorkField::Subtitle - ); - assert_eq!( - WorkField::from_str("Reference").unwrap(), - WorkField::Reference - ); - assert_eq!(WorkField::from_str("Edition").unwrap(), WorkField::Edition); - assert_eq!(WorkField::from_str("DOI").unwrap(), WorkField::Doi); - assert_eq!( - WorkField::from_str("PublicationDate").unwrap(), - WorkField::PublicationDate - ); - assert_eq!( - WorkField::from_str("WithdrawnDate").unwrap(), - WorkField::WithdrawnDate - ); - assert_eq!(WorkField::from_str("Place").unwrap(), WorkField::Place); - assert_eq!( - WorkField::from_str("PageCount").unwrap(), - WorkField::PageCount - ); - assert_eq!( - WorkField::from_str("PageBreakdown").unwrap(), - WorkField::PageBreakdown - ); - assert_eq!( - WorkField::from_str("FirstPage").unwrap(), - WorkField::FirstPage - ); - assert_eq!( - WorkField::from_str("LastPage").unwrap(), - WorkField::LastPage - ); - assert_eq!( - WorkField::from_str("PageInterval").unwrap(), - WorkField::PageInterval - ); - assert_eq!( - WorkField::from_str("ImageCount").unwrap(), - WorkField::ImageCount - ); - assert_eq!( - WorkField::from_str("TableCount").unwrap(), - WorkField::TableCount - ); - assert_eq!( - WorkField::from_str("AudioCount").unwrap(), - WorkField::AudioCount - ); - assert_eq!( - WorkField::from_str("VideoCount").unwrap(), - WorkField::VideoCount - ); - assert_eq!(WorkField::from_str("License").unwrap(), WorkField::License); - assert_eq!( - WorkField::from_str("CopyrightHolder").unwrap(), - WorkField::CopyrightHolder - ); - assert_eq!( - WorkField::from_str("LandingPage").unwrap(), - WorkField::LandingPage - ); - assert_eq!(WorkField::from_str("LCCN").unwrap(), WorkField::Lccn); - assert_eq!(WorkField::from_str("OCLC").unwrap(), WorkField::Oclc); - assert_eq!( - WorkField::from_str("ShortAbstract").unwrap(), - WorkField::ShortAbstract - ); - assert_eq!( - WorkField::from_str("LongAbstract").unwrap(), - WorkField::LongAbstract - ); - assert_eq!( - WorkField::from_str("GeneralNote").unwrap(), - WorkField::GeneralNote - ); - assert_eq!( - WorkField::from_str("BibliographyNote").unwrap(), - WorkField::BibliographyNote - ); - assert_eq!(WorkField::from_str("TOC").unwrap(), WorkField::Toc); - assert_eq!( - WorkField::from_str("CoverURL").unwrap(), - WorkField::CoverUrl - ); - assert_eq!( - WorkField::from_str("CoverCaption").unwrap(), - WorkField::CoverCaption - ); - assert_eq!( - WorkField::from_str("CreatedAt").unwrap(), - WorkField::CreatedAt - ); - assert_eq!( - WorkField::from_str("UpdatedAt").unwrap(), - WorkField::UpdatedAt - ); - assert_eq!( - WorkField::from_str("UpdatedAtWithRelations").unwrap(), - WorkField::UpdatedAtWithRelations - ); - assert!(WorkField::from_str("WorkID").is_err()); - assert!(WorkField::from_str("Contributors").is_err()); - assert!(WorkField::from_str("Publisher").is_err()); - } - - #[test] - fn test_work_into_patchwork() { - let work = test_work(); - let patch_work: PatchWork = work.clone().into(); - - macro_rules! assert_fields_eq { - ($($field:ident),+) => { - $( - assert_eq!(work.$field, patch_work.$field); - )+ - }; - } - assert_fields_eq!( - work_id, - work_type, - work_status, - full_title, - title, - subtitle, - reference, - edition, - imprint_id, - doi, - publication_date, - withdrawn_date, - place, - page_count, - page_breakdown, - image_count, - table_count, - audio_count, - video_count, - license, - copyright_holder, - landing_page, - lccn, - oclc, - short_abstract, - long_abstract, - general_note, - bibliography_note, - toc, - cover_url, - cover_caption, - first_page, - last_page, - page_interval - ); - } - - #[test] - fn test_compile_full_title() { - let mut work = test_work(); - assert_eq!(work.compile_fulltitle(), "Some title".to_string()); - - work.subtitle = Some("With a subtitle".to_string()); - assert_eq!( - work.compile_fulltitle(), - "Some title: With a subtitle".to_string() - ); - - work.title = "Some title?".to_string(); - assert_eq!( - work.compile_fulltitle(), - "Some title? With a subtitle".to_string() - ); - - work.title = "Some title.".to_string(); - assert_eq!( - work.compile_fulltitle(), - "Some title. With a subtitle".to_string() - ); - - work.title = "Some title!".to_string(); - assert_eq!( - work.compile_fulltitle(), - "Some title! With a subtitle".to_string() - ); - } - - #[test] - fn test_compile_page_interval() { - let mut work = test_work(); - assert!(work.compile_page_interval().is_none()); - - work.first_page = Some("1".to_string()); - work.last_page = Some("10".to_string()); - assert_eq!(work.compile_page_interval(), Some("1–10".to_string())); - } - - #[test] - fn test_is_published() { - let mut work = test_work(); - - work.work_status = WorkStatus::Forthcoming; - assert!(!work.is_published()); - work.work_status = WorkStatus::Cancelled; - assert!(!work.is_published()); - work.work_status = WorkStatus::PostponedIndefinitely; - assert!(!work.is_published()); - - work.work_status = WorkStatus::Active; - assert!(work.is_published()); - work.work_status = WorkStatus::Withdrawn; - assert!(work.is_published()); - work.work_status = WorkStatus::Superseded; - assert!(work.is_published()); - } - - #[test] - fn test_is_out_of_print() { - let mut work = test_work(); - - work.work_status = WorkStatus::Forthcoming; - assert!(!work.is_out_of_print()); - work.work_status = WorkStatus::Cancelled; - assert!(!work.is_out_of_print()); - work.work_status = WorkStatus::PostponedIndefinitely; - assert!(!work.is_out_of_print()); - work.work_status = WorkStatus::Active; - assert!(!work.is_out_of_print()); - - work.work_status = WorkStatus::Withdrawn; - assert!(work.is_out_of_print()); - work.work_status = WorkStatus::Superseded; - assert!(work.is_out_of_print()); - } - - #[test] - fn test_is_active() { - let mut work = test_work(); - assert!(work.is_active()); - - work.work_status = WorkStatus::Forthcoming; - assert!(!work.is_active()); - work.work_status = WorkStatus::Cancelled; - assert!(!work.is_active()); - work.work_status = WorkStatus::PostponedIndefinitely; - assert!(!work.is_active()); - work.work_status = WorkStatus::Withdrawn; - assert!(!work.is_active()); - work.work_status = WorkStatus::Superseded; - assert!(!work.is_active()); - } - - #[test] - fn test_validate_fails_when_published_without_publication_date() { - let mut work = test_work(); - work.work_status = WorkStatus::Active; - work.publication_date = None; - - assert_eq!(work.validate(), Err(ThothError::PublicationDateError)); - } - - #[test] - fn test_validate_fails_when_published_with_withdrawn_date() { - let mut work = test_work(); - work.work_status = WorkStatus::Active; - work.withdrawn_date = Some(NaiveDate::from_ymd_opt(2021, 1, 1).unwrap()); - - assert_eq!(work.validate(), Err(ThothError::WithdrawnDateError)); - } - - #[test] - fn test_validate_fails_when_out_of_print_without_withdrawn_date() { - let mut work = test_work(); - work.work_status = WorkStatus::Withdrawn; - work.withdrawn_date = None; - - assert_eq!(work.validate(), Err(ThothError::NoWithdrawnDateError)); - work.work_status = WorkStatus::Superseded; - assert_eq!(work.validate(), Err(ThothError::NoWithdrawnDateError)); - } - - #[test] - fn test_validate_fails_when_withdrawn_date_before_publication_date() { - let mut work = test_work(); - work.work_status = WorkStatus::Withdrawn; - work.publication_date = Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()); - work.withdrawn_date = Some(NaiveDate::from_ymd_opt(2019, 12, 31).unwrap()); - - assert_eq!( - work.validate(), - Err(ThothError::WithdrawnDateBeforePublicationDateError) - ); - } - - #[test] - fn test_validate_succeeds() { - let mut work = test_work(); - work.work_status = WorkStatus::Withdrawn; - work.publication_date = Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()); - work.withdrawn_date = Some(NaiveDate::from_ymd_opt(2021, 1, 1).unwrap()); - - assert_eq!(work.validate(), Ok(())); - } -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::WorkPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/work/policy.rs b/thoth-api/src/model/work/policy.rs new file mode 100644 index 00000000..d52a355f --- /dev/null +++ b/thoth-api/src/model/work/policy.rs @@ -0,0 +1,58 @@ +use crate::model::work::{NewWork, PatchWork, Work, WorkProperties, WorkType}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy, UserAccess}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Work`. +/// +/// This policy layer enforces: +/// - authentication +/// - publisher membership derived from the entity / input via `PublisherId` +pub struct WorkPolicy; + +impl CreatePolicy for WorkPolicy { + fn can_create(ctx: &C, data: &NewWork, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + data.validate() + } +} + +impl UpdatePolicy for WorkPolicy { + fn can_update( + ctx: &C, + current: &Work, + patch: &PatchWork, + _params: (), + ) -> ThothResult<()> { + let user = ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + current.can_update_imprint(ctx.db())?; + + if patch.work_type == WorkType::BookChapter { + current.can_be_chapter(ctx.db())?; + } + + if patch.work_status != current.work_status + || patch.publication_date != current.publication_date + || patch.withdrawn_date != current.withdrawn_date + { + ctx.require_work_lifecycle_for(patch)?; + } + + patch.validate()?; + + if current.is_published() && !patch.is_published() && !user.is_superuser() { + return Err(ThothError::ThothSetWorkStatusError); + } + Ok(()) + } +} + +impl DeletePolicy for WorkPolicy { + fn can_delete(ctx: &C, current: &Work) -> ThothResult<()> { + let user = ctx.require_publisher_for(current)?; + if current.is_published() && !user.is_superuser() { + return Err(ThothError::ThothDeleteWorkError); + } + Ok(()) + } +} diff --git a/thoth-api/src/model/work/tests.rs b/thoth-api/src/model/work/tests.rs new file mode 100644 index 00000000..dcd6ce4c --- /dev/null +++ b/thoth-api/src/model/work/tests.rs @@ -0,0 +1,2169 @@ +use super::*; +use uuid::Uuid; + +fn make_work() -> Work { + use std::str::FromStr; + Work { + work_id: Uuid::parse_str("00000000-0000-0000-AAAA-000000000001").unwrap(), + work_type: WorkType::Monograph, + work_status: WorkStatus::Active, + reference: None, + edition: Some(1), + imprint_id: Uuid::parse_str("00000000-0000-0000-BBBB-000000000002").unwrap(), + doi: Some(Doi::from_str("https://doi.org/10.00001/BOOK.0001").unwrap()), + publication_date: NaiveDate::from_ymd_opt(1999, 12, 31), + withdrawn_date: None, + place: Some("León, Spain".to_string()), + page_count: Some(123), + page_breakdown: None, + image_count: Some(22), + table_count: Some(3), + audio_count: None, + video_count: None, + license: Some("https://creativecommons.org/licenses/by/4.0/".to_string()), + copyright_holder: Some("Author1".to_string()), + landing_page: Some("https://book.page".to_string()), + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: Some("https://book.cover/image".to_string()), + cover_caption: None, + created_at: Default::default(), + updated_at: Default::default(), + first_page: None, + last_page: None, + page_interval: None, + updated_at_with_relations: Default::default(), + } +} + +mod defaults { + use super::*; + + #[test] + fn worktype_default_is_monograph() { + let worktype: WorkType = Default::default(); + assert_eq!(worktype, WorkType::Monograph); + } + + #[test] + fn workstatus_default_is_forthcoming() { + let workstatus: WorkStatus = Default::default(); + assert_eq!(workstatus, WorkStatus::Forthcoming); + } + + #[test] + fn workfield_default_is_full_title() { + let workfield: WorkField = Default::default(); + assert_eq!(workfield, WorkField::FullTitle); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn worktype_display_formats_expected_strings() { + assert_eq!(format!("{}", WorkType::BookChapter), "Book Chapter"); + assert_eq!(format!("{}", WorkType::Monograph), "Monograph"); + assert_eq!(format!("{}", WorkType::EditedBook), "Edited Book"); + assert_eq!(format!("{}", WorkType::Textbook), "Textbook"); + assert_eq!(format!("{}", WorkType::JournalIssue), "Journal Issue"); + assert_eq!(format!("{}", WorkType::BookSet), "Book Set"); + } + + #[test] + fn workstatus_display_formats_expected_strings() { + assert_eq!(format!("{}", WorkStatus::Cancelled), "Cancelled"); + assert_eq!(format!("{}", WorkStatus::Forthcoming), "Forthcoming"); + assert_eq!( + format!("{}", WorkStatus::PostponedIndefinitely), + "Postponed Indefinitely" + ); + assert_eq!(format!("{}", WorkStatus::Active), "Active"); + assert_eq!(format!("{}", WorkStatus::Withdrawn), "Withdrawn"); + assert_eq!(format!("{}", WorkStatus::Superseded), "Superseded"); + } + + #[test] + fn workfield_display_formats_expected_strings() { + assert_eq!(format!("{}", WorkField::WorkId), "ID"); + assert_eq!(format!("{}", WorkField::WorkType), "Type"); + assert_eq!(format!("{}", WorkField::WorkStatus), "WorkStatus"); + assert_eq!(format!("{}", WorkField::Reference), "Reference"); + assert_eq!(format!("{}", WorkField::Edition), "Edition"); + assert_eq!(format!("{}", WorkField::Doi), "DOI"); + assert_eq!(format!("{}", WorkField::PublicationDate), "PublicationDate"); + assert_eq!(format!("{}", WorkField::WithdrawnDate), "WithdrawnDate"); + assert_eq!(format!("{}", WorkField::Place), "Place"); + assert_eq!(format!("{}", WorkField::PageCount), "PageCount"); + assert_eq!(format!("{}", WorkField::PageBreakdown), "PageBreakdown"); + assert_eq!(format!("{}", WorkField::FirstPage), "FirstPage"); + assert_eq!(format!("{}", WorkField::LastPage), "LastPage"); + assert_eq!(format!("{}", WorkField::PageInterval), "PageInterval"); + assert_eq!(format!("{}", WorkField::ImageCount), "ImageCount"); + assert_eq!(format!("{}", WorkField::TableCount), "TableCount"); + assert_eq!(format!("{}", WorkField::AudioCount), "AudioCount"); + assert_eq!(format!("{}", WorkField::VideoCount), "VideoCount"); + assert_eq!(format!("{}", WorkField::License), "License"); + assert_eq!(format!("{}", WorkField::CopyrightHolder), "CopyrightHolder"); + assert_eq!(format!("{}", WorkField::LandingPage), "LandingPage"); + assert_eq!(format!("{}", WorkField::Lccn), "LCCN"); + assert_eq!(format!("{}", WorkField::Oclc), "OCLC"); + assert_eq!(format!("{}", WorkField::ShortAbstract), "ShortAbstract"); + assert_eq!(format!("{}", WorkField::LongAbstract), "LongAbstract"); + assert_eq!(format!("{}", WorkField::GeneralNote), "GeneralNote"); + assert_eq!( + format!("{}", WorkField::BibliographyNote), + "BibliographyNote" + ); + assert_eq!(format!("{}", WorkField::Toc), "TOC"); + assert_eq!( + format!("{}", WorkField::ResourcesDescription), + "ResourcesDescription" + ); + assert_eq!(format!("{}", WorkField::CoverUrl), "CoverURL"); + assert_eq!(format!("{}", WorkField::CoverCaption), "CoverCaption"); + assert_eq!(format!("{}", WorkField::CreatedAt), "CreatedAt"); + assert_eq!(format!("{}", WorkField::UpdatedAt), "UpdatedAt"); + assert_eq!( + format!("{}", WorkField::UpdatedAtWithRelations), + "UpdatedAtWithRelations" + ); + } + + #[test] + fn worktype_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + WorkType::from_str("Book Chapter").unwrap(), + WorkType::BookChapter + ); + assert_eq!( + WorkType::from_str("Monograph").unwrap(), + WorkType::Monograph + ); + assert_eq!( + WorkType::from_str("Edited Book").unwrap(), + WorkType::EditedBook + ); + assert_eq!(WorkType::from_str("Textbook").unwrap(), WorkType::Textbook); + assert_eq!( + WorkType::from_str("Journal Issue").unwrap(), + WorkType::JournalIssue + ); + assert_eq!(WorkType::from_str("Book Set").unwrap(), WorkType::BookSet); + + assert!(WorkType::from_str("Book Section").is_err()); + assert!(WorkType::from_str("Manuscript").is_err()); + } + + #[test] + fn workstatus_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + WorkStatus::from_str("Cancelled").unwrap(), + WorkStatus::Cancelled + ); + assert_eq!( + WorkStatus::from_str("Forthcoming").unwrap(), + WorkStatus::Forthcoming + ); + assert_eq!( + WorkStatus::from_str("Postponed Indefinitely").unwrap(), + WorkStatus::PostponedIndefinitely + ); + assert_eq!(WorkStatus::from_str("Active").unwrap(), WorkStatus::Active); + assert_eq!( + WorkStatus::from_str("Withdrawn").unwrap(), + WorkStatus::Withdrawn + ); + assert_eq!( + WorkStatus::from_str("Superseded").unwrap(), + WorkStatus::Superseded + ); + + assert!(WorkStatus::from_str("Published").is_err()); + assert!(WorkStatus::from_str("Unpublished").is_err()); + } + + #[test] + fn workfield_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!(WorkField::from_str("ID").unwrap(), WorkField::WorkId); + assert_eq!(WorkField::from_str("Type").unwrap(), WorkField::WorkType); + assert_eq!( + WorkField::from_str("WorkStatus").unwrap(), + WorkField::WorkStatus + ); + assert_eq!(WorkField::from_str("Title").unwrap(), WorkField::FullTitle); + assert_eq!(WorkField::from_str("ShortTitle").unwrap(), WorkField::Title); + assert_eq!( + WorkField::from_str("Subtitle").unwrap(), + WorkField::Subtitle + ); + assert_eq!( + WorkField::from_str("Reference").unwrap(), + WorkField::Reference + ); + assert_eq!(WorkField::from_str("Edition").unwrap(), WorkField::Edition); + assert_eq!(WorkField::from_str("DOI").unwrap(), WorkField::Doi); + assert_eq!( + WorkField::from_str("PublicationDate").unwrap(), + WorkField::PublicationDate + ); + assert_eq!( + WorkField::from_str("WithdrawnDate").unwrap(), + WorkField::WithdrawnDate + ); + assert_eq!(WorkField::from_str("Place").unwrap(), WorkField::Place); + assert_eq!( + WorkField::from_str("PageCount").unwrap(), + WorkField::PageCount + ); + assert_eq!( + WorkField::from_str("PageBreakdown").unwrap(), + WorkField::PageBreakdown + ); + assert_eq!( + WorkField::from_str("FirstPage").unwrap(), + WorkField::FirstPage + ); + assert_eq!( + WorkField::from_str("LastPage").unwrap(), + WorkField::LastPage + ); + assert_eq!( + WorkField::from_str("PageInterval").unwrap(), + WorkField::PageInterval + ); + assert_eq!( + WorkField::from_str("ImageCount").unwrap(), + WorkField::ImageCount + ); + assert_eq!( + WorkField::from_str("TableCount").unwrap(), + WorkField::TableCount + ); + assert_eq!( + WorkField::from_str("AudioCount").unwrap(), + WorkField::AudioCount + ); + assert_eq!( + WorkField::from_str("VideoCount").unwrap(), + WorkField::VideoCount + ); + assert_eq!(WorkField::from_str("License").unwrap(), WorkField::License); + assert_eq!( + WorkField::from_str("CopyrightHolder").unwrap(), + WorkField::CopyrightHolder + ); + assert_eq!( + WorkField::from_str("LandingPage").unwrap(), + WorkField::LandingPage + ); + assert_eq!(WorkField::from_str("LCCN").unwrap(), WorkField::Lccn); + assert_eq!(WorkField::from_str("OCLC").unwrap(), WorkField::Oclc); + assert_eq!( + WorkField::from_str("ShortAbstract").unwrap(), + WorkField::ShortAbstract + ); + assert_eq!( + WorkField::from_str("LongAbstract").unwrap(), + WorkField::LongAbstract + ); + assert_eq!( + WorkField::from_str("GeneralNote").unwrap(), + WorkField::GeneralNote + ); + assert_eq!( + WorkField::from_str("BibliographyNote").unwrap(), + WorkField::BibliographyNote + ); + assert_eq!(WorkField::from_str("TOC").unwrap(), WorkField::Toc); + assert_eq!( + WorkField::from_str("ResourcesDescription").unwrap(), + WorkField::ResourcesDescription + ); + assert_eq!( + WorkField::from_str("CoverURL").unwrap(), + WorkField::CoverUrl + ); + assert_eq!( + WorkField::from_str("CoverCaption").unwrap(), + WorkField::CoverCaption + ); + assert_eq!( + WorkField::from_str("CreatedAt").unwrap(), + WorkField::CreatedAt + ); + assert_eq!( + WorkField::from_str("UpdatedAt").unwrap(), + WorkField::UpdatedAt + ); + assert_eq!( + WorkField::from_str("UpdatedAtWithRelations").unwrap(), + WorkField::UpdatedAtWithRelations + ); + assert!(WorkField::from_str("WorkID").is_err()); + assert!(WorkField::from_str("Contributors").is_err()); + assert!(WorkField::from_str("Publisher").is_err()); + } +} + +mod conversions { + use super::*; + #[cfg(feature = "backend")] + use crate::model::tests::db::setup_test_db; + #[cfg(feature = "backend")] + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn work_into_patchwork_copies_fields() { + let work = make_work(); + let patch_work: PatchWork = work.clone().into(); + + macro_rules! assert_fields_eq { + ($($field:ident),+) => { + $( + assert_eq!(work.$field, patch_work.$field); + )+ + }; + } + assert_fields_eq!( + work_id, + work_type, + work_status, + reference, + edition, + imprint_id, + doi, + publication_date, + withdrawn_date, + place, + page_count, + page_breakdown, + image_count, + table_count, + audio_count, + video_count, + license, + copyright_holder, + landing_page, + lccn, + oclc, + general_note, + bibliography_note, + toc, + resources_description, + cover_url, + cover_caption, + first_page, + last_page, + page_interval + ); + } + + #[cfg(feature = "backend")] + #[test] + fn worktype_graphql_roundtrip() { + assert_graphql_enum_roundtrip(WorkType::Monograph); + } + + #[cfg(feature = "backend")] + #[test] + fn workstatus_graphql_roundtrip() { + assert_graphql_enum_roundtrip(WorkStatus::Active); + } + + #[cfg(feature = "backend")] + #[test] + fn workfield_graphql_roundtrip() { + assert_graphql_enum_roundtrip(WorkField::WorkId); + } + + #[cfg(feature = "backend")] + #[test] + fn worktype_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::( + pool.as_ref(), + "'monograph'::work_type", + WorkType::Monograph, + ); + } + + #[cfg(feature = "backend")] + #[test] + fn workstatus_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::( + pool.as_ref(), + "'active'::work_status", + WorkStatus::Active, + ); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn compile_page_interval_returns_expected_interval() { + let mut work = make_work(); + assert!(work.compile_page_interval().is_none()); + + work.first_page = Some("1".to_string()); + work.last_page = Some("10".to_string()); + assert_eq!(work.compile_page_interval(), Some("1–10".to_string())); + } + + #[test] + fn is_published_returns_true_for_published_statuses() { + let mut work = make_work(); + + work.work_status = WorkStatus::Forthcoming; + assert!(!work.is_published()); + work.work_status = WorkStatus::Cancelled; + assert!(!work.is_published()); + work.work_status = WorkStatus::PostponedIndefinitely; + assert!(!work.is_published()); + + work.work_status = WorkStatus::Active; + assert!(work.is_published()); + work.work_status = WorkStatus::Withdrawn; + assert!(work.is_published()); + work.work_status = WorkStatus::Superseded; + assert!(work.is_published()); + } + + #[test] + fn is_out_of_print_returns_true_for_out_of_print_statuses() { + let mut work = make_work(); + + work.work_status = WorkStatus::Forthcoming; + assert!(!work.is_out_of_print()); + work.work_status = WorkStatus::Cancelled; + assert!(!work.is_out_of_print()); + work.work_status = WorkStatus::PostponedIndefinitely; + assert!(!work.is_out_of_print()); + work.work_status = WorkStatus::Active; + assert!(!work.is_out_of_print()); + + work.work_status = WorkStatus::Withdrawn; + assert!(work.is_out_of_print()); + work.work_status = WorkStatus::Superseded; + assert!(work.is_out_of_print()); + } + + #[test] + fn is_active_returns_true_for_active_status() { + let mut work = make_work(); + assert!(work.is_active()); + + work.work_status = WorkStatus::Forthcoming; + assert!(!work.is_active()); + work.work_status = WorkStatus::Cancelled; + assert!(!work.is_active()); + work.work_status = WorkStatus::PostponedIndefinitely; + assert!(!work.is_active()); + work.work_status = WorkStatus::Withdrawn; + assert!(!work.is_active()); + work.work_status = WorkStatus::Superseded; + assert!(!work.is_active()); + } + + #[test] + fn pk_returns_id() { + let work: Work = Default::default(); + assert_eq!(work.pk(), work.work_id); + } + + #[test] + fn history_entry_serializes_model() { + let work: Work = Default::default(); + let user_id = "123456".to_string(); + let new_work_history = work.new_history_entry(&user_id); + assert_eq!(new_work_history.work_id, work.work_id); + assert_eq!(new_work_history.user_id, user_id); + assert_eq!( + new_work_history.data, + serde_json::Value::String(serde_json::to_string(&work).unwrap()) + ); + } +} + +mod validation { + use super::*; + + #[test] + fn validate_fails_when_published_without_publication_date() { + let mut work = make_work(); + work.work_status = WorkStatus::Active; + work.publication_date = None; + + assert_eq!(work.validate(), Err(ThothError::PublicationDateError)); + } + + #[test] + fn validate_fails_when_published_with_withdrawn_date() { + let mut work = make_work(); + work.work_status = WorkStatus::Active; + work.withdrawn_date = Some(NaiveDate::from_ymd_opt(2021, 1, 1).unwrap()); + + assert_eq!(work.validate(), Err(ThothError::WithdrawnDateError)); + } + + #[test] + fn validate_fails_when_out_of_print_without_withdrawn_date() { + let mut work = make_work(); + work.work_status = WorkStatus::Withdrawn; + work.withdrawn_date = None; + + assert_eq!(work.validate(), Err(ThothError::NoWithdrawnDateError)); + work.work_status = WorkStatus::Superseded; + assert_eq!(work.validate(), Err(ThothError::NoWithdrawnDateError)); + } + + #[test] + fn validate_fails_when_withdrawn_date_before_publication_date() { + let mut work = make_work(); + work.work_status = WorkStatus::Withdrawn; + work.publication_date = Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()); + work.withdrawn_date = Some(NaiveDate::from_ymd_opt(2019, 12, 31).unwrap()); + + assert_eq!( + work.validate(), + Err(ThothError::WithdrawnDateBeforePublicationDateError) + ); + } + + #[test] + fn validate_succeeds_with_valid_dates() { + let mut work = make_work(); + work.work_status = WorkStatus::Withdrawn; + work.publication_date = Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()); + work.withdrawn_date = Some(NaiveDate::from_ymd_opt(2021, 1, 1).unwrap()); + + assert_eq!(work.validate(), Ok(())); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + use std::collections::HashMap; + + use chrono::NaiveDate; + use zitadel::actix::introspection::IntrospectedUser; + + use crate::model::publication::{NewPublication, Publication, PublicationType}; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_superuser, test_user_with_role, + }; + use crate::model::work::policy::WorkPolicy; + use crate::model::Crud; + use crate::model::Isbn; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + use thoth_errors::ThothError; + + fn make_patch_from_work(work: &Work) -> PatchWork { + PatchWork { + work_id: work.work_id, + work_type: work.work_type, + work_status: work.work_status, + reference: work.reference.clone(), + edition: work.edition, + imprint_id: work.imprint_id, + doi: work.doi.clone(), + publication_date: work.publication_date, + withdrawn_date: work.withdrawn_date, + place: work.place.clone(), + page_count: work.page_count, + page_breakdown: work.page_breakdown.clone(), + image_count: work.image_count, + table_count: work.table_count, + audio_count: work.audio_count, + video_count: work.video_count, + license: work.license.clone(), + copyright_holder: work.copyright_holder.clone(), + landing_page: work.landing_page.clone(), + lccn: work.lccn.clone(), + oclc: work.oclc.clone(), + general_note: work.general_note.clone(), + bibliography_note: work.bibliography_note.clone(), + toc: work.toc.clone(), + resources_description: None, + cover_url: work.cover_url.clone(), + cover_caption: work.cover_caption.clone(), + first_page: work.first_page.clone(), + last_page: work.last_page.clone(), + page_interval: work.page_interval.clone(), + } + } + + fn make_new_work(imprint_id: Uuid) -> NewWork { + NewWork { + work_type: WorkType::Monograph, + work_status: WorkStatus::Forthcoming, + reference: None, + edition: Some(1), + imprint_id, + doi: None, + publication_date: None, + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: None, + last_page: None, + page_interval: None, + } + } + + fn make_new_publication_with_isbn(work_id: Uuid, isbn: Isbn) -> NewPublication { + NewPublication { + publication_type: PublicationType::Epub, + work_id, + isbn: Some(isbn), + width_mm: None, + width_in: None, + height_mm: None, + height_in: None, + depth_mm: None, + depth_in: None, + weight_g: None, + weight_oz: None, + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + } + } + + fn user_with_roles(user_id: &str, org_id: &str, roles: &[Role]) -> IntrospectedUser { + let mut project_roles = HashMap::new(); + for role in roles { + let mut scoped = HashMap::new(); + scoped.insert(org_id.to_string(), "role".to_string()); + project_roles.insert(role.as_ref().to_string(), scoped); + } + + IntrospectedUser { + user_id: user_id.to_string(), + username: None, + name: None, + given_name: None, + family_name: None, + preferred_username: None, + email: None, + email_verified: None, + locale: None, + project_roles: Some(project_roles), + metadata: None, + } + } + + #[test] + fn crud_policy_allows_publisher_user_for_create() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("work-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let new_work = NewWork { + work_type: WorkType::Monograph, + work_status: WorkStatus::Forthcoming, + reference: None, + edition: Some(1), + imprint_id: imprint.imprint_id, + doi: None, + publication_date: None, + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: None, + last_page: None, + page_interval: None, + }; + + assert!(WorkPolicy::can_create(&ctx, &new_work, ()).is_ok()); + + let other_user = test_user_with_role("work-user", Role::PublisherUser, "org-other"); + let other_ctx = test_context_with_user(pool.clone(), other_user); + assert!(WorkPolicy::can_create(&other_ctx, &new_work, ()).is_err()); + } + + #[test] + fn crud_policy_requires_work_lifecycle_role_for_status_change() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let mut patch = make_patch_from_work(&work); + patch.work_status = WorkStatus::Active; + patch.publication_date = NaiveDate::from_ymd_opt(2020, 1, 1); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let basic_user = test_user_with_role("work-user", Role::PublisherUser, &org_id); + let basic_ctx = test_context_with_user(pool.clone(), basic_user); + assert!(WorkPolicy::can_update(&basic_ctx, &work, &patch, ()).is_err()); + + let lifecycle_user = user_with_roles( + "work-user", + &org_id, + &[Role::PublisherUser, Role::WorkLifecycle], + ); + let lifecycle_ctx = test_context_with_user(pool.clone(), lifecycle_user); + assert!(WorkPolicy::can_update(&lifecycle_ctx, &work, &patch, ()).is_ok()); + } + + #[test] + fn crud_policy_requires_work_lifecycle_role_for_withdrawn_date_change() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let mut new_work = make_new_work(imprint.imprint_id); + new_work.work_status = WorkStatus::Withdrawn; + new_work.publication_date = NaiveDate::from_ymd_opt(2020, 1, 1); + new_work.withdrawn_date = NaiveDate::from_ymd_opt(2021, 1, 1); + let work = Work::create(pool.as_ref(), &new_work).expect("Failed to create work"); + + let mut patch = make_patch_from_work(&work); + patch.withdrawn_date = NaiveDate::from_ymd_opt(2022, 1, 1); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let basic_user = test_user_with_role("work-user", Role::PublisherUser, &org_id); + let basic_ctx = test_context_with_user(pool.clone(), basic_user); + assert!(WorkPolicy::can_update(&basic_ctx, &work, &patch, ()).is_err()); + + let lifecycle_user = user_with_roles( + "work-user", + &org_id, + &[Role::PublisherUser, Role::WorkLifecycle], + ); + let lifecycle_ctx = test_context_with_user(pool.clone(), lifecycle_user); + assert!(WorkPolicy::can_update(&lifecycle_ctx, &work, &patch, ()).is_ok()); + } + + #[test] + fn crud_policy_allows_non_lifecycle_update_without_work_lifecycle_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let mut patch = make_patch_from_work(&work); + patch.reference = Some("Updated reference".to_string()); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let basic_user = test_user_with_role("work-user", Role::PublisherUser, &org_id); + let basic_ctx = test_context_with_user(pool.clone(), basic_user); + + assert!(WorkPolicy::can_update(&basic_ctx, &work, &patch, ()).is_ok()); + } + + #[test] + fn crud_policy_rejects_chapter_when_isbn_publication_exists() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let new_publication = + make_new_publication_with_isbn(work.work_id, Isbn("978-3-16-148410-0".to_string())); + + Publication::create(pool.as_ref(), &new_publication) + .expect("Failed to create publication with ISBN"); + + let mut patch = make_patch_from_work(&work); + patch.work_type = WorkType::BookChapter; + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("work-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let result = WorkPolicy::can_update(&ctx, &work, &patch, ()); + assert!(matches!(result, Err(ThothError::ChapterIsbnError))); + } + + #[test] + fn crud_policy_prevents_non_superuser_from_unpublishing_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let mut new_work = make_new_work(imprint.imprint_id); + new_work.work_status = WorkStatus::Active; + new_work.publication_date = NaiveDate::from_ymd_opt(2020, 1, 1); + let work = Work::create(pool.as_ref(), &new_work).expect("Failed to create work"); + + let mut patch = make_patch_from_work(&work); + patch.work_status = WorkStatus::Forthcoming; + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let lifecycle_user = user_with_roles( + "work-user", + &org_id, + &[Role::PublisherUser, Role::WorkLifecycle], + ); + let ctx = test_context_with_user(pool.clone(), lifecycle_user); + + let result = WorkPolicy::can_update(&ctx, &work, &patch, ()); + assert!(matches!(result, Err(ThothError::ThothSetWorkStatusError))); + } + + #[test] + fn crud_policy_prevents_non_superuser_from_deleting_published_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let new_work = NewWork { + work_type: WorkType::Monograph, + work_status: WorkStatus::Active, + reference: None, + edition: Some(1), + imprint_id: imprint.imprint_id, + doi: None, + publication_date: NaiveDate::from_ymd_opt(2020, 1, 1), + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: None, + last_page: None, + page_interval: None, + }; + + let work = Work::create(pool.as_ref(), &new_work).expect("Failed to create work"); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("work-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + assert!(WorkPolicy::can_delete(&ctx, &work).is_err()); + + let super_ctx = test_context_with_user(pool.clone(), test_superuser("work-super")); + assert!(WorkPolicy::can_delete(&super_ctx, &work).is_ok()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + use chrono::NaiveDate; + use std::str::FromStr; + use uuid::Uuid; + + use crate::graphql::types::inputs::{Expression, TimeExpression}; + use crate::model::additional_resource::{ + AdditionalResource, NewAdditionalResource, ResourceType, + }; + use crate::model::issue::{Issue, NewIssue}; + use crate::model::locale::LocaleCode; + use crate::model::publication::{NewPublication, Publication, PublicationType}; + use crate::model::r#abstract::{Abstract, AbstractType, NewAbstract}; + use crate::model::tests::db::{ + create_imprint, create_publisher, create_series, create_work, setup_test_db, test_context, + }; + use crate::model::title::{NewTitle, Title}; + use crate::model::work_relation::{NewWorkRelation, RelationType, WorkRelation}; + use crate::model::{Crud, Doi, Isbn, Timestamp}; + use thoth_errors::ThothError; + + fn make_new_work(imprint_id: Uuid) -> NewWork { + NewWork { + work_type: WorkType::Monograph, + work_status: WorkStatus::Forthcoming, + reference: None, + edition: Some(1), + imprint_id, + doi: None, + publication_date: None, + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: None, + last_page: None, + page_interval: None, + } + } + + fn make_work( + pool: &crate::db::PgPool, + imprint_id: Uuid, + work_type: WorkType, + work_status: WorkStatus, + reference: Option, + ) -> Work { + let new_work = NewWork { + work_type, + work_status, + reference, + ..make_new_work(imprint_id) + }; + + Work::create(pool, &new_work).expect("Failed to create work") + } + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let fetched_work = + Work::from_id(pool.as_ref(), &work.work_id).expect("Failed to fetch work"); + assert_eq!(work.work_id, fetched_work.work_id); + + let patch = PatchWork { + work_id: work.work_id, + work_type: work.work_type, + work_status: work.work_status, + reference: Some(format!("Updated {}", Uuid::new_v4())), + edition: work.edition, + imprint_id: work.imprint_id, + doi: work.doi.clone(), + publication_date: work.publication_date, + withdrawn_date: work.withdrawn_date, + place: work.place.clone(), + page_count: work.page_count, + page_breakdown: work.page_breakdown.clone(), + image_count: work.image_count, + table_count: work.table_count, + audio_count: work.audio_count, + video_count: work.video_count, + license: work.license.clone(), + copyright_holder: work.copyright_holder.clone(), + landing_page: work.landing_page.clone(), + lccn: work.lccn.clone(), + oclc: work.oclc.clone(), + general_note: work.general_note.clone(), + bibliography_note: work.bibliography_note.clone(), + toc: work.toc.clone(), + resources_description: None, + cover_url: work.cover_url.clone(), + cover_caption: work.cover_caption.clone(), + first_page: work.first_page.clone(), + last_page: work.last_page.clone(), + page_interval: work.page_interval.clone(), + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = work.update(&ctx, &patch).expect("Failed to update work"); + assert_eq!(updated.reference, patch.reference); + + let deleted = updated + .delete(pool.as_ref()) + .expect("Failed to delete work"); + assert!(Work::from_id(pool.as_ref(), &deleted.work_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + create_work(pool.as_ref(), &imprint); + create_work(pool.as_ref(), &imprint); + + let order = WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }; + + let first = Work::all( + pool.as_ref(), + 1, + 0, + None, + order.clone(), + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch works"); + let second = Work::all( + pool.as_ref(), + 1, + 1, + None, + order, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to fetch works"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].work_id, second[0].work_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + create_work(pool.as_ref(), &imprint); + create_work(pool.as_ref(), &imprint); + + let count = Work::count(pool.as_ref(), None, vec![], vec![], vec![], None, None) + .expect("Failed to count works"); + assert_eq!(count, 2); + } + + #[test] + fn crud_count_filters_by_publisher_type_status_and_publication_date() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + + Work::create( + pool.as_ref(), + &NewWork { + work_type: WorkType::Monograph, + work_status: WorkStatus::Active, + publication_date: NaiveDate::from_ymd_opt(2000, 1, 1), + ..make_new_work(imprint.imprint_id) + }, + ) + .expect("Failed to create work"); + Work::create( + pool.as_ref(), + &NewWork { + work_type: WorkType::BookChapter, + work_status: WorkStatus::Forthcoming, + edition: None, + publication_date: NaiveDate::from_ymd_opt(2020, 1, 1), + ..make_new_work(other_imprint.imprint_id) + }, + ) + .expect("Failed to create work"); + + let count_by_publisher = Work::count( + pool.as_ref(), + None, + vec![publisher.publisher_id], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count works by publisher"); + assert_eq!(count_by_publisher, 1); + + let count_by_type = Work::count( + pool.as_ref(), + None, + vec![], + vec![WorkType::BookChapter], + vec![], + None, + None, + ) + .expect("Failed to count works by type"); + assert_eq!(count_by_type, 1); + + let count_by_status = Work::count( + pool.as_ref(), + None, + vec![], + vec![], + vec![WorkStatus::Active], + None, + None, + ) + .expect("Failed to count works by status"); + assert_eq!(count_by_status, 1); + + let newer_than = TimeExpression { + timestamp: Timestamp::parse_from_rfc3339("2010-01-01T00:00:00Z").unwrap(), + expression: Expression::GreaterThan, + }; + let count_by_date = Work::count( + pool.as_ref(), + None, + vec![], + vec![], + vec![], + Some(newer_than), + None, + ) + .expect("Failed to count works by publication date"); + assert_eq!(count_by_date, 1); + } + + #[test] + fn crud_filter_matches_reference() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let marker = format!("Ref {}", Uuid::new_v4()); + let matches = make_work( + pool.as_ref(), + imprint.imprint_id, + WorkType::Monograph, + WorkStatus::Forthcoming, + Some(marker.clone()), + ); + make_work( + pool.as_ref(), + imprint.imprint_id, + WorkType::Monograph, + WorkStatus::Forthcoming, + Some("Other Ref".to_string()), + ); + + let filtered = Work::all( + pool.as_ref(), + 10, + 0, + Some(marker), + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter works"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].work_id, matches.work_id); + } + + #[test] + fn crud_filter_param_limits_work_types() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let matches = make_work( + pool.as_ref(), + imprint.imprint_id, + WorkType::Monograph, + WorkStatus::Forthcoming, + None, + ); + make_work( + pool.as_ref(), + imprint.imprint_id, + WorkType::EditedBook, + WorkStatus::Forthcoming, + None, + ); + + let filtered = Work::all( + pool.as_ref(), + 10, + 0, + None, + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![WorkType::Monograph], + vec![], + None, + None, + ) + .expect("Failed to filter works by type"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].work_id, matches.work_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let first = create_work(pool.as_ref(), &imprint); + let second = create_work(pool.as_ref(), &imprint); + let mut ids = [first.work_id, second.work_id]; + ids.sort(); + + let asc = Work::all( + pool.as_ref(), + 2, + 0, + None, + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order works (asc)"); + + let desc = Work::all( + pool.as_ref(), + 2, + 0, + None, + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order works (desc)"); + + assert_eq!(asc[0].work_id, ids[0]); + assert_eq!(desc[0].work_id, ids[1]); + } + + #[test] + fn crud_ordering_by_publication_date_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let context = test_context(pool.clone(), "work-order-user"); + + let first = Work::create(pool.as_ref(), &make_new_work(imprint.imprint_id)) + .expect("Failed to create first work"); + let second = Work::create(pool.as_ref(), &make_new_work(imprint.imprint_id)) + .expect("Failed to create second work"); + + let mut by_id = [first, second]; + by_id.sort_by_key(|work| work.work_id); + + let mut newer_patch: PatchWork = by_id[0].clone().into(); + newer_patch.work_status = WorkStatus::Active; + newer_patch.publication_date = NaiveDate::from_ymd_opt(2025, 1, 1); + by_id[0] + .update(&context, &newer_patch) + .expect("Failed to update newer work"); + + let mut older_patch: PatchWork = by_id[1].clone().into(); + older_patch.work_status = WorkStatus::Active; + older_patch.publication_date = NaiveDate::from_ymd_opt(2020, 1, 1); + by_id[1] + .update(&context, &older_patch) + .expect("Failed to update older work"); + + let asc = Work::all( + pool.as_ref(), + 2, + 0, + None, + WorkOrderBy { + field: WorkField::PublicationDate, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order works by publication date (asc)"); + + let desc = Work::all( + pool.as_ref(), + 2, + 0, + None, + WorkOrderBy { + field: WorkField::PublicationDate, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order works by publication date (desc)"); + + assert_eq!(asc[0].work_id, by_id[1].work_id); + assert_eq!(desc[0].work_id, by_id[0].work_id); + } + + #[test] + fn crud_ordering_by_full_title_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + + let first = Work::create(pool.as_ref(), &make_new_work(imprint.imprint_id)) + .expect("Failed to create first work"); + let second = Work::create(pool.as_ref(), &make_new_work(imprint.imprint_id)) + .expect("Failed to create second work"); + + let mut by_id = [first, second]; + by_id.sort_by_key(|work| work.work_id); + + Title::create( + pool.as_ref(), + &NewTitle { + work_id: by_id[0].work_id, + locale_code: LocaleCode::En, + full_title: "Zulu".to_string(), + title: "Zulu".to_string(), + subtitle: None, + canonical: true, + }, + ) + .expect("Failed to create first title"); + + Title::create( + pool.as_ref(), + &NewTitle { + work_id: by_id[1].work_id, + locale_code: LocaleCode::En, + full_title: "Alpha".to_string(), + title: "Alpha".to_string(), + subtitle: None, + canonical: true, + }, + ) + .expect("Failed to create second title"); + + let asc = Work::all( + pool.as_ref(), + 2, + 0, + None, + WorkOrderBy { + field: WorkField::FullTitle, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order works by full title (asc)"); + + let desc = Work::all( + pool.as_ref(), + 2, + 0, + None, + WorkOrderBy { + field: WorkField::FullTitle, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order works by full title (desc)"); + + assert_eq!(asc[0].work_id, by_id[1].work_id); + assert_eq!(desc[0].work_id, by_id[0].work_id); + } + + #[test] + fn crud_from_doi_respects_case_and_type_filter() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let doi = Doi::from_str("https://doi.org/10.1234/TEST.DOI").unwrap(); + let new_work = NewWork { + doi: Some(doi.clone()), + ..make_new_work(imprint.imprint_id) + }; + let work = Work::create(pool.as_ref(), &new_work).expect("Failed to create work"); + + let lookup = Doi::from_str("https://doi.org/10.1234/test.doi").unwrap(); + let found = Work::from_doi(pool.as_ref(), lookup.clone(), vec![]) + .expect("Failed to fetch work by DOI"); + assert_eq!(found.work_id, work.work_id); + + let filtered_ok = Work::from_doi(pool.as_ref(), lookup.clone(), vec![WorkType::Monograph]) + .expect("Failed to fetch work by DOI with type filter"); + assert_eq!(filtered_ok.work_id, work.work_id); + + let filtered_err = Work::from_doi(pool.as_ref(), lookup, vec![WorkType::EditedBook]); + assert!(filtered_err.is_err()); + } + + #[test] + fn crud_can_update_imprint_rejects_work_with_issue() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + assert!(work.can_update_imprint(pool.as_ref()).is_ok()); + + let series = create_series(pool.as_ref(), &imprint); + Issue::create( + pool.as_ref(), + &NewIssue { + series_id: series.series_id, + work_id: work.work_id, + issue_ordinal: 1, + issue_number: None, + }, + ) + .expect("Failed to create issue"); + + assert!(work.can_update_imprint(pool.as_ref()).is_err()); + } + + #[test] + fn crud_can_be_chapter_rejects_work_with_isbn_publication() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + assert!(work.can_be_chapter(pool.as_ref()).is_ok()); + + Publication::create( + pool.as_ref(), + &NewPublication { + publication_type: PublicationType::Paperback, + work_id: work.work_id, + isbn: Some(Isbn::from_str("9780131103627").unwrap()), + width_mm: None, + width_in: None, + height_mm: None, + height_in: None, + depth_mm: None, + depth_in: None, + weight_g: None, + weight_oz: None, + accessibility_standard: None, + accessibility_additional_standard: None, + accessibility_exception: None, + accessibility_report_url: None, + }, + ) + .expect("Failed to create publication"); + + assert!(work.can_be_chapter(pool.as_ref()).is_err()); + } + + #[test] + fn crud_can_be_chapter_rejects_work_with_additional_resource() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + assert!(work.can_be_chapter(pool.as_ref()).is_ok()); + + AdditionalResource::create( + pool.as_ref(), + &NewAdditionalResource { + work_id: work.work_id, + title: "Resource".to_string(), + description: Some("Description".to_string()), + attribution: Some("Attribution".to_string()), + resource_type: ResourceType::Website, + doi: None, + handle: None, + url: Some("https://example.com/resource".to_string()), + date: None, + resource_ordinal: 1, + }, + ) + .expect("Failed to create additional resource"); + + let result = work.can_be_chapter(pool.as_ref()); + assert!(matches!(result, Err(ThothError::ChapterBookMetadataError))); + } + + #[test] + fn crud_children_returns_has_child_relations() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let parent = create_work(pool.as_ref(), &imprint); + let child = create_work(pool.as_ref(), &imprint); + let other = create_work(pool.as_ref(), &imprint); + + WorkRelation::create( + pool.as_ref(), + &NewWorkRelation { + relator_work_id: parent.work_id, + related_work_id: child.work_id, + relation_type: RelationType::HasChild, + relation_ordinal: 1, + }, + ) + .expect("Failed to create work relation"); + WorkRelation::create( + pool.as_ref(), + &NewWorkRelation { + relator_work_id: parent.work_id, + related_work_id: other.work_id, + relation_type: RelationType::HasPart, + relation_ordinal: 2, + }, + ) + .expect("Failed to create work relation"); + + let children = parent + .children(pool.as_ref()) + .expect("Failed to load children"); + + assert_eq!(children.len(), 1); + assert_eq!(children[0].work_id, child.work_id); + } + + #[test] + fn crud_filter_publishers_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let matches = create_work(pool.as_ref(), &imprint); + + let other_publisher = create_publisher(pool.as_ref()); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + create_work(pool.as_ref(), &other_imprint); + + let filtered = Work::all( + pool.as_ref(), + 10, + 0, + None, + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }, + vec![publisher.publisher_id], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter works by publisher"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].work_id, matches.work_id); + } + + #[test] + fn crud_filter_parent_imprint_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let matches = create_work(pool.as_ref(), &imprint); + + let other_imprint = create_imprint(pool.as_ref(), &publisher); + create_work(pool.as_ref(), &other_imprint); + + let filtered = Work::all( + pool.as_ref(), + 10, + 0, + None, + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }, + vec![], + Some(imprint.imprint_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter works by imprint"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].work_id, matches.work_id); + } + + #[test] + fn crud_filter_param_limits_work_statuses() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let matches = make_work( + pool.as_ref(), + imprint.imprint_id, + WorkType::Monograph, + WorkStatus::Forthcoming, + None, + ); + Work::create( + pool.as_ref(), + &NewWork { + work_status: WorkStatus::Active, + publication_date: NaiveDate::from_ymd_opt(2020, 1, 1), + ..make_new_work(imprint.imprint_id) + }, + ) + .expect("Failed to create work"); + + let filtered = Work::all( + pool.as_ref(), + 10, + 0, + None, + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![WorkStatus::Forthcoming], + None, + None, + ) + .expect("Failed to filter works by status"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].work_id, matches.work_id); + } + + #[test] + fn crud_filter_matches_title_and_abstract() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let marker = format!("Marker {}", Uuid::new_v4()); + + let work_with_title = create_work(pool.as_ref(), &imprint); + Title::create( + pool.as_ref(), + &NewTitle { + work_id: work_with_title.work_id, + locale_code: LocaleCode::En, + full_title: format!("Title {marker}"), + title: "Title".to_string(), + subtitle: None, + canonical: true, + }, + ) + .expect("Failed to create title"); + + let work_with_abstract = create_work(pool.as_ref(), &imprint); + Abstract::create( + pool.as_ref(), + &NewAbstract { + work_id: work_with_abstract.work_id, + content: format!("Abstract {marker}"), + locale_code: LocaleCode::En, + abstract_type: AbstractType::Long, + canonical: true, + }, + ) + .expect("Failed to create abstract"); + + let filtered = Work::all( + pool.as_ref(), + 10, + 0, + Some(marker.clone()), + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter works by title/abstract"); + + assert_eq!(filtered.len(), 2); + + let count = Work::count( + pool.as_ref(), + Some(marker), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count works by title/abstract"); + + assert_eq!(count, 2); + } + + #[test] + fn crud_filter_by_title_does_not_duplicate_rows() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let marker = format!("Duplicate Marker {}", Uuid::new_v4()); + let work = create_work(pool.as_ref(), &imprint); + + Title::create( + pool.as_ref(), + &NewTitle { + work_id: work.work_id, + locale_code: LocaleCode::En, + full_title: format!("Title {marker} canonical"), + title: "Canonical".to_string(), + subtitle: None, + canonical: true, + }, + ) + .expect("Failed to create canonical title"); + Title::create( + pool.as_ref(), + &NewTitle { + work_id: work.work_id, + locale_code: LocaleCode::Fr, + full_title: format!("Title {marker} alternate"), + title: "Alternate".to_string(), + subtitle: None, + canonical: false, + }, + ) + .expect("Failed to create alternate title"); + + let filtered = Work::all( + pool.as_ref(), + 10, + 0, + Some(marker.clone()), + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter works by title"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].work_id, work.work_id); + + let count = Work::count( + pool.as_ref(), + Some(marker), + vec![], + vec![], + vec![], + None, + None, + ) + .expect("Failed to count works by title"); + + assert_eq!(count, 1); + } + + #[test] + fn crud_filter_param_limits_publication_date() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + + Work::create( + pool.as_ref(), + &NewWork { + publication_date: NaiveDate::from_ymd_opt(2000, 1, 1), + ..make_new_work(imprint.imprint_id) + }, + ) + .expect("Failed to create work"); + Work::create( + pool.as_ref(), + &NewWork { + publication_date: NaiveDate::from_ymd_opt(2020, 1, 1), + ..make_new_work(imprint.imprint_id) + }, + ) + .expect("Failed to create work"); + + let greater_than = TimeExpression { + timestamp: Timestamp::parse_from_rfc3339("2010-01-01T00:00:00Z").unwrap(), + expression: Expression::GreaterThan, + }; + let less_than = TimeExpression { + timestamp: Timestamp::parse_from_rfc3339("2010-01-01T00:00:00Z").unwrap(), + expression: Expression::LessThan, + }; + + let newer = Work::all( + pool.as_ref(), + 10, + 0, + None, + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + Some(greater_than), + None, + ) + .expect("Failed to filter works by publication date (gt)"); + + let older = Work::all( + pool.as_ref(), + 10, + 0, + None, + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + Some(less_than), + None, + ) + .expect("Failed to filter works by publication date (lt)"); + + assert_eq!(newer.len(), 1); + assert_eq!(older.len(), 1); + } + + #[test] + fn crud_filter_param_limits_updated_at_with_relations() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + create_work(pool.as_ref(), &imprint); + create_work(pool.as_ref(), &imprint); + + let greater_than = TimeExpression { + timestamp: Timestamp::parse_from_rfc3339("1970-01-01T00:00:00Z").unwrap(), + expression: Expression::GreaterThan, + }; + let less_than = TimeExpression { + timestamp: Timestamp::parse_from_rfc3339("3000-01-01T00:00:00Z").unwrap(), + expression: Expression::LessThan, + }; + + let count_newer = Work::count( + pool.as_ref(), + None, + vec![], + vec![], + vec![], + None, + Some(greater_than), + ) + .expect("Failed to count works by updated_at_with_relations (gt)"); + + let count_older = Work::count( + pool.as_ref(), + None, + vec![], + vec![], + vec![], + None, + Some(less_than), + ) + .expect("Failed to count works by updated_at_with_relations (lt)"); + + assert_eq!(count_newer, 2); + assert_eq!(count_older, 2); + } + + #[test] + fn crud_filter_param_limits_updated_at_with_relations_in_all() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + create_work(pool.as_ref(), &imprint); + create_work(pool.as_ref(), &imprint); + + let greater_than = TimeExpression { + timestamp: Timestamp::parse_from_rfc3339("1970-01-01T00:00:00Z").unwrap(), + expression: Expression::GreaterThan, + }; + let less_than = TimeExpression { + timestamp: Timestamp::parse_from_rfc3339("3000-01-01T00:00:00Z").unwrap(), + expression: Expression::LessThan, + }; + + let newer = Work::all( + pool.as_ref(), + 10, + 0, + None, + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + Some(greater_than), + ) + .expect("Failed to filter works by updated_at_with_relations (gt)"); + + let older = Work::all( + pool.as_ref(), + 10, + 0, + None, + WorkOrderBy { + field: WorkField::WorkId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![], + vec![], + None, + Some(less_than), + ) + .expect("Failed to filter works by updated_at_with_relations (lt)"); + + assert_eq!(newer.len(), 2); + assert_eq!(older.len(), 2); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + create_work(pool.as_ref(), &imprint); + create_work(pool.as_ref(), &imprint); + + let fields: Vec WorkField> = vec![ + || WorkField::WorkId, + || WorkField::WorkType, + || WorkField::WorkStatus, + || WorkField::FullTitle, + || WorkField::Title, + || WorkField::Subtitle, + || WorkField::Reference, + || WorkField::Edition, + || WorkField::Doi, + || WorkField::PublicationDate, + || WorkField::WithdrawnDate, + || WorkField::Place, + || WorkField::PageCount, + || WorkField::PageBreakdown, + || WorkField::FirstPage, + || WorkField::LastPage, + || WorkField::PageInterval, + || WorkField::ImageCount, + || WorkField::TableCount, + || WorkField::AudioCount, + || WorkField::VideoCount, + || WorkField::License, + || WorkField::CopyrightHolder, + || WorkField::LandingPage, + || WorkField::Lccn, + || WorkField::Oclc, + || WorkField::ShortAbstract, + || WorkField::LongAbstract, + || WorkField::GeneralNote, + || WorkField::BibliographyNote, + || WorkField::Toc, + || WorkField::CoverUrl, + || WorkField::CoverCaption, + || WorkField::CreatedAt, + || WorkField::UpdatedAt, + || WorkField::UpdatedAtWithRelations, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = Work::all( + pool.as_ref(), + 10, + 0, + None, + WorkOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to order works"); + + assert_eq!(results.len(), 2); + } + } + } +} diff --git a/thoth-api/src/model/work_featured_video/crud.rs b/thoth-api/src/model/work_featured_video/crud.rs new file mode 100644 index 00000000..bfe804c9 --- /dev/null +++ b/thoth-api/src/model/work_featured_video/crud.rs @@ -0,0 +1,175 @@ +use super::{ + NewWorkFeaturedVideo, NewWorkFeaturedVideoHistory, PatchWorkFeaturedVideo, WorkFeaturedVideo, + WorkFeaturedVideoField, WorkFeaturedVideoHistory, WorkFeaturedVideoOrderBy, +}; +use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::schema::{work_featured_video, work_featured_video_history}; +use diesel::{ + BoolExpressionMethods, ExpressionMethods, OptionalExtension, PgTextExpressionMethods, QueryDsl, + RunQueryDsl, +}; +use thoth_errors::ThothResult; +use uuid::Uuid; + +impl Crud for WorkFeaturedVideo { + type NewEntity = NewWorkFeaturedVideo; + type PatchEntity = PatchWorkFeaturedVideo; + type OrderByEntity = WorkFeaturedVideoOrderBy; + type FilterParameter1 = (); + type FilterParameter2 = (); + type FilterParameter3 = (); + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.work_featured_video_id + } + + fn all( + db: &crate::db::PgPool, + limit: i32, + offset: i32, + filter: Option, + order: Self::OrderByEntity, + publishers: Vec, + parent_id_1: Option, + _: Option, + _: Vec, + _: Vec, + _: Option, + _: Option, + ) -> ThothResult> { + use crate::schema::work_featured_video::dsl::*; + let mut connection = db.get()?; + let mut query = work_featured_video + .inner_join(crate::schema::work::table.inner_join(crate::schema::imprint::table)) + .select(crate::schema::work_featured_video::all_columns) + .into_boxed(); + + query = match order.field { + WorkFeaturedVideoField::WorkFeaturedVideoId => { + apply_directional_order!(query, order.direction, order, work_featured_video_id) + } + WorkFeaturedVideoField::WorkId => { + apply_directional_order!(query, order.direction, order, work_id) + } + WorkFeaturedVideoField::Title => { + apply_directional_order!(query, order.direction, order, title) + } + WorkFeaturedVideoField::Url => { + apply_directional_order!(query, order.direction, order, url) + } + WorkFeaturedVideoField::Width => { + apply_directional_order!(query, order.direction, order, width) + } + WorkFeaturedVideoField::Height => { + apply_directional_order!(query, order.direction, order, height) + } + WorkFeaturedVideoField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + WorkFeaturedVideoField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } + }; + + if !publishers.is_empty() { + query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); + } + if let Some(pid) = parent_id_1 { + query = query.filter(work_id.eq(pid)); + } + if let Some(filter) = filter { + if !filter.is_empty() { + query = query.filter( + title + .ilike(format!("%{filter}%")) + .or(url.ilike(format!("%{filter}%"))), + ); + } + } + + query + .limit(limit.into()) + .offset(offset.into()) + .load::(&mut connection) + .map_err(Into::into) + } + + fn count( + db: &crate::db::PgPool, + filter: Option, + publishers: Vec, + _: Vec, + _: Vec, + _: Option, + _: Option, + ) -> ThothResult { + use crate::schema::work_featured_video::dsl::*; + let mut connection = db.get()?; + let mut query = work_featured_video + .inner_join(crate::schema::work::table.inner_join(crate::schema::imprint::table)) + .into_boxed(); + + if !publishers.is_empty() { + query = query.filter(crate::schema::imprint::publisher_id.eq_any(publishers)); + } + if let Some(filter) = filter { + if !filter.is_empty() { + query = query.filter( + title + .ilike(format!("%{filter}%")) + .or(url.ilike(format!("%{filter}%"))), + ); + } + } + + query + .count() + .get_result::(&mut connection) + .map(|t| t.to_string().parse::().unwrap()) + .map_err(Into::into) + } + + crud_methods!( + work_featured_video::table, + work_featured_video::dsl::work_featured_video + ); +} + +publisher_id_impls!( + WorkFeaturedVideo, + NewWorkFeaturedVideo, + PatchWorkFeaturedVideo, + |s, db| { crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) } +); + +impl WorkFeaturedVideo { + pub fn from_work_id(db: &crate::db::PgPool, work_id: &Uuid) -> ThothResult> { + use crate::schema::work_featured_video::dsl; + + let mut connection = db.get()?; + dsl::work_featured_video + .filter(dsl::work_id.eq(work_id)) + .first::(&mut connection) + .optional() + .map_err(Into::into) + } +} + +impl HistoryEntry for WorkFeaturedVideo { + type NewHistoryEntity = NewWorkFeaturedVideoHistory; + + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { + Self::NewHistoryEntity { + work_featured_video_id: self.work_featured_video_id, + user_id: user_id.to_string(), + data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), + } + } +} + +impl DbInsert for NewWorkFeaturedVideoHistory { + type MainEntity = WorkFeaturedVideoHistory; + + db_insert!(work_featured_video_history::table); +} diff --git a/thoth-api/src/model/work_featured_video/mod.rs b/thoth-api/src/model/work_featured_video/mod.rs new file mode 100644 index 00000000..96224c4e --- /dev/null +++ b/thoth-api/src/model/work_featured_video/mod.rs @@ -0,0 +1,111 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::graphql::types::inputs::Direction; +use crate::model::Timestamp; +#[cfg(feature = "backend")] +use crate::schema::work_featured_video; +#[cfg(feature = "backend")] +use crate::schema::work_featured_video_history; + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLEnum), + graphql(description = "Field to use when sorting featured videos list") +)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum WorkFeaturedVideoField { + WorkFeaturedVideoId, + WorkId, + Title, + Url, + Width, + Height, + CreatedAt, + #[default] + UpdatedAt, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkFeaturedVideo { + pub work_featured_video_id: Uuid, + pub work_id: Uuid, + pub title: Option, + pub url: Option, + pub width: i32, + pub height: i32, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::Insertable), + graphql(description = "Set of values required to define a new featured video linked to a work"), + diesel(table_name = work_featured_video) +)] +pub struct NewWorkFeaturedVideo { + pub work_id: Uuid, + pub title: Option, + pub url: Option, + pub width: i32, + pub height: i32, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, diesel::AsChangeset), + graphql(description = "Set of values required to update an existing featured video"), + diesel(table_name = work_featured_video, treat_none_as_null = true) +)] +pub struct PatchWorkFeaturedVideo { + pub work_featured_video_id: Uuid, + pub work_id: Uuid, + pub title: Option, + pub url: Option, + pub width: i32, + pub height: i32, +} + +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] +pub struct WorkFeaturedVideoHistory { + pub work_featured_video_history_id: Uuid, + pub work_featured_video_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, + pub timestamp: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(diesel::Insertable), + diesel(table_name = work_featured_video_history) +)] +pub struct NewWorkFeaturedVideoHistory { + pub work_featured_video_id: Uuid, + pub user_id: String, + pub data: serde_json::Value, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject), + graphql(description = "Field and order to use when sorting featured videos list") +)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkFeaturedVideoOrderBy { + pub field: WorkFeaturedVideoField, + pub direction: Direction, +} + +#[cfg(feature = "backend")] +pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::WorkFeaturedVideoPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/work_featured_video/policy.rs b/thoth-api/src/model/work_featured_video/policy.rs new file mode 100644 index 00000000..9eedd680 --- /dev/null +++ b/thoth-api/src/model/work_featured_video/policy.rs @@ -0,0 +1,53 @@ +use crate::model::work::{Work, WorkType}; +use crate::model::work_featured_video::{ + NewWorkFeaturedVideo, PatchWorkFeaturedVideo, WorkFeaturedVideo, +}; +use crate::model::Crud; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `WorkFeaturedVideo`. +/// +/// These policies enforce publisher scoping and prevent attachment to chapter records. +pub struct WorkFeaturedVideoPolicy; + +fn ensure_work_is_book(db: &crate::db::PgPool, work_id: uuid::Uuid) -> ThothResult<()> { + let work = Work::from_id(db, &work_id)?; + if work.work_type == WorkType::BookChapter { + Err(ThothError::ChapterBookMetadataError) + } else { + Ok(()) + } +} + +impl CreatePolicy for WorkFeaturedVideoPolicy { + fn can_create( + ctx: &C, + data: &NewWorkFeaturedVideo, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + ensure_work_is_book(ctx.db(), data.work_id) + } +} + +impl UpdatePolicy for WorkFeaturedVideoPolicy { + fn can_update( + ctx: &C, + current: &WorkFeaturedVideo, + patch: &PatchWorkFeaturedVideo, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + ensure_work_is_book(ctx.db(), current.work_id)?; + ensure_work_is_book(ctx.db(), patch.work_id) + } +} + +impl DeletePolicy for WorkFeaturedVideoPolicy { + fn can_delete(ctx: &C, current: &WorkFeaturedVideo) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ensure_work_is_book(ctx.db(), current.work_id) + } +} diff --git a/thoth-api/src/model/work_featured_video/tests.rs b/thoth-api/src/model/work_featured_video/tests.rs new file mode 100644 index 00000000..b2fb6813 --- /dev/null +++ b/thoth-api/src/model/work_featured_video/tests.rs @@ -0,0 +1,269 @@ +use super::*; +use crate::model::Crud; +use uuid::Uuid; + +fn make_work_featured_video( + pool: &crate::db::PgPool, + work_id: Uuid, + url: Option, +) -> WorkFeaturedVideo { + let data = NewWorkFeaturedVideo { + work_id, + title: Some("Featured video".to_string()), + url, + width: 560, + height: 315, + }; + + WorkFeaturedVideo::create(pool, &data).expect("Failed to create featured video") +} + +mod defaults { + use super::*; + + #[test] + fn workfeaturedvideofield_default_is_updated_at() { + let field: WorkFeaturedVideoField = Default::default(); + assert_eq!(field, WorkFeaturedVideoField::UpdatedAt); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let video: WorkFeaturedVideo = Default::default(); + assert_eq!(video.pk(), video.work_featured_video_id); + } + + #[test] + fn history_entry_serializes_model() { + let video: WorkFeaturedVideo = Default::default(); + let user_id = "123456".to_string(); + let history = video.new_history_entry(&user_id); + assert_eq!(history.work_featured_video_id, video.work_featured_video_id); + assert_eq!(history.user_id, user_id); + assert_eq!( + history.data, + serde_json::Value::String(serde_json::to_string(&video).unwrap()) + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::work::{NewWork, Work, WorkStatus, WorkType}; + use crate::model::work_featured_video::policy::WorkFeaturedVideoPolicy; + use crate::policy::{CreatePolicy, DeletePolicy, Role, UpdatePolicy}; + + #[test] + fn crud_policy_allows_publisher_user_for_write() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("featured-video-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let data = NewWorkFeaturedVideo { + work_id: work.work_id, + title: Some("Featured video".to_string()), + url: Some("https://cdn.example.org/video.mp4".to_string()), + width: 560, + height: 315, + }; + + let video = WorkFeaturedVideo::create(pool.as_ref(), &data).expect("Failed to create"); + let patch = PatchWorkFeaturedVideo { + work_featured_video_id: video.work_featured_video_id, + work_id: video.work_id, + title: video.title.clone(), + url: Some("https://cdn.example.org/video-v2.mp4".to_string()), + width: video.width, + height: video.height, + }; + + assert!(WorkFeaturedVideoPolicy::can_create(&ctx, &data, ()).is_ok()); + assert!(WorkFeaturedVideoPolicy::can_update(&ctx, &video, &patch, ()).is_ok()); + assert!(WorkFeaturedVideoPolicy::can_delete(&ctx, &video).is_ok()); + } + + #[test] + fn crud_policy_rejects_user_without_publisher_role() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let video = make_work_featured_video( + pool.as_ref(), + work.work_id, + Some("https://cdn.example.org/video.mp4".to_string()), + ); + + let patch = PatchWorkFeaturedVideo { + work_featured_video_id: video.work_featured_video_id, + work_id: video.work_id, + title: video.title.clone(), + url: Some("https://cdn.example.org/video-v2.mp4".to_string()), + width: video.width, + height: video.height, + }; + + let user = test_user_with_role("featured-video-user", Role::PublisherUser, "org-other"); + let ctx = test_context_with_user(pool.clone(), user); + + let data = NewWorkFeaturedVideo { + work_id: work.work_id, + title: Some("Featured video".to_string()), + url: Some("https://cdn.example.org/video.mp4".to_string()), + width: 560, + height: 315, + }; + + assert!(WorkFeaturedVideoPolicy::can_create(&ctx, &data, ()).is_err()); + assert!(WorkFeaturedVideoPolicy::can_update(&ctx, &video, &patch, ()).is_err()); + assert!(WorkFeaturedVideoPolicy::can_delete(&ctx, &video).is_err()); + } + + #[test] + fn crud_policy_rejects_chapter_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("featured-video-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let imprint = create_imprint(pool.as_ref(), &publisher); + let chapter = Work::create( + pool.as_ref(), + &NewWork { + work_type: WorkType::BookChapter, + work_status: WorkStatus::Forthcoming, + reference: None, + edition: None, + imprint_id: imprint.imprint_id, + doi: None, + publication_date: None, + withdrawn_date: None, + place: None, + page_count: None, + page_breakdown: None, + image_count: None, + table_count: None, + audio_count: None, + video_count: None, + license: None, + copyright_holder: None, + landing_page: None, + lccn: None, + oclc: None, + general_note: None, + bibliography_note: None, + toc: None, + resources_description: None, + cover_url: None, + cover_caption: None, + first_page: Some("1".to_string()), + last_page: Some("10".to_string()), + page_interval: Some("1-10".to_string()), + }, + ) + .expect("Failed to create chapter work"); + + let data = NewWorkFeaturedVideo { + work_id: chapter.work_id, + title: Some("Featured video".to_string()), + url: Some("https://cdn.example.org/video.mp4".to_string()), + width: 560, + height: 315, + }; + + assert!(matches!( + WorkFeaturedVideoPolicy::can_create(&ctx, &data, ()), + Err(thoth_errors::ThothError::ChapterBookMetadataError) + )); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context, + }; + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let video = make_work_featured_video( + pool.as_ref(), + work.work_id, + Some("https://cdn.example.org/video.mp4".to_string()), + ); + let fetched = WorkFeaturedVideo::from_id(pool.as_ref(), &video.work_featured_video_id) + .expect("Failed to fetch"); + assert_eq!(video.work_featured_video_id, fetched.work_featured_video_id); + + let patch = PatchWorkFeaturedVideo { + work_featured_video_id: video.work_featured_video_id, + work_id: video.work_id, + title: Some("Updated featured video".to_string()), + url: Some("https://cdn.example.org/video-v2.mp4".to_string()), + width: 640, + height: 360, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = video.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.url, patch.url); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!( + WorkFeaturedVideo::from_id(pool.as_ref(), &deleted.work_featured_video_id).is_err() + ); + } + + #[test] + fn crud_from_work_id_returns_record() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let video = make_work_featured_video( + pool.as_ref(), + work.work_id, + Some("https://cdn.example.org/video.mp4".to_string()), + ); + + let fetched = WorkFeaturedVideo::from_work_id(pool.as_ref(), &work.work_id) + .expect("Failed to fetch by work id") + .expect("Expected featured video"); + + assert_eq!(fetched.work_featured_video_id, video.work_featured_video_id); + } +} diff --git a/thoth-api/src/model/work_relation/crud.rs b/thoth-api/src/model/work_relation/crud.rs index e9b0f3aa..6fbe565d 100644 --- a/thoth-api/src/model/work_relation/crud.rs +++ b/thoth-api/src/model/work_relation/crud.rs @@ -2,12 +2,12 @@ use super::{ NewWorkRelation, NewWorkRelationHistory, PatchWorkRelation, RelationType, WorkRelation, WorkRelationField, WorkRelationHistory, WorkRelationOrderBy, }; -use crate::db_insert; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId, Reorder}; use crate::schema::{work_relation, work_relation_history}; -use diesel::dsl::max; -use diesel::{BoolExpressionMethods, Connection, ExpressionMethods, QueryDsl, RunQueryDsl}; +use diesel::{ + dsl::max, sql_query, sql_types::Text, BoolExpressionMethods, Connection, ExpressionMethods, + QueryDsl, RunQueryDsl, +}; use thoth_errors::{ThothError, ThothResult}; use uuid::Uuid; @@ -18,6 +18,7 @@ impl Crud for WorkRelation { type FilterParameter1 = RelationType; type FilterParameter2 = (); type FilterParameter3 = (); + type FilterParameter4 = (); fn pk(&self) -> Uuid { self.work_relation_id @@ -35,6 +36,7 @@ impl Crud for WorkRelation { relation_types: Vec, _: Vec, _: Option, + _: Option, ) -> ThothResult> { use crate::schema::work_relation::dsl::*; let mut connection = db.get()?; @@ -43,34 +45,27 @@ impl Crud for WorkRelation { .into_boxed(); query = match order.field { - WorkRelationField::WorkRelationId => match order.direction { - Direction::Asc => query.order(work_relation_id.asc()), - Direction::Desc => query.order(work_relation_id.desc()), - }, - WorkRelationField::RelatorWorkId => match order.direction { - Direction::Asc => query.order(relator_work_id.asc()), - Direction::Desc => query.order(relator_work_id.desc()), - }, - WorkRelationField::RelatedWorkId => match order.direction { - Direction::Asc => query.order(related_work_id.asc()), - Direction::Desc => query.order(related_work_id.desc()), - }, - WorkRelationField::RelationType => match order.direction { - Direction::Asc => query.order(relation_type.asc()), - Direction::Desc => query.order(relation_type.desc()), - }, - WorkRelationField::RelationOrdinal => match order.direction { - Direction::Asc => query.order(relation_ordinal.asc()), - Direction::Desc => query.order(relation_ordinal.desc()), - }, - WorkRelationField::CreatedAt => match order.direction { - Direction::Asc => query.order(created_at.asc()), - Direction::Desc => query.order(created_at.desc()), - }, - WorkRelationField::UpdatedAt => match order.direction { - Direction::Asc => query.order(updated_at.asc()), - Direction::Desc => query.order(updated_at.desc()), - }, + WorkRelationField::WorkRelationId => { + apply_directional_order!(query, order.direction, order, work_relation_id) + } + WorkRelationField::RelatorWorkId => { + apply_directional_order!(query, order.direction, order, relator_work_id) + } + WorkRelationField::RelatedWorkId => { + apply_directional_order!(query, order.direction, order, related_work_id) + } + WorkRelationField::RelationType => { + apply_directional_order!(query, order.direction, order, relation_type) + } + WorkRelationField::RelationOrdinal => { + apply_directional_order!(query, order.direction, order, relation_ordinal) + } + WorkRelationField::CreatedAt => { + apply_directional_order!(query, order.direction, order, created_at) + } + WorkRelationField::UpdatedAt => { + apply_directional_order!(query, order.direction, order, updated_at) + } }; if let Some(pid) = parent_id_1 { query = query.filter(relator_work_id.eq(pid)); @@ -92,6 +87,7 @@ impl Crud for WorkRelation { relation_types: Vec, _: Vec, _: Option, + _: Option, ) -> ThothResult { use crate::schema::work_relation::dsl::*; let mut connection = db.get()?; @@ -126,30 +122,43 @@ impl Crud for WorkRelation { // For each Relator - Relationship - Related record we create, we must also // create the corresponding Related - InverseRelationship - Relator record. let mut connection = db.get()?; - // We need to determine an appropriate relation_ordinal for the inverse record. - // Find the current highest ordinal for the relevant work and type. - // This will return `None` if no records with this work and type already exist. - let max_inverse_ordinal = work_relation::table - .select(max(work_relation::relation_ordinal)) - .filter( - work_relation::relator_work_id - .eq(data.related_work_id) - .and(work_relation::relation_type.eq(data.relation_type.convert_to_inverse())), - ) - .get_result::>(&mut connection) - .expect("Error loading work relation ordinal values"); - let inverse_data = NewWorkRelation { - relator_work_id: data.related_work_id, - related_work_id: data.relator_work_id, - relation_type: data.relation_type.convert_to_inverse(), - // Set the ordinal based on the current highest ordinal for this work and type - // (defaulting to 1 if none exists). Note that user-entered ordinal sequences - // may contain 'holes' and this will not fill them. - relation_ordinal: max_inverse_ordinal.unwrap_or_default() + 1, - }; // Execute both creations within the same transaction, // because if one fails, both need to be reverted. connection.transaction(|connection| { + // Take a transaction-level advisory lock to serialise ordinal assignment + // for this (relator_work, relation_type) pair. We build a stable string key + // from the related work ID and the inverse relation type, and let Postgres + // hash it to an integer for the lock. + sql_query("SELECT pg_advisory_xact_lock(hashtext($1))") + .bind::(format!( + "{}|{:?}", + data.related_work_id, + data.relation_type.convert_to_inverse() + )) + .execute(connection)?; + + // We need to determine an appropriate relation_ordinal for the inverse record. + // Find the current highest ordinal for the relevant work and type. + // This will return `None` if no records with this work and type already exist. + let max_inverse_ordinal = + work_relation::table + .select(max(work_relation::relation_ordinal)) + .filter(work_relation::relator_work_id.eq(data.related_work_id).and( + work_relation::relation_type.eq(data.relation_type.convert_to_inverse()), + )) + .get_result::>(connection) + .expect("Error loading work relation ordinal values"); + + let inverse_data = NewWorkRelation { + relator_work_id: data.related_work_id, + related_work_id: data.relator_work_id, + relation_type: data.relation_type.convert_to_inverse(), + // Set the ordinal based on the current highest ordinal for this work and type + // (defaulting to 1 if none exists). Note that user-entered ordinal sequences + // may contain 'holes' and this will not fill them. + relation_ordinal: max_inverse_ordinal.unwrap_or_default() + 1, + }; + diesel::insert_into(work_relation::table) .values(&inverse_data) .execute(connection)?; @@ -160,15 +169,14 @@ impl Crud for WorkRelation { }) } - fn update( + fn update( &self, - db: &crate::db::PgPool, + ctx: &C, data: &PatchWorkRelation, - account_id: &Uuid, ) -> ThothResult { // For each Relator - Relationship - Related record we update, we must also // update the corresponding Related - InverseRelationship - Relator record. - let inverse_work_relation = self.get_inverse(db)?; + let inverse_work_relation = self.get_inverse(ctx.db())?; let inverse_data = PatchWorkRelation { work_relation_id: inverse_work_relation.work_relation_id, relator_work_id: data.related_work_id, @@ -178,7 +186,7 @@ impl Crud for WorkRelation { }; // Execute both updates within the same transaction, // because if one fails, both need to be reverted. - let mut connection = db.get()?; + let mut connection = ctx.db().get()?; connection.transaction(|connection| { diesel::update(work_relation::table.find(inverse_work_relation.work_relation_id)) .set(inverse_data) @@ -190,7 +198,7 @@ impl Crud for WorkRelation { .and_then(|t| { // On success, create a new history table entry. // Only record the original update, not the automatic inverse update. - self.new_history_entry(account_id) + self.new_history_entry(ctx.user_id()?) .insert(connection) .map(|_| t) }) @@ -213,21 +221,24 @@ impl Crud for WorkRelation { .map_err(Into::into) }) } - - fn publisher_id(&self, _db: &crate::db::PgPool) -> ThothResult { - Err(ThothError::InternalError( - "Method publisher_id() is not supported for Work Relation objects".to_string(), - )) - } } +publisher_ids_impls!(WorkRelation, NewWorkRelation, PatchWorkRelation, |s, db| { + let a = crate::model::work::Work::from_id(db, &s.relator_work_id)?.publisher_id(db)?; + let b = crate::model::work::Work::from_id(db, &s.related_work_id)?.publisher_id(db)?; + let mut v = vec![a, b]; + v.sort(); + v.dedup(); + Ok(v) +}); + impl HistoryEntry for WorkRelation { type NewHistoryEntity = NewWorkRelationHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { work_relation_id: self.work_relation_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -239,6 +250,33 @@ impl DbInsert for NewWorkRelationHistory { db_insert!(work_relation_history::table); } +impl Reorder for WorkRelation { + db_change_ordinal!( + work_relation::table, + work_relation::relation_ordinal, + "work_relation_ordinal_type_uniq" + ); + + fn get_other_objects( + &self, + connection: &mut diesel::PgConnection, + ) -> ThothResult> { + work_relation::table + .select(( + work_relation::work_relation_id, + work_relation::relation_ordinal, + )) + .filter( + work_relation::relator_work_id + .eq(self.relator_work_id) + .and(work_relation::relation_type.eq(self.relation_type)) + .and(work_relation::work_relation_id.ne(self.work_relation_id)), + ) + .load::<(Uuid, i32)>(connection) + .map_err(Into::into) + } +} + impl WorkRelation { pub fn get_inverse(&self, db: &crate::db::PgPool) -> ThothResult { // Every WorkRelation record must be accompanied by an 'inverse' record, @@ -265,30 +303,3 @@ impl WorkRelation { }) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_work_relation_pk() { - let work_relation: WorkRelation = Default::default(); - assert_eq!(work_relation.pk(), work_relation.work_relation_id); - } - - #[test] - fn test_new_work_relation_history_from_work_relation() { - let work_relation: WorkRelation = Default::default(); - let account_id: Uuid = Default::default(); - let new_work_relation_history = work_relation.new_history_entry(&account_id); - assert_eq!( - new_work_relation_history.work_relation_id, - work_relation.work_relation_id - ); - assert_eq!(new_work_relation_history.account_id, account_id); - assert_eq!( - new_work_relation_history.data, - serde_json::Value::String(serde_json::to_string(&work_relation).unwrap()) - ); - } -} diff --git a/thoth-api/src/model/work_relation/mod.rs b/thoth-api/src/model/work_relation/mod.rs index 5959c6c7..2c9f9f81 100644 --- a/thoth-api/src/model/work_relation/mod.rs +++ b/thoth-api/src/model/work_relation/mod.rs @@ -3,8 +3,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; -use crate::model::work::Work; +use crate::graphql::types::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::work_relation; @@ -13,7 +12,7 @@ use crate::schema::work_relation_history; #[cfg_attr( feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), + derive(diesel_derive_enum::DbEnum, juniper::GraphQLEnum), graphql(description = "Nature of a relationship between works"), ExistingTypePath = "crate::schema::sql_types::RelationType" )] @@ -107,7 +106,7 @@ pub enum WorkRelationField { UpdatedAt, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WorkRelation { @@ -120,20 +119,9 @@ pub struct WorkRelation { pub updated_at: Timestamp, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WorkRelationWithRelatedWork { - pub work_relation_id: Uuid, - pub relator_work_id: Uuid, - pub related_work_id: Uuid, - pub relation_type: RelationType, - pub relation_ordinal: i32, - pub related_work: Work, -} - #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, Insertable), + derive(juniper::GraphQLInputObject, diesel::Insertable), graphql(description = "Set of values required to define a new relationship between two works"), diesel(table_name = work_relation) )] @@ -146,7 +134,7 @@ pub struct NewWorkRelation { #[cfg_attr( feature = "backend", - derive(juniper::GraphQLInputObject, AsChangeset), + derive(juniper::GraphQLInputObject, diesel::AsChangeset), graphql(description = "Set of values required to update an existing relationship between two works"), diesel(table_name = work_relation, treat_none_as_null = true) )] @@ -158,23 +146,23 @@ pub struct PatchWorkRelation { pub relation_ordinal: i32, } -#[cfg_attr(feature = "backend", derive(Queryable))] +#[cfg_attr(feature = "backend", derive(diesel::Queryable))] pub struct WorkRelationHistory { pub work_relation_history_id: Uuid, pub work_relation_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } #[cfg_attr( feature = "backend", - derive(Insertable), + derive(diesel::Insertable), diesel(table_name = work_relation_history) )] pub struct NewWorkRelationHistory { pub work_relation_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -205,88 +193,11 @@ impl RelationType { } } -impl Default for WorkRelationWithRelatedWork { - fn default() -> WorkRelationWithRelatedWork { - WorkRelationWithRelatedWork { - work_relation_id: Default::default(), - relator_work_id: Default::default(), - related_work_id: Default::default(), - relation_type: Default::default(), - relation_ordinal: 1, - related_work: Default::default(), - } - } -} - -#[test] -fn test_relationtype_default() { - let reltype: RelationType = Default::default(); - assert_eq!(reltype, RelationType::HasChild); -} - -#[test] -fn test_workrelationfield_default() { - let workrelfield: WorkRelationField = Default::default(); - assert_eq!(workrelfield, WorkRelationField::RelationType); -} - -#[test] -fn test_relationtype_display() { - assert_eq!(format!("{}", RelationType::Replaces), "Replaces"); - assert_eq!( - format!("{}", RelationType::HasTranslation), - "Has Translation" - ); - assert_eq!(format!("{}", RelationType::HasPart), "Has Part"); - assert_eq!(format!("{}", RelationType::HasChild), "Has Child"); - assert_eq!(format!("{}", RelationType::IsReplacedBy), "Is Replaced By"); - assert_eq!( - format!("{}", RelationType::IsTranslationOf), - "Is Translation Of" - ); - assert_eq!(format!("{}", RelationType::IsPartOf), "Is Part Of"); - assert_eq!(format!("{}", RelationType::IsChildOf), "Is Child Of"); -} - -#[test] -fn test_relationtype_fromstr() { - use std::str::FromStr; - assert_eq!( - RelationType::from_str("Replaces").unwrap(), - RelationType::Replaces - ); - assert_eq!( - RelationType::from_str("Has Translation").unwrap(), - RelationType::HasTranslation - ); - assert_eq!( - RelationType::from_str("Has Part").unwrap(), - RelationType::HasPart - ); - assert_eq!( - RelationType::from_str("Has Child").unwrap(), - RelationType::HasChild - ); - assert_eq!( - RelationType::from_str("Is Replaced By").unwrap(), - RelationType::IsReplacedBy - ); - assert_eq!( - RelationType::from_str("Is Translation Of").unwrap(), - RelationType::IsTranslationOf - ); - assert_eq!( - RelationType::from_str("Is Part Of").unwrap(), - RelationType::IsPartOf - ); - assert_eq!( - RelationType::from_str("Is Child Of").unwrap(), - RelationType::IsChildOf - ); - - assert!(RelationType::from_str("Has Parent").is_err()); - assert!(RelationType::from_str("Subsumes").is_err()); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::WorkRelationPolicy; +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/model/work_relation/policy.rs b/thoth-api/src/model/work_relation/policy.rs new file mode 100644 index 00000000..22634043 --- /dev/null +++ b/thoth-api/src/model/work_relation/policy.rs @@ -0,0 +1,50 @@ +use crate::model::work_relation::{NewWorkRelation, PatchWorkRelation, WorkRelation}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `WorkRelation`. +/// +/// `WorkRelation` spans two works and therefore potentially two publisher scopes. +/// This policy enforces: +/// - authentication +/// - membership for *all* publishers involved (via `PublisherIds`) +pub struct WorkRelationPolicy; + +impl CreatePolicy for WorkRelationPolicy { + fn can_create( + ctx: &C, + data: &NewWorkRelation, + _params: (), + ) -> ThothResult<()> { + ctx.require_publishers_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy for WorkRelationPolicy { + fn can_update( + ctx: &C, + current: &WorkRelation, + patch: &PatchWorkRelation, + _params: (), + ) -> ThothResult<()> { + ctx.require_publishers_for(current)?; + ctx.require_publishers_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy for WorkRelationPolicy { + fn can_delete(ctx: &C, current: &WorkRelation) -> ThothResult<()> { + ctx.require_publishers_for(current)?; + Ok(()) + } +} + +impl MovePolicy for WorkRelationPolicy { + fn can_move(ctx: &C, current: &WorkRelation) -> ThothResult<()> { + ctx.require_publishers_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/work_relation/tests.rs b/thoth-api/src/model/work_relation/tests.rs new file mode 100644 index 00000000..4da239da --- /dev/null +++ b/thoth-api/src/model/work_relation/tests.rs @@ -0,0 +1,840 @@ +use super::*; + +mod defaults { + use super::*; + + #[test] + fn relationtype_default_is_has_child() { + let reltype: RelationType = Default::default(); + assert_eq!(reltype, RelationType::HasChild); + } + + #[test] + fn workrelationfield_default_is_relation_type() { + let workrelfield: WorkRelationField = Default::default(); + assert_eq!(workrelfield, WorkRelationField::RelationType); + } +} + +mod display_and_parse { + use super::*; + + #[test] + fn relationtype_display_formats_expected_strings() { + assert_eq!(format!("{}", RelationType::Replaces), "Replaces"); + assert_eq!( + format!("{}", RelationType::HasTranslation), + "Has Translation" + ); + assert_eq!(format!("{}", RelationType::HasPart), "Has Part"); + assert_eq!(format!("{}", RelationType::HasChild), "Has Child"); + assert_eq!(format!("{}", RelationType::IsReplacedBy), "Is Replaced By"); + assert_eq!( + format!("{}", RelationType::IsTranslationOf), + "Is Translation Of" + ); + assert_eq!(format!("{}", RelationType::IsPartOf), "Is Part Of"); + assert_eq!(format!("{}", RelationType::IsChildOf), "Is Child Of"); + } + + #[test] + fn relationtype_fromstr_parses_expected_values() { + use std::str::FromStr; + assert_eq!( + RelationType::from_str("Replaces").unwrap(), + RelationType::Replaces + ); + assert_eq!( + RelationType::from_str("Has Translation").unwrap(), + RelationType::HasTranslation + ); + assert_eq!( + RelationType::from_str("Has Part").unwrap(), + RelationType::HasPart + ); + assert_eq!( + RelationType::from_str("Has Child").unwrap(), + RelationType::HasChild + ); + assert_eq!( + RelationType::from_str("Is Replaced By").unwrap(), + RelationType::IsReplacedBy + ); + assert_eq!( + RelationType::from_str("Is Translation Of").unwrap(), + RelationType::IsTranslationOf + ); + assert_eq!( + RelationType::from_str("Is Part Of").unwrap(), + RelationType::IsPartOf + ); + assert_eq!( + RelationType::from_str("Is Child Of").unwrap(), + RelationType::IsChildOf + ); + + assert!(RelationType::from_str("Has Parent").is_err()); + assert!(RelationType::from_str("Subsumes").is_err()); + } +} + +#[cfg(feature = "backend")] +mod conversions { + use super::*; + use crate::model::tests::db::setup_test_db; + use crate::model::tests::{assert_db_enum_roundtrip, assert_graphql_enum_roundtrip}; + + #[test] + fn relationtype_graphql_roundtrip() { + assert_graphql_enum_roundtrip(RelationType::HasPart); + } + + #[test] + fn workrelationfield_graphql_roundtrip() { + assert_graphql_enum_roundtrip(WorkRelationField::RelationType); + } + + #[test] + fn relationtype_db_enum_roundtrip() { + let (_guard, pool) = setup_test_db(); + + assert_db_enum_roundtrip::( + pool.as_ref(), + "'has-part'::relation_type", + RelationType::HasPart, + ); + } +} + +mod helpers { + use super::*; + use crate::model::{Crud, HistoryEntry}; + + #[test] + fn pk_returns_id() { + let work_relation: WorkRelation = Default::default(); + assert_eq!(work_relation.pk(), work_relation.work_relation_id); + } + + #[test] + fn history_entry_serializes_model() { + let work_relation: WorkRelation = Default::default(); + let user_id = "123456".to_string(); + let new_work_relation_history = work_relation.new_history_entry(&user_id); + assert_eq!( + new_work_relation_history.work_relation_id, + work_relation.work_relation_id + ); + assert_eq!(new_work_relation_history.user_id, user_id); + assert_eq!( + new_work_relation_history.data, + serde_json::Value::String(serde_json::to_string(&work_relation).unwrap()) + ); + } + + #[test] + fn relationtype_convert_to_inverse_pairs() { + assert_eq!( + RelationType::HasTranslation.convert_to_inverse(), + RelationType::IsTranslationOf + ); + assert_eq!( + RelationType::IsTranslationOf.convert_to_inverse(), + RelationType::HasTranslation + ); + assert_eq!( + RelationType::IsReplacedBy.convert_to_inverse(), + RelationType::Replaces + ); + assert_eq!( + RelationType::Replaces.convert_to_inverse(), + RelationType::IsReplacedBy + ); + assert_eq!( + RelationType::IsPartOf.convert_to_inverse(), + RelationType::HasPart + ); + assert_eq!( + RelationType::HasPart.convert_to_inverse(), + RelationType::IsPartOf + ); + assert_eq!( + RelationType::IsChildOf.convert_to_inverse(), + RelationType::HasChild + ); + assert_eq!( + RelationType::HasChild.convert_to_inverse(), + RelationType::IsChildOf + ); + } +} + +#[cfg(feature = "backend")] +mod policy { + use super::*; + use std::collections::HashMap; + + use zitadel::actix::introspection::IntrospectedUser; + + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context_with_user, + test_user_with_role, + }; + use crate::model::work_relation::policy::WorkRelationPolicy; + use crate::model::Crud; + use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, Role, UpdatePolicy}; + + fn multi_org_user(user_id: &str, role: Role, org_ids: &[String]) -> IntrospectedUser { + let mut scoped = HashMap::new(); + for org_id in org_ids { + scoped.insert(org_id.clone(), "role".to_string()); + } + let mut project_roles = HashMap::new(); + project_roles.insert(role.as_ref().to_string(), scoped); + + IntrospectedUser { + user_id: user_id.to_string(), + username: None, + name: None, + given_name: None, + family_name: None, + preferred_username: None, + email: None, + email_verified: None, + locale: None, + project_roles: Some(project_roles), + metadata: None, + } + } + + #[test] + fn crud_policy_rejects_missing_publisher_role_for_related_work() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let relator = create_work(pool.as_ref(), &imprint); + let related = create_work(pool.as_ref(), &other_imprint); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("work-relation-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let new_relation = NewWorkRelation { + relator_work_id: relator.work_id, + related_work_id: related.work_id, + relation_type: RelationType::HasPart, + relation_ordinal: 1, + }; + + assert!(WorkRelationPolicy::can_create(&ctx, &new_relation, ()).is_err()); + + let relation = WorkRelation::create(pool.as_ref(), &new_relation) + .expect("Failed to create work relation"); + let patch = PatchWorkRelation { + work_relation_id: relation.work_relation_id, + relator_work_id: relation.relator_work_id, + related_work_id: relation.related_work_id, + relation_type: RelationType::Replaces, + relation_ordinal: 2, + }; + + assert!(WorkRelationPolicy::can_update(&ctx, &relation, &patch, ()).is_err()); + assert!(WorkRelationPolicy::can_delete(&ctx, &relation).is_err()); + assert!(WorkRelationPolicy::can_move(&ctx, &relation).is_err()); + } + + #[test] + fn crud_policy_allows_user_with_roles_for_both_publishers() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let other_publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let other_imprint = create_imprint(pool.as_ref(), &other_publisher); + let relator = create_work(pool.as_ref(), &imprint); + let related = create_work(pool.as_ref(), &other_imprint); + + let org_ids = vec![ + publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"), + other_publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"), + ]; + let user = multi_org_user("work-relation-user", Role::PublisherUser, &org_ids); + let ctx = test_context_with_user(pool.clone(), user); + + let new_relation = NewWorkRelation { + relator_work_id: relator.work_id, + related_work_id: related.work_id, + relation_type: RelationType::HasPart, + relation_ordinal: 1, + }; + + assert!(WorkRelationPolicy::can_create(&ctx, &new_relation, ()).is_ok()); + + let relation = WorkRelation::create(pool.as_ref(), &new_relation) + .expect("Failed to create work relation"); + let patch = PatchWorkRelation { + work_relation_id: relation.work_relation_id, + relator_work_id: relation.relator_work_id, + related_work_id: relation.related_work_id, + relation_type: RelationType::Replaces, + relation_ordinal: 2, + }; + + assert!(WorkRelationPolicy::can_update(&ctx, &relation, &patch, ()).is_ok()); + assert!(WorkRelationPolicy::can_delete(&ctx, &relation).is_ok()); + assert!(WorkRelationPolicy::can_move(&ctx, &relation).is_ok()); + } +} + +#[cfg(feature = "backend")] +mod crud { + use super::*; + use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; + use thoth_errors::ThothError; + + use crate::model::tests::db::{ + create_imprint, create_publisher, create_work, setup_test_db, test_context, + }; + use crate::model::{Crud, Reorder}; + + fn make_work_relation( + pool: &crate::db::PgPool, + relator_work_id: Uuid, + related_work_id: Uuid, + relation_type: RelationType, + relation_ordinal: i32, + ) -> WorkRelation { + let new_relation = NewWorkRelation { + relator_work_id, + related_work_id, + relation_type, + relation_ordinal, + }; + + WorkRelation::create(pool, &new_relation).expect("Failed to create work relation") + } + + #[test] + fn crud_roundtrip_create_fetch_update_delete() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let related_work = create_work(pool.as_ref(), &imprint); + + let new_relation = NewWorkRelation { + relator_work_id: work.work_id, + related_work_id: related_work.work_id, + relation_type: RelationType::HasPart, + relation_ordinal: 1, + }; + + let relation = WorkRelation::create(pool.as_ref(), &new_relation) + .expect("Failed to create work relation"); + let fetched = WorkRelation::from_id(pool.as_ref(), &relation.work_relation_id) + .expect("Failed to fetch"); + assert_eq!(relation.work_relation_id, fetched.work_relation_id); + + let patch = PatchWorkRelation { + work_relation_id: relation.work_relation_id, + relator_work_id: relation.relator_work_id, + related_work_id: relation.related_work_id, + relation_type: RelationType::Replaces, + relation_ordinal: 2, + }; + + let ctx = test_context(pool.clone(), "test-user"); + let updated = relation.update(&ctx, &patch).expect("Failed to update"); + assert_eq!(updated.relation_type, patch.relation_type); + + let inverse = updated + .get_inverse(pool.as_ref()) + .expect("Failed to fetch inverse relation"); + + let deleted = updated.delete(pool.as_ref()).expect("Failed to delete"); + assert!(WorkRelation::from_id(pool.as_ref(), &deleted.work_relation_id).is_err()); + assert!(WorkRelation::from_id(pool.as_ref(), &inverse.work_relation_id).is_err()); + } + + #[test] + fn crud_all_respects_limit_and_offset() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let relator = create_work(pool.as_ref(), &imprint); + let related_one = create_work(pool.as_ref(), &imprint); + let related_two = create_work(pool.as_ref(), &imprint); + + make_work_relation( + pool.as_ref(), + relator.work_id, + related_one.work_id, + RelationType::HasPart, + 1, + ); + make_work_relation( + pool.as_ref(), + relator.work_id, + related_two.work_id, + RelationType::HasPart, + 2, + ); + + let order = WorkRelationOrderBy { + field: WorkRelationField::WorkRelationId, + direction: Direction::Asc, + }; + + let first = WorkRelation::all( + pool.as_ref(), + 1, + 0, + None, + order.clone(), + vec![], + None, + None, + vec![RelationType::HasPart], + vec![], + None, + None, + ) + .expect("Failed to fetch work relations"); + let second = WorkRelation::all( + pool.as_ref(), + 1, + 1, + None, + order, + vec![], + None, + None, + vec![RelationType::HasPart], + vec![], + None, + None, + ) + .expect("Failed to fetch work relations"); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].work_relation_id, second[0].work_relation_id); + } + + #[test] + fn crud_count_returns_total() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let relator = create_work(pool.as_ref(), &imprint); + let related_one = create_work(pool.as_ref(), &imprint); + let related_two = create_work(pool.as_ref(), &imprint); + + make_work_relation( + pool.as_ref(), + relator.work_id, + related_one.work_id, + RelationType::HasPart, + 1, + ); + make_work_relation( + pool.as_ref(), + relator.work_id, + related_two.work_id, + RelationType::HasPart, + 2, + ); + + let count = WorkRelation::count( + pool.as_ref(), + None, + vec![], + vec![RelationType::HasPart], + vec![], + None, + None, + ) + .expect("Failed to count work relations"); + assert_eq!(count, 2); + } + + #[test] + fn crud_filter_param_limits_relation_types() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let relator = create_work(pool.as_ref(), &imprint); + let related_one = create_work(pool.as_ref(), &imprint); + let related_two = create_work(pool.as_ref(), &imprint); + + let matches = make_work_relation( + pool.as_ref(), + relator.work_id, + related_one.work_id, + RelationType::HasPart, + 1, + ); + make_work_relation( + pool.as_ref(), + relator.work_id, + related_two.work_id, + RelationType::Replaces, + 2, + ); + + let filtered = WorkRelation::all( + pool.as_ref(), + 10, + 0, + None, + WorkRelationOrderBy { + field: WorkRelationField::WorkRelationId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![RelationType::HasPart], + vec![], + None, + None, + ) + .expect("Failed to filter work relations by type"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].work_relation_id, matches.work_relation_id); + } + + #[test] + fn crud_filter_parent_work_id_limits_results() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let relator = create_work(pool.as_ref(), &imprint); + let other_relator = create_work(pool.as_ref(), &imprint); + let related = create_work(pool.as_ref(), &imprint); + + let matches = make_work_relation( + pool.as_ref(), + relator.work_id, + related.work_id, + RelationType::HasPart, + 1, + ); + make_work_relation( + pool.as_ref(), + other_relator.work_id, + related.work_id, + RelationType::HasPart, + 2, + ); + + let filtered = WorkRelation::all( + pool.as_ref(), + 10, + 0, + None, + WorkRelationOrderBy { + field: WorkRelationField::WorkRelationId, + direction: Direction::Asc, + }, + vec![], + Some(relator.work_id), + None, + vec![], + vec![], + None, + None, + ) + .expect("Failed to filter work relations by relator"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].work_relation_id, matches.work_relation_id); + } + + #[test] + fn crud_ordering_by_id_respects_direction() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let relator = create_work(pool.as_ref(), &imprint); + let related_one = create_work(pool.as_ref(), &imprint); + let related_two = create_work(pool.as_ref(), &imprint); + + let first = make_work_relation( + pool.as_ref(), + relator.work_id, + related_one.work_id, + RelationType::HasPart, + 1, + ); + let second = make_work_relation( + pool.as_ref(), + relator.work_id, + related_two.work_id, + RelationType::HasPart, + 2, + ); + let mut ids = [first.work_relation_id, second.work_relation_id]; + ids.sort(); + + let asc = WorkRelation::all( + pool.as_ref(), + 2, + 0, + None, + WorkRelationOrderBy { + field: WorkRelationField::WorkRelationId, + direction: Direction::Asc, + }, + vec![], + None, + None, + vec![RelationType::HasPart], + vec![], + None, + None, + ) + .expect("Failed to order work relations (asc)"); + + let desc = WorkRelation::all( + pool.as_ref(), + 2, + 0, + None, + WorkRelationOrderBy { + field: WorkRelationField::WorkRelationId, + direction: Direction::Desc, + }, + vec![], + None, + None, + vec![RelationType::HasPart], + vec![], + None, + None, + ) + .expect("Failed to order work relations (desc)"); + + assert_eq!(asc[0].work_relation_id, ids[0]); + assert_eq!(desc[0].work_relation_id, ids[1]); + } + + #[test] + fn crud_ordering_by_fields_is_supported() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let relator = create_work(pool.as_ref(), &imprint); + let related_one = create_work(pool.as_ref(), &imprint); + let related_two = create_work(pool.as_ref(), &imprint); + + make_work_relation( + pool.as_ref(), + relator.work_id, + related_one.work_id, + RelationType::HasPart, + 1, + ); + make_work_relation( + pool.as_ref(), + relator.work_id, + related_two.work_id, + RelationType::HasPart, + 2, + ); + + let fields: Vec WorkRelationField> = vec![ + || WorkRelationField::WorkRelationId, + || WorkRelationField::RelatorWorkId, + || WorkRelationField::RelatedWorkId, + || WorkRelationField::RelationType, + || WorkRelationField::RelationOrdinal, + || WorkRelationField::CreatedAt, + || WorkRelationField::UpdatedAt, + ]; + + for field in fields { + for direction in [Direction::Asc, Direction::Desc] { + let results = WorkRelation::all( + pool.as_ref(), + 10, + 0, + None, + WorkRelationOrderBy { + field: field(), + direction, + }, + vec![], + None, + None, + vec![RelationType::HasPart], + vec![], + None, + None, + ) + .expect("Failed to order work relations"); + + assert_eq!(results.len(), 2); + } + } + } + + #[test] + fn crud_change_ordinal_reorders_work_relations() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let relator = create_work(pool.as_ref(), &imprint); + let related_one = create_work(pool.as_ref(), &imprint); + let related_two = create_work(pool.as_ref(), &imprint); + + let first = make_work_relation( + pool.as_ref(), + relator.work_id, + related_one.work_id, + RelationType::HasPart, + 1, + ); + let second = make_work_relation( + pool.as_ref(), + relator.work_id, + related_two.work_id, + RelationType::HasPart, + 2, + ); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = first + .change_ordinal(&ctx, first.relation_ordinal, 2) + .expect("Failed to change relation ordinal"); + + let refreshed_first = WorkRelation::from_id(pool.as_ref(), &updated.work_relation_id) + .expect("Failed to fetch"); + let refreshed_second = WorkRelation::from_id(pool.as_ref(), &second.work_relation_id) + .expect("Failed to fetch"); + + assert_eq!(refreshed_first.relation_ordinal, 2); + assert_eq!(refreshed_second.relation_ordinal, 1); + } + + #[test] + fn crud_change_ordinal_noop_keeps_relation() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let relator = create_work(pool.as_ref(), &imprint); + let related = create_work(pool.as_ref(), &imprint); + + let relation = make_work_relation( + pool.as_ref(), + relator.work_id, + related.work_id, + RelationType::HasPart, + 1, + ); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = relation + .change_ordinal(&ctx, relation.relation_ordinal, relation.relation_ordinal) + .expect("Failed to no-op change ordinal"); + + assert_eq!(updated.relation_ordinal, relation.relation_ordinal); + } + + #[test] + fn crud_change_ordinal_move_up_reorders_work_relations() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let relator = create_work(pool.as_ref(), &imprint); + let related_one = create_work(pool.as_ref(), &imprint); + let related_two = create_work(pool.as_ref(), &imprint); + + let first = make_work_relation( + pool.as_ref(), + relator.work_id, + related_one.work_id, + RelationType::HasPart, + 1, + ); + let second = make_work_relation( + pool.as_ref(), + relator.work_id, + related_two.work_id, + RelationType::HasPart, + 2, + ); + + let ctx = test_context(pool.clone(), "test-user"); + let updated = second + .change_ordinal(&ctx, second.relation_ordinal, 1) + .expect("Failed to move relation ordinal up"); + + let refreshed_first = + WorkRelation::from_id(pool.as_ref(), &first.work_relation_id).expect("Failed to fetch"); + let refreshed_second = WorkRelation::from_id(pool.as_ref(), &updated.work_relation_id) + .expect("Failed to fetch"); + + assert_eq!(refreshed_second.relation_ordinal, 1); + assert_eq!(refreshed_first.relation_ordinal, 2); + } + + #[test] + fn crud_get_inverse_rejects_mismatched_relation_types() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let relator = create_work(pool.as_ref(), &imprint); + let related = create_work(pool.as_ref(), &imprint); + + let relation = make_work_relation( + pool.as_ref(), + relator.work_id, + related.work_id, + RelationType::HasPart, + 1, + ); + let inverse = relation + .get_inverse(pool.as_ref()) + .expect("Failed to fetch inverse relation"); + + let mut connection = pool.get().expect("Failed to get DB connection"); + diesel::update( + crate::schema::work_relation::dsl::work_relation.find(inverse.work_relation_id), + ) + .set(crate::schema::work_relation::dsl::relation_type.eq(RelationType::Replaces)) + .execute(&mut connection) + .expect("Failed to update inverse relation type"); + + let result = relation.get_inverse(pool.as_ref()); + assert!(matches!( + result, + Err(ThothError::InternalError(msg)) + if msg.contains("Found mismatched relation types") + )); + } +} diff --git a/thoth-api/src/policy.rs b/thoth-api/src/policy.rs new file mode 100644 index 00000000..d3233c32 --- /dev/null +++ b/thoth-api/src/policy.rs @@ -0,0 +1,386 @@ +use uuid::Uuid; +use zitadel::actix::introspection::IntrospectedUser; + +use crate::db::PgPool; +use crate::model::{Crud, PublisherId, PublisherIds}; +use thoth_errors::{ThothError, ThothResult}; + +use std::collections::HashSet; +use strum::AsRefStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, AsRefStr)] +#[strum(serialize_all = "SCREAMING_SNAKE_CASE")] +pub(crate) enum Role { + Superuser, + PublisherAdmin, + PublisherUser, + WorkLifecycle, + CdnWrite, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PublisherPermissions { + pub publisher_admin: bool, + pub work_lifecycle: bool, + pub cdn_write: bool, +} + +impl PublisherPermissions { + pub fn for_superuser() -> Self { + Self { + publisher_admin: true, + work_lifecycle: true, + cdn_write: true, + } + } +} + +pub(crate) trait UserAccess { + fn is_superuser(&self) -> bool; + + /// Returns true if the user has the given role scoped to the given ZITADEL organisation id. + fn has_role_for_org(&self, role: Role, org_id: &str) -> bool; + + /// Return all ZITADEL organisation ids the user has any publisher-scoped role for. + /// + /// This is intended for building publisher switcher lists in the frontend. + fn publisher_org_ids(&self) -> Vec; + + /// Compute the user's permissions for a specific publisher organisation. + fn permissions_for_org(&self, org_id: &str) -> PublisherPermissions; +} + +impl UserAccess for IntrospectedUser { + fn is_superuser(&self) -> bool { + let role = Role::Superuser.as_ref(); + self.project_roles + .as_ref() + .is_some_and(|roles| roles.contains_key(role)) + } + + fn has_role_for_org(&self, role: Role, org_id: &str) -> bool { + let role = role.as_ref(); + self.project_roles + .as_ref() + .and_then(|roles| roles.get(role)) + .and_then(|scoped| scoped.get(org_id)) + .is_some() + } + + fn publisher_org_ids(&self) -> Vec { + if self.is_superuser() { + // Superusers can access everything; callers should decide how to list publishers. + return vec![]; + } + + let Some(project_roles) = self.project_roles.as_ref() else { + return vec![]; + }; + + let mut org_ids: HashSet = HashSet::new(); + + // Collect org ids from all scoped project roles (excluding SUPERUSER). + // This is future-proof: adding a new publisher-scoped role automatically enables publisher selection. + let superuser_key = Role::Superuser.as_ref(); + for (role_key, scoped) in project_roles { + if role_key == superuser_key { + continue; + } + + for org_id in scoped.keys() { + org_ids.insert(org_id.clone()); + } + } + + let mut out: Vec = org_ids.into_iter().collect(); + out.sort(); + out + } + + fn permissions_for_org(&self, org_id: &str) -> PublisherPermissions { + if self.is_superuser() { + return PublisherPermissions::for_superuser(); + } + + PublisherPermissions { + publisher_admin: self.has_role_for_org(Role::PublisherAdmin, org_id), + work_lifecycle: self.has_role_for_org(Role::WorkLifecycle, org_id), + cdn_write: self.has_role_for_org(Role::CdnWrite, org_id), + } + } +} + +pub(crate) trait PolicyContext { + /// Return a reference to the database connection pool for the current request context. + fn db(&self) -> &PgPool; + + /// Return the authenticated user for the current request, if any. + fn user(&self) -> Option<&IntrospectedUser>; + + /// Require that a user is authenticated and return the authenticated user. + /// + /// # Errors + /// + /// Returns [`ThothError::Unauthorised`] if no user is present in the context. + fn require_authentication(&self) -> ThothResult<&IntrospectedUser> { + self.user().ok_or(ThothError::Unauthorised) + } + + /// Return the user id of the authenticated user. + /// + /// # Errors + /// + /// Returns [`ThothError::Unauthorised`] if no user is present in the context. + fn user_id(&self) -> ThothResult<&str> { + self.user() + .map(|u| u.user_id.as_str()) + .ok_or(ThothError::Unauthorised) + } + + /// Require that the authenticated user has the `SUPERUSER` role. + /// + /// # Errors + /// + /// Returns [`ThothError::Unauthorised`] if the user is not authenticated or does not have + /// the superuser role. + fn require_superuser(&self) -> ThothResult<&IntrospectedUser> { + let user = self.require_authentication()?; + if user.is_superuser() { + Ok(user) + } else { + Err(ThothError::Unauthorised) + } + } + + /// Authorise the current user against the publisher derived from the given value. + fn require_publisher_for(&self, value: &T) -> ThothResult<&IntrospectedUser> { + self.require_role_for_publisher(value, Role::PublisherUser) + } + + /// Authorise the current user to edit publisher and imprint data for the publisher derived from the given value. + fn require_publisher_admin_for( + &self, + value: &T, + ) -> ThothResult<&IntrospectedUser> { + self.require_role_for_publisher(value, Role::PublisherAdmin) + } + + /// Authorise the current user to change lifecycle-related fields (status, publication date, superseding, etc.). + fn require_work_lifecycle_for( + &self, + value: &T, + ) -> ThothResult<&IntrospectedUser> { + self.require_role_for_publisher(value, Role::WorkLifecycle) + } + + /// Authorise the current user to upload or modify files for the publisher derived from the given value. + #[allow(dead_code)] + fn require_cdn_write_for(&self, value: &T) -> ThothResult<&IntrospectedUser> { + self.require_role_for_publisher(value, Role::CdnWrite) + } + + /// Authorise the current user against all publishers derived from the given value. + /// + /// This is intended for entities that span more than one publisher scope, e.g. `WorkRelation`. + fn require_publishers_for(&self, value: &T) -> ThothResult<&IntrospectedUser> { + self.require_role_for_publishers(value, Role::PublisherUser) + } + + /// Authorise the current user against the publisher derived from the given value, + /// requiring the specified ZITADEL project role for that publisher's organisation. + fn require_role_for_publisher( + &self, + value: &T, + role: Role, + ) -> ThothResult<&IntrospectedUser> { + let user = self.require_authentication()?; + if user.is_superuser() { + return Ok(user); + } + + let org_id = value.zitadel_id(self.db())?; + + if user.has_role_for_org(role, &org_id) { + Ok(user) + } else { + Err(ThothError::Unauthorised) + } + } + + /// Authorise the current user against all publishers derived from the given value, + /// requiring the specified ZITADEL project role for each publisher's organisation. + /// + /// This is intended for entities that span more than one publisher scope, e.g. `WorkRelation`. + fn require_role_for_publishers( + &self, + value: &T, + role: Role, + ) -> ThothResult<&IntrospectedUser> { + let user = self.require_authentication()?; + if user.is_superuser() { + return Ok(user); + } + + for org_id in value.zitadel_ids(self.db())? { + if !user.has_role_for_org(role, &org_id) { + return Err(ThothError::Unauthorised); + } + } + + Ok(user) + } + + /// Load an entity by primary key after requiring authentication. + fn load_current(&self, id: &Uuid) -> ThothResult { + self.require_authentication()?; + T::from_id(self.db(), id) + } +} + +/// A policy for create actions. +/// +/// Some create operations require additional parameters beyond the `New*` input (e.g. markup +/// format). Use the `Params` type parameter for those cases. +pub(crate) trait CreatePolicy { + fn can_create(ctx: &C, data: &New, params: Params) -> ThothResult<()>; +} + +/// A policy for update actions. +/// +/// Some update operations require additional parameters beyond the `Patch*` input. +pub(crate) trait UpdatePolicy { + fn can_update( + ctx: &C, + current: &Model, + patch: &Patch, + params: Params, + ) -> ThothResult<()>; +} + +/// A policy for delete actions. +pub(crate) trait DeletePolicy { + fn can_delete(ctx: &C, current: &Model) -> ThothResult<()>; +} + +/// A policy for move / reorder actions. +pub(crate) trait MovePolicy { + fn can_move(ctx: &C, current: &Model) -> ThothResult<()>; +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::HashMap; + + fn mk_user( + project_roles: Option>>, + ) -> IntrospectedUser { + IntrospectedUser { + user_id: "user-1".to_string(), + username: None, + name: None, + given_name: None, + family_name: None, + preferred_username: None, + email: None, + email_verified: None, + locale: None, + project_roles, + metadata: None, + } + } + + fn scoped(org_id: &str) -> HashMap { + let mut m = HashMap::new(); + // ZITADEL stores a label/value (often domain) as the map value; it is irrelevant for our checks. + m.insert(org_id.to_string(), "label".to_string()); + m + } + + #[test] + fn role_as_ref_is_screaming_snake_case() { + assert_eq!(Role::Superuser.as_ref(), "SUPERUSER"); + assert_eq!(Role::PublisherAdmin.as_ref(), "PUBLISHER_ADMIN"); + assert_eq!(Role::PublisherUser.as_ref(), "PUBLISHER_USER"); + assert_eq!(Role::WorkLifecycle.as_ref(), "WORK_LIFECYCLE"); + assert_eq!(Role::CdnWrite.as_ref(), "CDN_WRITE"); + } + + #[test] + fn is_superuser_checks_project_roles_key() { + let mut roles: HashMap> = HashMap::new(); + roles.insert(Role::Superuser.as_ref().to_string(), HashMap::new()); + + let user = mk_user(Some(roles)); + assert!(user.is_superuser()); + + let user = mk_user(None); + assert!(!user.is_superuser()); + } + + #[test] + fn has_role_for_org_requires_scope_match() { + let mut roles: HashMap> = HashMap::new(); + roles.insert(Role::PublisherUser.as_ref().to_string(), scoped("org-1")); + + let user = mk_user(Some(roles)); + assert!(user.has_role_for_org(Role::PublisherUser, "org-1")); + assert!(!user.has_role_for_org(Role::PublisherUser, "org-2")); + assert!(!user.has_role_for_org(Role::PublisherAdmin, "org-1")); + } + + #[test] + fn publisher_org_ids_collects_all_scoped_orgs_except_superuser() { + let mut roles: HashMap> = HashMap::new(); + roles.insert(Role::PublisherUser.as_ref().to_string(), scoped("org-1")); + + // add another role with overlapping and new orgs + let mut admin_scoped = scoped("org-2"); + admin_scoped.insert("org-1".to_string(), "label".to_string()); + roles.insert(Role::PublisherAdmin.as_ref().to_string(), admin_scoped); + + let user = mk_user(Some(roles)); + let orgs = user.publisher_org_ids(); + + assert_eq!(orgs, vec!["org-1".to_string(), "org-2".to_string()]); + } + + #[test] + fn publisher_org_ids_is_empty_for_superuser() { + let mut roles: HashMap> = HashMap::new(); + roles.insert(Role::Superuser.as_ref().to_string(), HashMap::new()); + + let user = mk_user(Some(roles)); + assert!(user.publisher_org_ids().is_empty()); + } + + #[test] + fn permissions_for_org_sets_booleans_from_roles() { + let mut roles: HashMap> = HashMap::new(); + roles.insert(Role::PublisherAdmin.as_ref().to_string(), scoped("org-1")); + roles.insert(Role::WorkLifecycle.as_ref().to_string(), scoped("org-1")); + + let user = mk_user(Some(roles)); + let p = user.permissions_for_org("org-1"); + + assert!(p.publisher_admin); + assert!(p.work_lifecycle); + assert!(!p.cdn_write); + + // different org should yield no permissions + let p = user.permissions_for_org("org-2"); + assert_eq!(p, PublisherPermissions::default()); + } + + #[test] + fn permissions_for_org_all_true_for_superuser() { + let mut roles: HashMap> = HashMap::new(); + roles.insert(Role::Superuser.as_ref().to_string(), HashMap::new()); + + let user = mk_user(Some(roles)); + let p = user.permissions_for_org("any"); + + assert_eq!(p, PublisherPermissions::for_superuser()); + } +} diff --git a/thoth-api/src/schema.rs b/thoth-api/src/schema.rs index e78c5350..a12f8334 100644 --- a/thoth-api/src/schema.rs +++ b/thoth-api/src/schema.rs @@ -1,4 +1,8 @@ pub mod sql_types { + #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] + #[diesel(postgres_type(name = "award_role"))] + pub struct AwardRole; + #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] #[diesel(postgres_type(name = "contribution_type"))] pub struct ContributionType; @@ -46,24 +50,87 @@ pub mod sql_types { #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] #[diesel(postgres_type(name = "relation_type"))] pub struct RelationType; + + #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] + #[diesel(postgres_type(name = "resource_type"))] + pub struct ResourceType; + + #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] + #[diesel(postgres_type(name = "locale_code"))] + pub struct LocaleCode; + + #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] + #[diesel(postgres_type(name = "abstract_type"))] + pub struct AbstractType; + + #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] + #[diesel(postgres_type(name = "markup_format"))] + pub struct MarkupFormat; + + #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] + #[diesel(postgres_type(name = "file_type"))] + pub struct FileType; + + #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] + #[diesel(postgres_type(name = "contact_type"))] + pub struct ContactType; + + #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] + #[diesel(postgres_type(name = "accessibility_standard"))] + pub struct AccessibilityStandard; + + #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] + #[diesel(postgres_type(name = "accessibility_exception"))] + pub struct AccessibilityException; } +use diesel::{allow_tables_to_appear_in_same_query, joinable, table}; + table! { use diesel::sql_types::*; + use super::sql_types::{LocaleCode, MarkupFormat, AbstractType}; - account (account_id) { - account_id -> Uuid, - name -> Text, - surname -> Text, - email -> Text, - hash -> Bytea, - salt -> Text, - is_superuser -> Bool, - is_bot -> Bool, - is_active -> Bool, + #[sql_name = "abstract"] + work_abstract (abstract_id) { + abstract_id -> Uuid, + work_id -> Uuid, + content -> Text, + locale_code -> LocaleCode, + abstract_type -> AbstractType, + canonical -> Bool, + } +} + +table! { + use diesel::sql_types::*; + use super::sql_types::ResourceType; + + additional_resource (additional_resource_id) { + additional_resource_id -> Uuid, + work_id -> Uuid, + title -> Text, + description -> Nullable, + attribution -> Nullable, + resource_type -> ResourceType, + doi -> Nullable, + handle -> Nullable, + url -> Nullable, + date -> Nullable, + resource_ordinal -> Int4, created_at -> Timestamptz, updated_at -> Timestamptz, - token -> Nullable, + } +} + +table! { + use diesel::sql_types::*; + + additional_resource_history (additional_resource_history_id) { + additional_resource_history_id -> Uuid, + additional_resource_id -> Uuid, + user_id -> Text, + data -> Jsonb, + timestamp -> Timestamptz, } } @@ -81,13 +148,82 @@ table! { } } +table! { + use diesel::sql_types::*; + use super::sql_types::LocaleCode; + + biography (biography_id) { + biography_id -> Uuid, + contribution_id -> Uuid, + content -> Text, + canonical -> Bool, + locale_code -> LocaleCode, + } +} + +table! { + use diesel::sql_types::*; + use super::sql_types::AwardRole; + + award (award_id) { + award_id -> Uuid, + work_id -> Uuid, + title -> Text, + url -> Nullable, + category -> Nullable, + prize_statement -> Nullable, + role -> Nullable, + award_ordinal -> Int4, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + + award_history (award_history_id) { + award_history_id -> Uuid, + award_id -> Uuid, + user_id -> Text, + data -> Jsonb, + timestamp -> Timestamptz, + } +} + table! { use diesel::sql_types::*; affiliation_history (affiliation_history_id) { affiliation_history_id -> Uuid, affiliation_id -> Uuid, - account_id -> Uuid, + user_id -> Text, + data -> Jsonb, + timestamp -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + use super::sql_types::ContactType; + + contact (contact_id) { + contact_id -> Uuid, + publisher_id -> Uuid, + contact_type -> ContactType, + email -> Text, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + + contact_history (contact_history_id) { + contact_history_id -> Uuid, + contact_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -103,7 +239,6 @@ table! { contributor_id -> Uuid, contribution_type -> ContributionType, main_contribution -> Bool, - biography -> Nullable, created_at -> Timestamptz, updated_at -> Timestamptz, first_name -> Nullable, @@ -119,7 +254,7 @@ table! { contribution_history (contribution_history_id) { contribution_history_id -> Uuid, contribution_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -146,7 +281,74 @@ table! { contributor_history (contributor_history_id) { contributor_history_id -> Uuid, contributor_id -> Uuid, - account_id -> Uuid, + user_id -> Text, + data -> Jsonb, + timestamp -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + + book_review (book_review_id) { + book_review_id -> Uuid, + work_id -> Uuid, + title -> Nullable, + author_name -> Nullable, + reviewer_orcid -> Nullable, + reviewer_institution_id -> Nullable, + url -> Nullable, + doi -> Nullable, + review_date -> Nullable, + journal_name -> Nullable, + journal_volume -> Nullable, + journal_number -> Nullable, + journal_issn -> Nullable, + page_range -> Nullable, + text -> Nullable, + review_ordinal -> Int4, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + + book_review_history (book_review_history_id) { + book_review_history_id -> Uuid, + book_review_id -> Uuid, + user_id -> Text, + data -> Jsonb, + timestamp -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + + endorsement (endorsement_id) { + endorsement_id -> Uuid, + work_id -> Uuid, + author_name -> Nullable, + author_role -> Nullable, + author_orcid -> Nullable, + author_institution_id -> Nullable, + url -> Nullable, + text -> Nullable, + endorsement_ordinal -> Int4, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + + endorsement_history (endorsement_history_id) { + endorsement_history_id -> Uuid, + endorsement_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -163,7 +365,6 @@ table! { project_name -> Nullable, project_shortname -> Nullable, grant_number -> Nullable, - jurisdiction -> Nullable, created_at -> Timestamptz, updated_at -> Timestamptz, } @@ -175,7 +376,7 @@ table! { funding_history (funding_history_id) { funding_history_id -> Uuid, funding_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -183,6 +384,8 @@ table! { table! { use diesel::sql_types::*; + use super::sql_types::CurrencyCode; + use super::sql_types::LocaleCode; imprint (imprint_id) { imprint_id -> Uuid, @@ -190,6 +393,12 @@ table! { imprint_name -> Text, imprint_url -> Nullable, crossmark_doi -> Nullable, + s3_bucket -> Nullable, + cdn_domain -> Nullable, + cloudfront_dist_id -> Nullable, + default_currency -> Nullable, + default_place -> Nullable, + default_locale -> Nullable, created_at -> Timestamptz, updated_at -> Timestamptz, } @@ -201,7 +410,7 @@ table! { imprint_history (imprint_history_id) { imprint_history_id -> Uuid, imprint_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -228,7 +437,7 @@ table! { institution_history (institution_history_id) { institution_history_id -> Uuid, institution_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -242,6 +451,7 @@ table! { series_id -> Uuid, work_id -> Uuid, issue_ordinal -> Int4, + issue_number -> Nullable, created_at -> Timestamptz, updated_at -> Timestamptz, } @@ -253,7 +463,7 @@ table! { issue_history (issue_history_id) { issue_history_id -> Uuid, issue_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -269,7 +479,6 @@ table! { work_id -> Uuid, language_code -> LanguageCode, language_relation -> LanguageRelation, - main_language -> Bool, created_at -> Timestamptz, updated_at -> Timestamptz, } @@ -281,7 +490,7 @@ table! { language_history (language_history_id) { language_history_id -> Uuid, language_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -309,7 +518,7 @@ table! { location_history (location_history_id) { location_history_id -> Uuid, location_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -335,7 +544,7 @@ table! { price_history (price_history_id) { price_history_id -> Uuid, price_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -344,6 +553,8 @@ table! { table! { use diesel::sql_types::*; use super::sql_types::PublicationType; + use super::sql_types::AccessibilityStandard; + use super::sql_types::AccessibilityException; publication (publication_id) { publication_id -> Uuid, @@ -360,6 +571,10 @@ table! { depth_in -> Nullable, weight_g -> Nullable, weight_oz -> Nullable, + accessibility_standard -> Nullable, + accessibility_additional_standard -> Nullable, + accessibility_exception -> Nullable, + accessibility_report_url -> Nullable, } } @@ -369,7 +584,7 @@ table! { publication_history (publication_history_id) { publication_history_id -> Uuid, publication_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -383,18 +598,9 @@ table! { publisher_name -> Text, publisher_shortname -> Nullable, publisher_url -> Nullable, - created_at -> Timestamptz, - updated_at -> Timestamptz, - } -} - -table! { - use diesel::sql_types::*; - - publisher_account (account_id, publisher_id) { - account_id -> Uuid, - publisher_id -> Uuid, - is_admin -> Bool, + zitadel_id -> Nullable, + accessibility_statement -> Nullable, + accessibility_report_url -> Nullable, created_at -> Timestamptz, updated_at -> Timestamptz, } @@ -406,7 +612,7 @@ table! { publisher_history (publisher_history_id) { publisher_history_id -> Uuid, publisher_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -450,7 +656,7 @@ table! { reference_history (reference_history_id) { reference_history_id -> Uuid, reference_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -481,7 +687,7 @@ table! { series_history (series_history_id) { series_history_id -> Uuid, series_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -508,7 +714,7 @@ table! { subject_history (subject_history_id) { subject_history_id -> Uuid, subject_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -523,9 +729,6 @@ table! { work_id -> Uuid, work_type -> WorkType, work_status -> WorkStatus, - full_title -> Text, - title -> Text, - subtitle -> Nullable, reference -> Nullable, edition -> Nullable, imprint_id -> Uuid, @@ -544,11 +747,10 @@ table! { landing_page -> Nullable, lccn -> Nullable, oclc -> Nullable, - short_abstract -> Nullable, - long_abstract -> Nullable, general_note -> Nullable, bibliography_note -> Nullable, toc -> Nullable, + resources_description -> Nullable, cover_url -> Nullable, cover_caption -> Nullable, created_at -> Timestamptz, @@ -566,7 +768,34 @@ table! { work_history (work_history_id) { work_history_id -> Uuid, work_id -> Uuid, - account_id -> Uuid, + user_id -> Text, + data -> Jsonb, + timestamp -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + + work_featured_video (work_featured_video_id) { + work_featured_video_id -> Uuid, + work_id -> Uuid, + title -> Nullable, + url -> Nullable, + width -> Int4, + height -> Int4, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + + work_featured_video_history (work_featured_video_history_id) { + work_featured_video_history_id -> Uuid, + work_featured_video_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -593,75 +822,190 @@ table! { work_relation_history (work_relation_history_id) { work_relation_history_id -> Uuid, work_relation_id -> Uuid, - account_id -> Uuid, + user_id -> Text, + data -> Jsonb, + timestamp -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + use super::sql_types::LocaleCode; + use super::sql_types::MarkupFormat; + + #[sql_name = "title"] + work_title (title_id) { + title_id -> Uuid, + work_id -> Uuid, + full_title -> Text, + title -> Text, + subtitle -> Nullable, + canonical -> Bool, + locale_code -> LocaleCode, + } +} + +table! { + use diesel::sql_types::*; + + title_history (title_history_id) { + title_history_id -> Uuid, + title_id -> Uuid, + user_id -> Text, + data -> Jsonb, + timestamp -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + use super::sql_types::FileType; + + file (file_id) { + file_id -> Uuid, + file_type -> FileType, + work_id -> Nullable, + publication_id -> Nullable, + additional_resource_id -> Nullable, + work_featured_video_id -> Nullable, + object_key -> Text, + cdn_url -> Text, + mime_type -> Text, + bytes -> Int8, + sha256 -> Text, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + use super::sql_types::FileType; + + file_upload (file_upload_id) { + file_upload_id -> Uuid, + file_type -> FileType, + work_id -> Nullable, + publication_id -> Nullable, + additional_resource_id -> Nullable, + work_featured_video_id -> Nullable, + declared_mime_type -> Text, + declared_extension -> Text, + declared_sha256 -> Text, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + + abstract_history (abstract_history_id) { + abstract_history_id -> Uuid, + abstract_id -> Uuid, + user_id -> Text, + data -> Jsonb, + timestamp -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + + biography_history (biography_history_id) { + biography_history_id -> Uuid, + biography_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } } +joinable!(abstract_history -> work_abstract (abstract_id)); +joinable!(additional_resource -> work (work_id)); +joinable!(additional_resource_history -> additional_resource (additional_resource_id)); joinable!(affiliation -> contribution (contribution_id)); joinable!(affiliation -> institution (institution_id)); -joinable!(affiliation_history -> account (account_id)); joinable!(affiliation_history -> affiliation (affiliation_id)); +joinable!(award -> work (work_id)); +joinable!(award_history -> award (award_id)); +joinable!(biography_history -> biography (biography_id)); +joinable!(book_review -> institution (reviewer_institution_id)); +joinable!(book_review -> work (work_id)); +joinable!(book_review_history -> book_review (book_review_id)); +joinable!(contact -> publisher (publisher_id)); +joinable!(contact_history -> contact (contact_id)); joinable!(contribution -> contributor (contributor_id)); joinable!(contribution -> work (work_id)); -joinable!(contribution_history -> account (account_id)); joinable!(contribution_history -> contribution (contribution_id)); -joinable!(contributor_history -> account (account_id)); joinable!(contributor_history -> contributor (contributor_id)); +joinable!(endorsement -> institution (author_institution_id)); +joinable!(endorsement -> work (work_id)); +joinable!(endorsement_history -> endorsement (endorsement_id)); +joinable!(file -> work (work_id)); +joinable!(file -> publication (publication_id)); +joinable!(file -> additional_resource (additional_resource_id)); +joinable!(file -> work_featured_video (work_featured_video_id)); +joinable!(file_upload -> work (work_id)); +joinable!(file_upload -> publication (publication_id)); +joinable!(file_upload -> additional_resource (additional_resource_id)); +joinable!(file_upload -> work_featured_video (work_featured_video_id)); joinable!(funding -> institution (institution_id)); joinable!(funding -> work (work_id)); -joinable!(funding_history -> account (account_id)); joinable!(funding_history -> funding (funding_id)); joinable!(imprint -> publisher (publisher_id)); -joinable!(imprint_history -> account (account_id)); joinable!(imprint_history -> imprint (imprint_id)); -joinable!(institution_history -> account (account_id)); joinable!(institution_history -> institution (institution_id)); joinable!(issue -> series (series_id)); joinable!(issue -> work (work_id)); -joinable!(issue_history -> account (account_id)); joinable!(issue_history -> issue (issue_id)); joinable!(language -> work (work_id)); -joinable!(language_history -> account (account_id)); joinable!(language_history -> language (language_id)); joinable!(location -> publication (publication_id)); -joinable!(location_history -> account (account_id)); joinable!(location_history -> location (location_id)); joinable!(price -> publication (publication_id)); -joinable!(price_history -> account (account_id)); joinable!(price_history -> price (price_id)); joinable!(publication -> work (work_id)); -joinable!(publication_history -> account (account_id)); joinable!(publication_history -> publication (publication_id)); -joinable!(publisher_account -> account (account_id)); -joinable!(publisher_account -> publisher (publisher_id)); -joinable!(publisher_history -> account (account_id)); joinable!(publisher_history -> publisher (publisher_id)); joinable!(reference -> work (work_id)); -joinable!(reference_history -> account (account_id)); joinable!(reference_history -> reference (reference_id)); joinable!(series -> imprint (imprint_id)); -joinable!(series_history -> account (account_id)); joinable!(series_history -> series (series_id)); joinable!(subject -> work (work_id)); -joinable!(subject_history -> account (account_id)); joinable!(subject_history -> subject (subject_id)); +joinable!(title_history -> work_title (title_id)); joinable!(work -> imprint (imprint_id)); -joinable!(work_history -> account (account_id)); +joinable!(work_abstract -> work (work_id)); joinable!(work_history -> work (work_id)); +joinable!(work_featured_video -> work (work_id)); +joinable!(work_featured_video_history -> work_featured_video (work_featured_video_id)); joinable!(work_relation -> work (relator_work_id)); -joinable!(work_relation_history -> account (account_id)); joinable!(work_relation_history -> work_relation (work_relation_id)); +joinable!(work_title -> work (work_id)); allow_tables_to_appear_in_same_query!( - account, + abstract_history, + additional_resource, + additional_resource_history, affiliation, affiliation_history, + award, + award_history, + biography, + biography_history, + book_review, + book_review_history, + contact, + contact_history, contribution, contribution_history, contributor, contributor_history, + endorsement, + endorsement_history, + file, + file_upload, funding, funding_history, imprint, @@ -679,7 +1023,6 @@ allow_tables_to_appear_in_same_query!( publication, publication_history, publisher, - publisher_account, publisher_history, reference, reference_history, @@ -687,8 +1030,13 @@ allow_tables_to_appear_in_same_query!( series_history, subject, subject_history, + title_history, work, + work_abstract, work_history, + work_featured_video, + work_featured_video_history, work_relation, work_relation_history, + work_title, ); diff --git a/thoth-api/src/storage/cleanup.rs b/thoth-api/src/storage/cleanup.rs new file mode 100644 index 00000000..0c14c162 --- /dev/null +++ b/thoth-api/src/storage/cleanup.rs @@ -0,0 +1,360 @@ +use log::{info, warn}; +use std::time::{Duration, Instant}; +use thoth_errors::ThothResult; +use uuid::Uuid; + +use crate::db::PgPool; +use crate::model::{ + additional_resource::AdditionalResource, + file::{File, FileCleanupCandidate}, + imprint::Imprint, + publication::Publication, + work::Work, + work_featured_video::WorkFeaturedVideo, + Crud, +}; + +use super::{ + cleanup_object_best_effort, AwsErrorContext, CleanupObjectOutcome, CloudFrontClient, S3Client, + StorageConfig, +}; + +const SLOW_OPERATION_WARN_THRESHOLD_MS: u128 = 5_000; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(super) struct CleanupCounters { + pub keys_total: usize, + pub keys_deleted: usize, + pub keys_absent: usize, + pub keys_failed: usize, +} + +impl CleanupCounters { + pub(super) fn record(&mut self, outcome: CleanupObjectOutcome) { + self.keys_total += 1; + match outcome { + CleanupObjectOutcome::Deleted => self.keys_deleted += 1, + CleanupObjectOutcome::AlreadyAbsent => self.keys_absent += 1, + CleanupObjectOutcome::Failed => self.keys_failed += 1, + } + } +} + +pub(super) fn duration_ms(duration: Duration) -> u128 { + duration.as_millis() +} + +pub(super) fn elapsed_ms(started: Instant) -> u128 { + duration_ms(started.elapsed()) +} + +fn aws_log_fields( + context: Option<&AwsErrorContext>, +) -> (String, String, String, String, String, &'static str) { + match context { + Some(context) => ( + context.code.clone().unwrap_or_default(), + context.message.clone().unwrap_or_default(), + context + .http_status + .map(|status| status.to_string()) + .unwrap_or_default(), + context.request_id.clone().unwrap_or_default(), + context.extended_request_id.clone().unwrap_or_default(), + context.retryable_classification, + ), + None => ( + String::new(), + String::new(), + String::new(), + String::new(), + String::new(), + "", + ), + } +} + +pub struct FileCleanupPlan { + pub entity_type: &'static str, + pub entity_id: Uuid, + pub storage_config: StorageConfig, + pub candidates: Vec, +} + +fn resolve_storage_config( + db: &PgPool, + work: &Work, + entity_type: &'static str, + entity_id: Uuid, +) -> ThothResult> { + let imprint = Imprint::from_id(db, &work.imprint_id)?; + match StorageConfig::from_imprint(&imprint) { + Ok(storage_config) => Ok(Some(storage_config)), + Err(error) => { + warn!( + "entity_type={entity_type} entity_id={entity_id} cleanup_skipped=true reason=\"missing_storage_config\" error=\"{error}\"", + ); + Ok(None) + } + } +} + +pub fn work_cleanup_plan(db: &PgPool, work: &Work) -> ThothResult> { + let candidates = File::cleanup_candidates_for_work(db, &work.work_id)?; + if candidates.is_empty() { + return Ok(None); + } + + let Some(storage_config) = resolve_storage_config(db, work, "work", work.work_id)? else { + return Ok(None); + }; + + Ok(Some(FileCleanupPlan { + entity_type: "work", + entity_id: work.work_id, + storage_config, + candidates, + })) +} + +pub fn publication_cleanup_plan( + db: &PgPool, + publication: &Publication, +) -> ThothResult> { + let candidates = File::cleanup_candidates_for_publication(db, &publication.publication_id)?; + if candidates.is_empty() { + return Ok(None); + } + + let work = Work::from_id(db, &publication.work_id)?; + let Some(storage_config) = + resolve_storage_config(db, &work, "publication", publication.publication_id)? + else { + return Ok(None); + }; + + Ok(Some(FileCleanupPlan { + entity_type: "publication", + entity_id: publication.publication_id, + storage_config, + candidates, + })) +} + +pub fn additional_resource_cleanup_plan( + db: &PgPool, + additional_resource: &AdditionalResource, +) -> ThothResult> { + let candidates = File::cleanup_candidates_for_additional_resource( + db, + &additional_resource.additional_resource_id, + )?; + if candidates.is_empty() { + return Ok(None); + } + + let work = Work::from_id(db, &additional_resource.work_id)?; + let Some(storage_config) = resolve_storage_config( + db, + &work, + "additional_resource", + additional_resource.additional_resource_id, + )? + else { + return Ok(None); + }; + + Ok(Some(FileCleanupPlan { + entity_type: "additional_resource", + entity_id: additional_resource.additional_resource_id, + storage_config, + candidates, + })) +} + +pub fn work_featured_video_cleanup_plan( + db: &PgPool, + work_featured_video: &WorkFeaturedVideo, +) -> ThothResult> { + let candidates = File::cleanup_candidates_for_work_featured_video( + db, + &work_featured_video.work_featured_video_id, + )?; + if candidates.is_empty() { + return Ok(None); + } + + let work = Work::from_id(db, &work_featured_video.work_id)?; + let Some(storage_config) = resolve_storage_config( + db, + &work, + "work_featured_video", + work_featured_video.work_featured_video_id, + )? + else { + return Ok(None); + }; + + Ok(Some(FileCleanupPlan { + entity_type: "work_featured_video", + entity_id: work_featured_video.work_featured_video_id, + storage_config, + candidates, + })) +} + +pub async fn run_cleanup_plan( + s3_client: &S3Client, + cloudfront_client: &CloudFrontClient, + plan: FileCleanupPlan, +) { + let started = Instant::now(); + let cleanup_run_id = Uuid::new_v4(); + let mut counters = CleanupCounters::default(); + + for candidate in &plan.candidates { + let report = cleanup_object_best_effort( + s3_client, + cloudfront_client, + &plan.storage_config.s3_bucket, + &plan.storage_config.cloudfront_dist_id, + &candidate.object_key, + ) + .await; + counters.record(report.outcome); + + let ( + delete_error_code, + delete_error_message, + delete_http_status, + delete_request_id, + delete_extended_request_id, + delete_retryable_classification, + ) = aws_log_fields(report.delete_error.as_ref()); + let delete_slow = report.delete_ms > SLOW_OPERATION_WARN_THRESHOLD_MS; + let delete_base_log = format!( + "cleanup_run_id={} entity_type={} entity_id={} file_type={} object_key={} bucket={} cloudfront_dist_id={} phase=delete result={} delete_ms={} aws_error_code={} aws_error_message={:?} aws_http_status={} request_id={} extended_request_id={} retryable_classification={}", + cleanup_run_id, + plan.entity_type, + plan.entity_id, + candidate.file_type, + candidate.object_key, + plan.storage_config.s3_bucket, + plan.storage_config.cloudfront_dist_id, + report.delete_outcome.as_str(), + report.delete_ms, + delete_error_code, + delete_error_message, + delete_http_status, + delete_request_id, + delete_extended_request_id, + delete_retryable_classification + ); + if report.delete_outcome == CleanupObjectOutcome::Failed { + warn!("{delete_base_log}"); + } else { + info!("{delete_base_log}"); + } + if delete_slow { + warn!( + "cleanup_run_id={} entity_type={} entity_id={} file_type={} object_key={} bucket={} cloudfront_dist_id={} phase=delete slow_operation=true operation_ms={} threshold_ms={}", + cleanup_run_id, + plan.entity_type, + plan.entity_id, + candidate.file_type, + candidate.object_key, + plan.storage_config.s3_bucket, + plan.storage_config.cloudfront_dist_id, + report.delete_ms, + SLOW_OPERATION_WARN_THRESHOLD_MS + ); + } + + match report.invalidate_ms { + Some(invalidate_ms) => { + let ( + invalidate_error_code, + invalidate_error_message, + invalidate_http_status, + invalidate_request_id, + invalidate_extended_request_id, + invalidate_retryable_classification, + ) = aws_log_fields(report.invalidate_error.as_ref()); + let invalidate_result = if report.invalidate_error.is_some() { + "failed" + } else { + "invalidated" + }; + let invalidate_base_log = format!( + "cleanup_run_id={} entity_type={} entity_id={} file_type={} object_key={} bucket={} cloudfront_dist_id={} phase=invalidate result={} invalidate_ms={} aws_error_code={} aws_error_message={:?} aws_http_status={} request_id={} extended_request_id={} retryable_classification={}", + cleanup_run_id, + plan.entity_type, + plan.entity_id, + candidate.file_type, + candidate.object_key, + plan.storage_config.s3_bucket, + plan.storage_config.cloudfront_dist_id, + invalidate_result, + invalidate_ms, + invalidate_error_code, + invalidate_error_message, + invalidate_http_status, + invalidate_request_id, + invalidate_extended_request_id, + invalidate_retryable_classification + ); + if report.invalidate_error.is_some() { + warn!("{invalidate_base_log}"); + } else { + info!("{invalidate_base_log}"); + } + if invalidate_ms > SLOW_OPERATION_WARN_THRESHOLD_MS { + warn!( + "cleanup_run_id={} entity_type={} entity_id={} file_type={} object_key={} bucket={} cloudfront_dist_id={} phase=invalidate slow_operation=true operation_ms={} threshold_ms={}", + cleanup_run_id, + plan.entity_type, + plan.entity_id, + candidate.file_type, + candidate.object_key, + plan.storage_config.s3_bucket, + plan.storage_config.cloudfront_dist_id, + invalidate_ms, + SLOW_OPERATION_WARN_THRESHOLD_MS + ); + } + } + None => { + info!( + "cleanup_run_id={} entity_type={} entity_id={} file_type={} object_key={} bucket={} cloudfront_dist_id={} phase=invalidate result=skipped reason=delete_failed", + cleanup_run_id, + plan.entity_type, + plan.entity_id, + candidate.file_type, + candidate.object_key, + plan.storage_config.s3_bucket, + plan.storage_config.cloudfront_dist_id + ); + } + } + } + + info!( + "cleanup_run_id={} entity_type={} entity_id={} cleanup_completed=true keys_total={} keys_deleted={} keys_absent={} keys_failed={} duration_ms={}", + cleanup_run_id, + plan.entity_type, + plan.entity_id, + counters.keys_total, + counters.keys_deleted, + counters.keys_absent, + counters.keys_failed, + elapsed_ms(started) + ); +} + +pub fn run_cleanup_plan_sync( + s3_client: &S3Client, + cloudfront_client: &CloudFrontClient, + plan: FileCleanupPlan, +) { + futures::executor::block_on(run_cleanup_plan(s3_client, cloudfront_client, plan)); +} diff --git a/thoth-api/src/storage/mod.rs b/thoth-api/src/storage/mod.rs new file mode 100644 index 00000000..293b0f90 --- /dev/null +++ b/thoth-api/src/storage/mod.rs @@ -0,0 +1,726 @@ +use aws_sdk_cloudfront::operation::create_invalidation::CreateInvalidationError; +use aws_sdk_cloudfront::operation::RequestId as CloudFrontRequestId; +pub use aws_sdk_cloudfront::Client as CloudFrontClient; +use aws_sdk_s3::operation::delete_object::DeleteObjectError; +use aws_sdk_s3::operation::RequestId as S3RequestId; +pub use aws_sdk_s3::Client as S3Client; +use aws_sdk_s3::{presigning::PresigningConfig, types::ChecksumAlgorithm}; +use std::time::{Duration as StdDuration, Instant}; +use thoth_errors::{ThothError, ThothResult}; +use uuid::Uuid; + +use crate::model::imprint::Imprint; + +pub mod cleanup; +pub use cleanup::{ + additional_resource_cleanup_plan, publication_cleanup_plan, run_cleanup_plan, + run_cleanup_plan_sync, work_cleanup_plan, work_featured_video_cleanup_plan, FileCleanupPlan, +}; + +const S3_EXTENDED_REQUEST_ID_META_KEY: &str = "s3_extended_request_id"; +const S3_EXTENDED_REQUEST_ID_HEADER: &str = "x-amz-id-2"; +const CLOUDFRONT_REQUEST_ID_HEADER: &str = "x-amz-cf-id"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CleanupObjectOutcome { + Deleted, + AlreadyAbsent, + Failed, +} + +impl CleanupObjectOutcome { + pub fn as_str(&self) -> &'static str { + match self { + CleanupObjectOutcome::Deleted => "deleted", + CleanupObjectOutcome::AlreadyAbsent => "already_absent", + CleanupObjectOutcome::Failed => "failed", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AwsErrorContext { + pub code: Option, + pub message: Option, + pub http_status: Option, + pub request_id: Option, + pub extended_request_id: Option, + pub retryable_classification: &'static str, +} + +impl AwsErrorContext { + fn summary(&self) -> String { + format!( + "code={} message={} http_status={} request_id={} extended_request_id={} retryable_classification={}", + self.code.as_deref().unwrap_or(""), + self.message.as_deref().unwrap_or(""), + self.http_status + .map(|status| status.to_string()) + .unwrap_or_default(), + self.request_id.as_deref().unwrap_or(""), + self.extended_request_id.as_deref().unwrap_or(""), + self.retryable_classification, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CleanupObjectReport { + pub outcome: CleanupObjectOutcome, + pub delete_outcome: CleanupObjectOutcome, + pub delete_ms: u128, + pub invalidate_ms: Option, + pub delete_error: Option, + pub invalidate_error: Option, +} + +pub fn classify_delete_error(error: &AwsErrorContext) -> CleanupObjectOutcome { + let is_absent_code = matches!( + error.code.as_deref(), + Some("NoSuchKey") | Some("NotFound") | Some("NoSuchObject") + ); + if is_absent_code || error.http_status == Some(404) { + CleanupObjectOutcome::AlreadyAbsent + } else { + CleanupObjectOutcome::Failed + } +} + +fn s3_retryable_classification( + error: &aws_sdk_s3::error::SdkError, +) -> &'static str { + match error { + aws_sdk_s3::error::SdkError::ConstructionFailure(_) => "construction_failure", + aws_sdk_s3::error::SdkError::TimeoutError(_) => "timeout", + aws_sdk_s3::error::SdkError::DispatchFailure(dispatch) if dispatch.is_timeout() => { + "dispatch_timeout" + } + aws_sdk_s3::error::SdkError::DispatchFailure(dispatch) if dispatch.is_io() => "dispatch_io", + aws_sdk_s3::error::SdkError::DispatchFailure(dispatch) if dispatch.is_user() => { + "dispatch_user" + } + aws_sdk_s3::error::SdkError::DispatchFailure(_) => "dispatch_other", + aws_sdk_s3::error::SdkError::ResponseError(_) => "response_error", + aws_sdk_s3::error::SdkError::ServiceError(_) => "service_error", + _ => "unknown", + } +} + +fn cloudfront_retryable_classification( + error: &aws_sdk_cloudfront::error::SdkError, +) -> &'static str { + match error { + aws_sdk_cloudfront::error::SdkError::ConstructionFailure(_) => "construction_failure", + aws_sdk_cloudfront::error::SdkError::TimeoutError(_) => "timeout", + aws_sdk_cloudfront::error::SdkError::DispatchFailure(dispatch) if dispatch.is_timeout() => { + "dispatch_timeout" + } + aws_sdk_cloudfront::error::SdkError::DispatchFailure(dispatch) if dispatch.is_io() => { + "dispatch_io" + } + aws_sdk_cloudfront::error::SdkError::DispatchFailure(dispatch) if dispatch.is_user() => { + "dispatch_user" + } + aws_sdk_cloudfront::error::SdkError::DispatchFailure(_) => "dispatch_other", + aws_sdk_cloudfront::error::SdkError::ResponseError(_) => "response_error", + aws_sdk_cloudfront::error::SdkError::ServiceError(_) => "service_error", + _ => "unknown", + } +} + +fn s3_delete_error_context( + error: &aws_sdk_s3::error::SdkError, +) -> AwsErrorContext { + let metadata = aws_sdk_s3::error::ProvideErrorMetadata::meta(error); + AwsErrorContext { + code: aws_sdk_s3::error::ProvideErrorMetadata::code(error).map(ToOwned::to_owned), + message: aws_sdk_s3::error::ProvideErrorMetadata::message(error).map(ToOwned::to_owned), + http_status: error + .raw_response() + .map(|response| response.status().as_u16()), + request_id: S3RequestId::request_id(metadata).map(ToOwned::to_owned), + extended_request_id: metadata + .extra(S3_EXTENDED_REQUEST_ID_META_KEY) + .map(ToOwned::to_owned) + .or_else(|| { + error + .raw_response() + .and_then(|response| response.headers().get(S3_EXTENDED_REQUEST_ID_HEADER)) + .map(ToOwned::to_owned) + }), + retryable_classification: s3_retryable_classification(error), + } +} + +fn cloudfront_invalidation_error_context( + error: &aws_sdk_cloudfront::error::SdkError, +) -> AwsErrorContext { + let metadata = aws_sdk_cloudfront::error::ProvideErrorMetadata::meta(error); + AwsErrorContext { + code: aws_sdk_cloudfront::error::ProvideErrorMetadata::code(error).map(ToOwned::to_owned), + message: aws_sdk_cloudfront::error::ProvideErrorMetadata::message(error) + .map(ToOwned::to_owned), + http_status: error + .raw_response() + .map(|response| response.status().as_u16()), + request_id: CloudFrontRequestId::request_id(metadata).map(ToOwned::to_owned), + extended_request_id: error + .raw_response() + .and_then(|response| response.headers().get(CLOUDFRONT_REQUEST_ID_HEADER)) + .map(ToOwned::to_owned), + retryable_classification: cloudfront_retryable_classification(error), + } +} + +fn local_error_context(message: String) -> AwsErrorContext { + AwsErrorContext { + code: None, + message: Some(message), + http_status: None, + request_id: None, + extended_request_id: None, + retryable_classification: "non_service_error", + } +} + +fn thoth_internal_error(operation: &str, context: &AwsErrorContext) -> ThothError { + ThothError::InternalError(format!("{operation}: {}", context.summary())) +} + +async fn delete_object_with_outcome( + s3_client: &S3Client, + bucket: &str, + key: &str, +) -> Result { + match s3_client + .delete_object() + .bucket(bucket) + .key(key) + .send() + .await + { + Ok(_) => Ok(CleanupObjectOutcome::Deleted), + Err(error) => { + let context = s3_delete_error_context(&error); + match classify_delete_error(&context) { + CleanupObjectOutcome::AlreadyAbsent => Ok(CleanupObjectOutcome::AlreadyAbsent), + CleanupObjectOutcome::Deleted => Ok(CleanupObjectOutcome::Deleted), + CleanupObjectOutcome::Failed => Err(context), + } + } + } +} + +async fn invalidate_cloudfront_with_context( + cloudfront_client: &CloudFrontClient, + distribution_id: &str, + path: &str, +) -> Result { + use aws_sdk_cloudfront::types::Paths; + + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{}", path) + }; + + let paths = Paths::builder() + .quantity(1) + .items(path) + .build() + .map_err(|error| local_error_context(format!("Failed to build paths: {error}")))?; + + let invalidation_batch = aws_sdk_cloudfront::types::InvalidationBatch::builder() + .paths(paths) + .caller_reference(format!("thoth-{}", Uuid::new_v4())) + .build() + .map_err(|error| { + local_error_context(format!("Failed to build invalidation batch: {error}")) + })?; + + let response = cloudfront_client + .create_invalidation() + .distribution_id(distribution_id) + .invalidation_batch(invalidation_batch) + .send() + .await + .map_err(|error| cloudfront_invalidation_error_context(&error))?; + + response + .invalidation() + .map(|invalidation| invalidation.id().to_string()) + .ok_or_else(|| local_error_context("No invalidation ID returned".to_string())) +} + +/// Storage configuration extracted from an imprint +pub struct StorageConfig { + pub s3_bucket: String, + pub cdn_domain: String, + pub cloudfront_dist_id: String, +} + +impl StorageConfig { + /// Extract storage configuration from an imprint + pub fn from_imprint(imprint: &Imprint) -> ThothResult { + match ( + &imprint.s3_bucket, + &imprint.cdn_domain, + &imprint.cloudfront_dist_id, + ) { + (Some(bucket), Some(domain), Some(dist_id)) => Ok(StorageConfig { + s3_bucket: bucket.clone(), + cdn_domain: domain.clone(), + cloudfront_dist_id: dist_id.clone(), + }), + _ => Err(ThothError::InternalError( + "Imprint is not configured for file hosting".to_string(), + )), + } + } +} + +async fn load_aws_config( + access_key_id: &str, + secret_access_key: &str, + region: &str, +) -> aws_config::SdkConfig { + let credentials = aws_credential_types::Credentials::new( + access_key_id, + secret_access_key, + None, + None, + "thoth-cli", + ); + + aws_config::ConfigLoader::default() + .behavior_version(aws_config::BehaviorVersion::latest()) + .credentials_provider(credentials) + .region(aws_config::Region::new(region.to_string())) + .load() + .await +} + +/// Create an S3 client configured with explicit credentials and region. +pub async fn create_s3_client( + access_key_id: &str, + secret_access_key: &str, + region: &str, +) -> S3Client { + let config = load_aws_config(access_key_id, secret_access_key, region).await; + S3Client::new(&config) +} + +/// Create a CloudFront client configured with explicit credentials and region. +pub async fn create_cloudfront_client( + access_key_id: &str, + secret_access_key: &str, + region: &str, +) -> CloudFrontClient { + let config = load_aws_config(access_key_id, secret_access_key, region).await; + CloudFrontClient::new(&config) +} + +/// Generate a presigned PUT URL for uploading a file to S3 +/// required headers: +/// - Content-Type: from declared_mime_type +/// - x-amz-checksum-sha256: base64-encoded SHA-256 checksum +/// - x-amz-sdk-checksum-algorithm: SHA256 +pub async fn presign_put_for_upload( + s3_client: &S3Client, + bucket: &str, + temp_key: &str, + declared_mime_type: &str, + declared_sha256: &str, + expires_in_minutes: u64, +) -> ThothResult { + use base64::{engine::general_purpose, Engine as _}; + + // Convert hex SHA-256 to base64 + let sha256_bytes = hex::decode(declared_sha256) + .map_err(|e| ThothError::InternalError(format!("Invalid SHA-256 hex: {}", e)))?; + let sha256_base64 = general_purpose::STANDARD.encode(&sha256_bytes); + + let expires_in = StdDuration::from_secs(expires_in_minutes * 60); + + let presigning_config = PresigningConfig::expires_in(expires_in).map_err(|e| { + ThothError::InternalError(format!("Failed to create presigning config: {}", e)) + })?; + + let request = s3_client + .put_object() + .bucket(bucket) + .key(temp_key) + .content_type(declared_mime_type) + .checksum_sha256(sha256_base64) + .checksum_algorithm(ChecksumAlgorithm::Sha256); + + // Presign the request + let presigned_request = request + .presigned(presigning_config) + .await + .map_err(|e| ThothError::InternalError(format!("Failed to presign request: {}", e)))?; + + Ok(presigned_request.uri().to_string()) +} + +/// Copy an object from temporary upload location to final canonical location +pub async fn copy_temp_object_to_final( + s3_client: &S3Client, + bucket: &str, + temp_key: &str, + final_key: &str, +) -> ThothResult<()> { + let copy_source = format!("{}/{}", bucket, temp_key); + + s3_client + .copy_object() + .bucket(bucket) + .copy_source(copy_source) + .key(final_key) + .send() + .await + .map_err(|e| ThothError::InternalError(format!("Failed to copy object: {}", e)))?; + + Ok(()) +} + +/// Delete an object from S3 +pub async fn delete_object(s3_client: &S3Client, bucket: &str, key: &str) -> ThothResult<()> { + delete_object_with_outcome(s3_client, bucket, key) + .await + .map(|_| ()) + .map_err(|context| thoth_internal_error("Failed to delete object", &context)) +} + +/// Get object metadata (HeadObject) from S3 +pub async fn head_object( + s3_client: &S3Client, + bucket: &str, + key: &str, +) -> ThothResult<(i64, String)> { + let response = s3_client + .head_object() + .bucket(bucket) + .key(key) + .send() + .await + .map_err(|e| ThothError::InternalError(format!("Failed to head object: {}", e)))?; + + let bytes = response.content_length().unwrap_or(0); + let mime_type = response + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + + Ok((bytes, mime_type)) +} + +async fn get_object_range_bytes( + s3_client: &S3Client, + bucket: &str, + key: &str, + byte_range: &str, +) -> ThothResult> { + let response = s3_client + .get_object() + .bucket(bucket) + .key(key) + .range(byte_range) + .send() + .await + .map_err(|e| ThothError::InternalError(format!("Failed to get object range: {}", e)))?; + + let bytes = response + .body + .collect() + .await + .map_err(|e| ThothError::InternalError(format!("Failed to read object body: {}", e)))? + .into_bytes() + .to_vec(); + + Ok(bytes) +} + +fn read_u32_be(data: &[u8], offset: usize) -> Option { + let end = offset.checked_add(4)?; + let bytes: [u8; 4] = data.get(offset..end)?.try_into().ok()?; + Some(u32::from_be_bytes(bytes)) +} + +fn read_u64_be(data: &[u8], offset: usize) -> Option { + let end = offset.checked_add(8)?; + let bytes: [u8; 8] = data.get(offset..end)?.try_into().ok()?; + Some(u64::from_be_bytes(bytes)) +} + +// Parse `tkhd` atoms from ISO BMFF containers (mp4/m4v/mov) and extract width/height. +fn parse_mp4_track_header_dimensions(data: &[u8]) -> Option<(i32, i32)> { + let mut best_dimensions: Option<(i32, i32)> = None; + let mut index = 4usize; + + while index + 4 <= data.len() { + if data.get(index..index + 4) != Some(b"tkhd") { + index += 1; + continue; + } + + let Some(box_start) = index.checked_sub(4) else { + break; + }; + let Some(size32) = read_u32_be(data, box_start).map(|v| v as usize) else { + index += 1; + continue; + }; + + let (box_size, header_size) = if size32 == 1 { + let Some(large_size) = + read_u64_be(data, box_start + 8).and_then(|v| usize::try_from(v).ok()) + else { + index += 1; + continue; + }; + (large_size, 16usize) + } else if size32 == 0 { + (data.len().saturating_sub(box_start), 8usize) + } else { + (size32, 8usize) + }; + + let Some(box_end) = box_start.checked_add(box_size) else { + index += 1; + continue; + }; + if box_end > data.len() || box_size < header_size + 4 { + index += 1; + continue; + } + + let Some(version) = data.get(box_start + header_size).copied() else { + index += 1; + continue; + }; + let width_offset = match version { + 0 => header_size + 76, + 1 => header_size + 88, + _ => { + index += 1; + continue; + } + }; + + if box_start + width_offset + 8 > box_end { + index += 1; + continue; + } + + let Some(width_fixed) = read_u32_be(data, box_start + width_offset) else { + index += 1; + continue; + }; + let Some(height_fixed) = read_u32_be(data, box_start + width_offset + 4) else { + index += 1; + continue; + }; + + let width = (width_fixed >> 16) as i32; + let height = (height_fixed >> 16) as i32; + if width > 0 && height > 0 { + let replace = match best_dimensions { + Some((best_width, best_height)) => { + i64::from(width) * i64::from(height) + > i64::from(best_width) * i64::from(best_height) + } + None => true, + }; + if replace { + best_dimensions = Some((width, height)); + } + } + + index = box_end.max(index + 1); + } + + best_dimensions +} + +/// Best-effort probe of video dimensions from uploaded object bytes. +/// +/// Currently parses mp4/m4v/mov track headers. For other formats (e.g. webm) this returns `None`. +pub async fn probe_video_dimensions( + s3_client: &S3Client, + bucket: &str, + key: &str, + extension: &str, + content_length: i64, +) -> Option<(i32, i32)> { + let extension = extension.to_ascii_lowercase(); + if !matches!(extension.as_str(), "mp4" | "m4v" | "mov") || content_length <= 0 { + return None; + } + + const PROBE_RANGE_BYTES: i64 = 8 * 1024 * 1024; + + let first_chunk_end = content_length.min(PROBE_RANGE_BYTES) - 1; + if first_chunk_end >= 0 { + let range = format!("bytes=0-{first_chunk_end}"); + if let Ok(bytes) = get_object_range_bytes(s3_client, bucket, key, &range).await { + if let Some(dimensions) = parse_mp4_track_header_dimensions(&bytes) { + return Some(dimensions); + } + } + } + + if content_length > PROBE_RANGE_BYTES { + let tail_chunk_start = content_length - PROBE_RANGE_BYTES; + let range = format!("bytes={tail_chunk_start}-{}", content_length - 1); + if let Ok(bytes) = get_object_range_bytes(s3_client, bucket, key, &range).await { + if let Some(dimensions) = parse_mp4_track_header_dimensions(&bytes) { + return Some(dimensions); + } + } + } + + None +} + +/// Invalidate CloudFront cache for a given path +pub async fn invalidate_cloudfront( + cloudfront_client: &CloudFrontClient, + distribution_id: &str, + path: &str, +) -> ThothResult { + invalidate_cloudfront_with_context(cloudfront_client, distribution_id, path) + .await + .map_err(|context| thoth_internal_error("Failed to create invalidation", &context)) +} + +/// Invalidate and clean up an existing canonical object, if one exists. +/// +/// When replacing an existing object at a new key, the old object is deleted and both old and +/// new paths are invalidated. When replacing in place (same key), only the canonical path is +/// invalidated. +pub async fn reconcile_replaced_object( + s3_client: &S3Client, + cloudfront_client: &CloudFrontClient, + bucket: &str, + distribution_id: &str, + old_object_key: Option<&str>, + canonical_key: &str, +) -> ThothResult<()> { + let Some(old_key) = old_object_key else { + return Ok(()); + }; + + if old_key != canonical_key { + delete_object(s3_client, bucket, old_key).await?; + invalidate_cloudfront(cloudfront_client, distribution_id, old_key).await?; + } + + invalidate_cloudfront(cloudfront_client, distribution_id, canonical_key).await?; + Ok(()) +} + +/// Best-effort object cleanup used by parent-entity delete flows. +/// +/// This function never returns an error; instead it returns a structured outcome report. +pub async fn cleanup_object_best_effort( + s3_client: &S3Client, + cloudfront_client: &CloudFrontClient, + bucket: &str, + distribution_id: &str, + object_key: &str, +) -> CleanupObjectReport { + let delete_started = Instant::now(); + let delete_result = delete_object_with_outcome(s3_client, bucket, object_key).await; + let delete_ms = delete_started.elapsed().as_millis(); + + let delete_outcome = match delete_result { + Ok(outcome) => outcome, + Err(error) => { + return CleanupObjectReport { + outcome: CleanupObjectOutcome::Failed, + delete_outcome: CleanupObjectOutcome::Failed, + delete_ms, + invalidate_ms: None, + delete_error: Some(error), + invalidate_error: None, + }; + } + }; + + let invalidate_started = Instant::now(); + match invalidate_cloudfront_with_context(cloudfront_client, distribution_id, object_key).await { + Ok(_) => CleanupObjectReport { + outcome: delete_outcome, + delete_outcome, + delete_ms, + invalidate_ms: Some(invalidate_started.elapsed().as_millis()), + delete_error: None, + invalidate_error: None, + }, + Err(error) => CleanupObjectReport { + outcome: CleanupObjectOutcome::Failed, + delete_outcome, + delete_ms, + invalidate_ms: Some(invalidate_started.elapsed().as_millis()), + delete_error: None, + invalidate_error: Some(error), + }, + } +} + +/// Compute the temporary S3 key for an upload +pub fn temp_key(file_upload_id: &Uuid) -> String { + format!("uploads/{}", file_upload_id) +} + +/// Compute the canonical object key for a publication file +pub fn canonical_publication_key(doi_prefix: &str, doi_suffix: &str, extension: &str) -> String { + format!( + "{}/{}.{}", + doi_prefix.to_lowercase(), + doi_suffix.to_lowercase(), + extension.to_lowercase() + ) +} + +/// Compute the canonical object key for a frontcover file +pub fn canonical_frontcover_key(doi_prefix: &str, doi_suffix: &str, extension: &str) -> String { + format!( + "{}/{}_frontcover.{}", + doi_prefix.to_lowercase(), + doi_suffix.to_lowercase(), + extension.to_lowercase() + ) +} + +/// Compute the canonical object key for an additional resource or featured video file +pub fn canonical_resource_key( + doi_prefix: &str, + doi_suffix: &str, + resource_id: &Uuid, + extension: &str, +) -> String { + format!( + "{}/{}/resources/{}.{}", + doi_prefix.to_lowercase(), + doi_suffix.to_lowercase(), + resource_id, + extension.to_lowercase() + ) +} + +/// Build the full CDN URL from domain and object key +pub fn build_cdn_url(cdn_domain: &str, object_key: &str) -> String { + // Ensure cdn_domain doesn't end with / and object_key doesn't have a leading / + let domain = cdn_domain.trim_end_matches('/'); + let domain = domain + .strip_prefix("https://") + .or_else(|| domain.strip_prefix("http://")) + .unwrap_or(domain); + let key = object_key.trim_start_matches('/'); + format!("https://{}/{}", domain, key) +} + +#[cfg(test)] +mod tests; diff --git a/thoth-api/src/storage/tests.rs b/thoth-api/src/storage/tests.rs new file mode 100644 index 00000000..aa1e64e8 --- /dev/null +++ b/thoth-api/src/storage/tests.rs @@ -0,0 +1,463 @@ +use super::*; +use crate::db::PgPool; +use crate::model::{ + file::{File, FileType, NewFile, NewFileUpload}, + imprint::{Imprint, NewImprint}, + tests::db::{create_imprint, create_publication, create_publisher, create_work, setup_test_db}, + Crud, +}; +use std::time::Duration; +use thoth_errors::ThothError; +use uuid::Uuid; + +#[test] +fn storage_config_from_imprint_returns_values() { + let imprint = Imprint { + s3_bucket: Some("bucket".to_string()), + cdn_domain: Some("cdn.example.org".to_string()), + cloudfront_dist_id: Some("dist-123".to_string()), + ..Default::default() + }; + + let config = StorageConfig::from_imprint(&imprint).expect("Expected storage config"); + assert_eq!(config.s3_bucket, "bucket"); + assert_eq!(config.cdn_domain, "cdn.example.org"); + assert_eq!(config.cloudfront_dist_id, "dist-123"); +} + +#[test] +fn storage_config_from_imprint_requires_all_fields() { + let imprint = Imprint { + s3_bucket: Some("bucket".to_string()), + cdn_domain: None, + cloudfront_dist_id: Some("dist-123".to_string()), + ..Default::default() + }; + + let err = StorageConfig::from_imprint(&imprint) + .err() + .expect("Expected missing config error"); + assert_eq!( + err, + ThothError::InternalError("Imprint is not configured for file hosting".to_string()) + ); +} + +#[test] +fn temp_key_prefixes_uploads() { + let upload_id = Uuid::parse_str("6f4e7ad7-8e68-4c1e-8efc-49f7c59b0c88").unwrap(); + assert_eq!(temp_key(&upload_id), format!("uploads/{}", upload_id)); +} + +#[test] +fn canonical_publication_key_lowercases_parts() { + let key = canonical_publication_key("10.1234", "AbC/Def", "PDF"); + assert_eq!(key, "10.1234/abc/def.pdf"); +} + +#[test] +fn canonical_frontcover_key_lowercases_parts() { + let key = canonical_frontcover_key("10.1234", "AbC/Def", "PNG"); + assert_eq!(key, "10.1234/abc/def_frontcover.png"); +} + +#[test] +fn canonical_resource_key_uses_resource_subpath() { + let resource_id = Uuid::parse_str("0f97fb46-4ed2-4bc0-98dd-f2f8ce0ebe11").unwrap(); + let key = canonical_resource_key("10.1234", "AbC/Def", &resource_id, "MP4"); + assert_eq!( + key, + "10.1234/abc/def/resources/0f97fb46-4ed2-4bc0-98dd-f2f8ce0ebe11.mp4" + ); +} + +#[test] +fn build_cdn_url_normalizes_domain_and_key() { + let https_url = build_cdn_url("https://cdn.example.org/", "/files/doc.pdf"); + assert_eq!(https_url, "https://cdn.example.org/files/doc.pdf"); + + let http_url = build_cdn_url("http://cdn.example.org", "files/doc.pdf"); + assert_eq!(http_url, "https://cdn.example.org/files/doc.pdf"); +} + +fn build_tkhd_box_v0(width: u32, height: u32) -> Vec { + let mut tkhd = vec![0u8; 92]; + tkhd[0..4].copy_from_slice(&(92u32).to_be_bytes()); + tkhd[4..8].copy_from_slice(b"tkhd"); + tkhd[8] = 0; // version 0 + tkhd[9..12].copy_from_slice(&[0, 0, 7]); // flags + tkhd[84..88].copy_from_slice(&(width << 16).to_be_bytes()); + tkhd[88..92].copy_from_slice(&(height << 16).to_be_bytes()); + tkhd +} + +#[test] +fn parse_mp4_track_header_dimensions_extracts_size() { + let mut payload = vec![0u8; 32]; + payload.extend_from_slice(&build_tkhd_box_v0(1280, 720)); + + let parsed = parse_mp4_track_header_dimensions(&payload); + assert_eq!(parsed, Some((1280, 720))); +} + +#[test] +fn parse_mp4_track_header_dimensions_prefers_non_zero_video_track() { + let mut payload = build_tkhd_box_v0(0, 0); + payload.extend_from_slice(&build_tkhd_box_v0(640, 360)); + + let parsed = parse_mp4_track_header_dimensions(&payload); + assert_eq!(parsed, Some((640, 360))); +} + +const TEST_SHA256_HEX: &str = "444b138b41e3c48ca505b1740091b0c93ce9a71c7c9d24956e6cf8716f1aad7e"; + +fn create_hosting_imprint( + pool: &PgPool, + publisher: &crate::model::publisher::Publisher, +) -> Imprint { + Imprint::create( + pool, + &NewImprint { + publisher_id: publisher.publisher_id, + imprint_name: format!("Hosting Imprint {}", Uuid::new_v4()), + imprint_url: None, + crossmark_doi: None, + s3_bucket: Some("bucket-example".to_string()), + cdn_domain: Some("cdn.example.org".to_string()), + cloudfront_dist_id: Some("dist-example".to_string()), + default_currency: None, + default_place: None, + default_locale: None, + }, + ) + .expect("Failed to create hosting imprint") +} + +fn create_additional_resource( + pool: &PgPool, + work_id: Uuid, +) -> crate::model::additional_resource::AdditionalResource { + use crate::model::additional_resource::{ + AdditionalResource, NewAdditionalResource, ResourceType, + }; + + AdditionalResource::create( + pool, + &NewAdditionalResource { + work_id, + title: format!("Resource {}", Uuid::new_v4()), + description: None, + attribution: None, + resource_type: ResourceType::Dataset, + doi: None, + handle: None, + url: None, + date: None, + resource_ordinal: 1, + }, + ) + .expect("Failed to create additional resource") +} + +fn create_work_featured_video( + pool: &PgPool, + work_id: Uuid, +) -> crate::model::work_featured_video::WorkFeaturedVideo { + use crate::model::work_featured_video::{NewWorkFeaturedVideo, WorkFeaturedVideo}; + + WorkFeaturedVideo::create( + pool, + &NewWorkFeaturedVideo { + work_id, + title: Some("Featured video".to_string()), + url: None, + width: 560, + height: 315, + }, + ) + .expect("Failed to create featured video") +} + +#[test] +fn work_cleanup_plan_returns_none_without_candidates() { + let (_guard, pool) = setup_test_db(); + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let plan = work_cleanup_plan(pool.as_ref(), &work).expect("Failed to build cleanup plan"); + assert!(plan.is_none()); +} + +#[test] +fn publication_cleanup_plan_returns_none_without_candidates() { + let (_guard, pool) = setup_test_db(); + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let plan = publication_cleanup_plan(pool.as_ref(), &publication) + .expect("Failed to build publication cleanup plan"); + assert!(plan.is_none()); +} + +#[test] +fn work_cleanup_plan_includes_storage_config_and_candidates() { + let (_guard, pool) = setup_test_db(); + let publisher = create_publisher(pool.as_ref()); + let imprint = create_hosting_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + + let object_key = format!("10.1234/{}/cover.jpg", Uuid::new_v4()); + File::create( + pool.as_ref(), + &NewFile { + file_type: FileType::Frontcover, + work_id: Some(work.work_id), + publication_id: None, + additional_resource_id: None, + work_featured_video_id: None, + object_key: object_key.clone(), + cdn_url: format!("https://cdn.example.org/{object_key}"), + mime_type: "image/jpeg".to_string(), + bytes: 1024, + sha256: TEST_SHA256_HEX.to_string(), + }, + ) + .expect("Failed to create file"); + let pending_upload = crate::model::file::FileUpload::create( + pool.as_ref(), + &NewFileUpload { + file_type: FileType::Frontcover, + work_id: Some(work.work_id), + publication_id: None, + additional_resource_id: None, + work_featured_video_id: None, + declared_mime_type: "image/jpeg".to_string(), + declared_extension: "jpg".to_string(), + declared_sha256: TEST_SHA256_HEX.to_string(), + }, + ) + .expect("Failed to create pending upload"); + + let plan = work_cleanup_plan(pool.as_ref(), &work) + .expect("Failed to build work cleanup plan") + .expect("Expected cleanup plan"); + + assert_eq!(plan.entity_type, "work"); + assert_eq!(plan.entity_id, work.work_id); + assert_eq!(plan.storage_config.s3_bucket, "bucket-example"); + assert_eq!(plan.storage_config.cloudfront_dist_id, "dist-example"); + assert_eq!(plan.candidates.len(), 2); + assert!(plan + .candidates + .iter() + .any(|candidate| candidate.object_key == object_key)); + assert!(plan + .candidates + .iter() + .any(|candidate| candidate.object_key == temp_key(&pending_upload.file_upload_id))); +} + +#[test] +fn publication_cleanup_plan_includes_pending_upload_candidate() { + let (_guard, pool) = setup_test_db(); + let publisher = create_publisher(pool.as_ref()); + let imprint = create_hosting_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_publication(pool.as_ref(), &work); + + let pending_upload = crate::model::file::FileUpload::create( + pool.as_ref(), + &NewFileUpload { + file_type: FileType::Publication, + work_id: None, + publication_id: Some(publication.publication_id), + additional_resource_id: None, + work_featured_video_id: None, + declared_mime_type: "application/pdf".to_string(), + declared_extension: "pdf".to_string(), + declared_sha256: TEST_SHA256_HEX.to_string(), + }, + ) + .expect("Failed to create pending upload"); + + let plan = publication_cleanup_plan(pool.as_ref(), &publication) + .expect("Failed to build publication cleanup plan") + .expect("Expected cleanup plan"); + + assert_eq!(plan.entity_type, "publication"); + assert_eq!(plan.entity_id, publication.publication_id); + assert_eq!(plan.storage_config.s3_bucket, "bucket-example"); + assert_eq!(plan.storage_config.cloudfront_dist_id, "dist-example"); + assert_eq!(plan.candidates.len(), 1); + assert_eq!( + plan.candidates[0].object_key, + temp_key(&pending_upload.file_upload_id) + ); +} + +#[test] +fn additional_resource_cleanup_plan_includes_pending_upload_candidate() { + let (_guard, pool) = setup_test_db(); + let publisher = create_publisher(pool.as_ref()); + let imprint = create_hosting_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let additional_resource = create_additional_resource(pool.as_ref(), work.work_id); + + let pending_upload = crate::model::file::FileUpload::create( + pool.as_ref(), + &NewFileUpload { + file_type: FileType::AdditionalResource, + work_id: None, + publication_id: None, + additional_resource_id: Some(additional_resource.additional_resource_id), + work_featured_video_id: None, + declared_mime_type: "application/json".to_string(), + declared_extension: "json".to_string(), + declared_sha256: TEST_SHA256_HEX.to_string(), + }, + ) + .expect("Failed to create pending additional-resource upload"); + + let plan = additional_resource_cleanup_plan(pool.as_ref(), &additional_resource) + .expect("Failed to build additional-resource cleanup plan") + .expect("Expected cleanup plan"); + + assert_eq!(plan.entity_type, "additional_resource"); + assert_eq!(plan.entity_id, additional_resource.additional_resource_id); + assert_eq!(plan.storage_config.s3_bucket, "bucket-example"); + assert_eq!(plan.storage_config.cloudfront_dist_id, "dist-example"); + assert_eq!(plan.candidates.len(), 1); + assert_eq!( + plan.candidates[0].object_key, + temp_key(&pending_upload.file_upload_id) + ); +} + +#[test] +fn work_featured_video_cleanup_plan_includes_pending_upload_candidate() { + let (_guard, pool) = setup_test_db(); + let publisher = create_publisher(pool.as_ref()); + let imprint = create_hosting_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let featured_video = create_work_featured_video(pool.as_ref(), work.work_id); + + let pending_upload = crate::model::file::FileUpload::create( + pool.as_ref(), + &NewFileUpload { + file_type: FileType::WorkFeaturedVideo, + work_id: None, + publication_id: None, + additional_resource_id: None, + work_featured_video_id: Some(featured_video.work_featured_video_id), + declared_mime_type: "video/mp4".to_string(), + declared_extension: "mp4".to_string(), + declared_sha256: TEST_SHA256_HEX.to_string(), + }, + ) + .expect("Failed to create pending featured-video upload"); + + let plan = work_featured_video_cleanup_plan(pool.as_ref(), &featured_video) + .expect("Failed to build featured-video cleanup plan") + .expect("Expected cleanup plan"); + + assert_eq!(plan.entity_type, "work_featured_video"); + assert_eq!(plan.entity_id, featured_video.work_featured_video_id); + assert_eq!(plan.storage_config.s3_bucket, "bucket-example"); + assert_eq!(plan.storage_config.cloudfront_dist_id, "dist-example"); + assert_eq!(plan.candidates.len(), 1); + assert_eq!( + plan.candidates[0].object_key, + temp_key(&pending_upload.file_upload_id) + ); +} + +#[test] +fn run_cleanup_plan_sync_is_noop_for_empty_candidates() { + let runtime = tokio::runtime::Runtime::new().expect("Failed to build Tokio runtime"); + let s3_client = runtime.block_on(create_s3_client( + "test-access-key", + "test-secret-key", + "us-east-1", + )); + let cloudfront_client = runtime.block_on(create_cloudfront_client( + "test-access-key", + "test-secret-key", + "us-east-1", + )); + + run_cleanup_plan_sync( + &s3_client, + &cloudfront_client, + FileCleanupPlan { + entity_type: "work", + entity_id: Uuid::new_v4(), + storage_config: StorageConfig { + s3_bucket: "bucket-example".to_string(), + cdn_domain: "cdn.example.org".to_string(), + cloudfront_dist_id: "dist-example".to_string(), + }, + candidates: vec![], + }, + ); +} + +fn build_aws_error_context(code: Option<&str>, http_status: Option) -> AwsErrorContext { + AwsErrorContext { + code: code.map(ToOwned::to_owned), + message: Some("test error".to_string()), + http_status, + request_id: Some("req-id".to_string()), + extended_request_id: Some("ext-id".to_string()), + retryable_classification: "service_error", + } +} + +#[test] +fn classify_delete_error_marks_missing_object_as_absent() { + let missing_key = build_aws_error_context(Some("NoSuchKey"), None); + assert_eq!( + classify_delete_error(&missing_key), + CleanupObjectOutcome::AlreadyAbsent + ); + + let not_found = build_aws_error_context(None, Some(404)); + assert_eq!( + classify_delete_error(¬_found), + CleanupObjectOutcome::AlreadyAbsent + ); +} + +#[test] +fn classify_delete_error_marks_other_service_errors_as_failed() { + let access_denied = build_aws_error_context(Some("AccessDenied"), Some(403)); + assert_eq!( + classify_delete_error(&access_denied), + CleanupObjectOutcome::Failed + ); +} + +#[test] +fn cleanup_counters_track_deleted_absent_and_failed_results() { + let mut counters = super::cleanup::CleanupCounters::default(); + counters.record(CleanupObjectOutcome::Deleted); + counters.record(CleanupObjectOutcome::AlreadyAbsent); + counters.record(CleanupObjectOutcome::Failed); + counters.record(CleanupObjectOutcome::Deleted); + + assert_eq!(counters.keys_total, 4); + assert_eq!(counters.keys_deleted, 2); + assert_eq!(counters.keys_absent, 1); + assert_eq!(counters.keys_failed, 1); +} + +#[test] +fn duration_ms_helper_reports_milliseconds() { + assert_eq!( + super::cleanup::duration_ms(Duration::from_millis(1500)), + 1500 + ); + assert_eq!(super::cleanup::duration_ms(Duration::from_secs(2)), 2000); +} diff --git a/thoth-api/tests/graphql_permissions.rs b/thoth-api/tests/graphql_permissions.rs new file mode 100644 index 00000000..9334da79 --- /dev/null +++ b/thoth-api/tests/graphql_permissions.rs @@ -0,0 +1,687 @@ +#![cfg(feature = "backend")] + +mod support; + +use std::sync::Arc; + +use serde_json::json; +use thoth_api::db::PgPool; +use uuid::Uuid; + +struct CreatedImprint { + imprint_id: Uuid, + imprint_name: String, +} + +async fn create_publisher(pool: Arc, org_id: &str) -> Uuid { + let name = format!("Test Publisher {}", Uuid::new_v4()); + let query = r#" +mutation($data: NewPublisher!) { + createPublisher(data: $data) { + publisherId + publisherName + zitadelId + } +} +"#; + + let variables = json!({ + "data": { + "publisherName": name, + "zitadelId": org_id, + } + }); + + let response = support::execute_graphql( + pool, + Some(support::superuser("superuser-1")), + query, + Some(variables), + ) + .await; + support::assert_no_errors(&response); + + let id = response + .pointer("/data/createPublisher/publisherId") + .and_then(|v| v.as_str()) + .expect("Missing publisherId in response"); + Uuid::parse_str(id).expect("Invalid publisherId") +} + +async fn create_imprint_record( + pool: Arc, + publisher_id: Uuid, + s3_bucket: Option<&str>, + cdn_domain: Option<&str>, + cloudfront_dist_id: Option<&str>, +) -> CreatedImprint { + let name = format!("Test Imprint {}", Uuid::new_v4()); + let query = r#" +mutation($data: NewImprint!) { + createImprint(data: $data) { + imprintId + imprintName + } +} +"#; + + let variables = json!({ + "data": { + "publisherId": publisher_id, + "imprintName": name, + "s3Bucket": s3_bucket, + "cdnDomain": cdn_domain, + "cloudfrontDistId": cloudfront_dist_id, + } + }); + + let response = support::execute_graphql( + pool, + Some(support::superuser("superuser-1")), + query, + Some(variables), + ) + .await; + support::assert_no_errors(&response); + + let id = response + .pointer("/data/createImprint/imprintId") + .and_then(|v| v.as_str()) + .expect("Missing imprintId in response"); + + CreatedImprint { + imprint_id: Uuid::parse_str(id).expect("Invalid imprintId"), + imprint_name: response + .pointer("/data/createImprint/imprintName") + .and_then(|v| v.as_str()) + .expect("Missing imprintName in response") + .to_string(), + } +} + +async fn create_imprint(pool: Arc, publisher_id: Uuid) -> Uuid { + create_imprint_record(pool, publisher_id, None, None, None) + .await + .imprint_id +} + +async fn create_work(pool: Arc, imprint_id: Uuid) -> Uuid { + let query = r#" +mutation($data: NewWork!) { + createWork(data: $data) { + workId + workStatus + } +} +"#; + + let variables = json!({ + "data": { + "workType": "MONOGRAPH", + "workStatus": "FORTHCOMING", + "edition": 1, + "imprintId": imprint_id, + } + }); + + let response = support::execute_graphql( + pool, + Some(support::superuser("superuser-1")), + query, + Some(variables), + ) + .await; + support::assert_no_errors(&response); + + let id = response + .pointer("/data/createWork/workId") + .and_then(|v| v.as_str()) + .expect("Missing workId in response"); + Uuid::parse_str(id).expect("Invalid workId") +} + +#[tokio::test(flavor = "current_thread")] +async fn test_me_requires_auth() { + let _guard = support::test_lock(); + let pool = support::db_pool(); + support::reset_db(&pool).expect("Failed to reset DB"); + + let query = "query { me { userId } }"; + let response = support::execute_graphql(pool, None, query, None).await; + + support::assert_no_access(&response); +} + +#[tokio::test(flavor = "current_thread")] +async fn test_me_publisher_contexts() { + let _guard = support::test_lock(); + let pool = support::db_pool(); + support::reset_db(&pool).expect("Failed to reset DB"); + + let org_id = format!("org-{}", Uuid::new_v4()); + let _publisher_id = create_publisher(pool.clone(), org_id.as_str()).await; + + let user = support::user_with_roles( + "user-1", + &[ + ("PUBLISHER_ADMIN", org_id.as_str()), + ("WORK_LIFECYCLE", org_id.as_str()), + ], + ); + + let query = r#" +query { + me { + userId + isSuperuser + publisherContexts { + publisher { publisherId publisherName zitadelId } + permissions { publisherAdmin workLifecycle cdnWrite } + } + } +} +"#; + + let response = support::execute_graphql(pool, Some(user), query, None).await; + support::assert_no_errors(&response); + + let contexts = response + .pointer("/data/me/publisherContexts") + .and_then(|v| v.as_array()) + .expect("Missing publisherContexts"); + assert_eq!(contexts.len(), 1); + + let permissions = &contexts[0]["permissions"]; + assert_eq!(permissions["publisherAdmin"].as_bool(), Some(true)); + assert_eq!(permissions["workLifecycle"].as_bool(), Some(true)); + assert_eq!(permissions["cdnWrite"].as_bool(), Some(false)); + + let publisher = &contexts[0]["publisher"]; + let zitadel_id = publisher["zitadelId"].as_str().expect("Missing zitadelId"); + assert_eq!(zitadel_id, org_id.as_str()); +} + +#[tokio::test(flavor = "current_thread")] +async fn test_create_publisher_requires_superuser() { + let _guard = support::test_lock(); + let pool = support::db_pool(); + support::reset_db(&pool).expect("Failed to reset DB"); + + let query = r#" +mutation($data: NewPublisher!) { + createPublisher(data: $data) { + publisherId + } +} +"#; + + let variables = json!({ + "data": { + "publisherName": "Nope Publisher", + "zitadelId": "org-1", + } + }); + + let user = support::user_with_roles("user-1", &[]); + let response = support::execute_graphql(pool, Some(user), query, Some(variables)).await; + + support::assert_no_access(&response); +} + +#[tokio::test(flavor = "current_thread")] +async fn test_create_work_allows_publisher_user() { + let _guard = support::test_lock(); + let pool = support::db_pool(); + support::reset_db(&pool).expect("Failed to reset DB"); + + let org_id = format!("org-{}", Uuid::new_v4()); + let publisher_id = create_publisher(pool.clone(), org_id.as_str()).await; + let imprint_id = create_imprint(pool.clone(), publisher_id).await; + + let query = r#" +mutation($data: NewWork!) { + createWork(data: $data) { + workId + workStatus + imprintId + } +} +"#; + + let variables = json!({ + "data": { + "workType": "MONOGRAPH", + "workStatus": "FORTHCOMING", + "edition": 1, + "imprintId": imprint_id, + } + }); + + let user = support::user_with_roles("user-1", &[("PUBLISHER_USER", org_id.as_str())]); + let response = support::execute_graphql(pool, Some(user), query, Some(variables)).await; + support::assert_no_errors(&response); + + let imprint = response + .pointer("/data/createWork/imprintId") + .and_then(|v| v.as_str()) + .expect("Missing imprintId"); + assert_eq!(imprint, imprint_id.to_string()); + + let status = response + .pointer("/data/createWork/workStatus") + .and_then(|v| v.as_str()) + .expect("Missing workStatus"); + assert_eq!(status, "FORTHCOMING"); +} + +#[tokio::test(flavor = "current_thread")] +async fn test_update_work_requires_work_lifecycle() { + let _guard = support::test_lock(); + let pool = support::db_pool(); + support::reset_db(&pool).expect("Failed to reset DB"); + + let org_id = format!("org-{}", Uuid::new_v4()); + let publisher_id = create_publisher(pool.clone(), org_id.as_str()).await; + let imprint_id = create_imprint(pool.clone(), publisher_id).await; + let work_id = create_work(pool.clone(), imprint_id).await; + + let query = r#" +mutation($data: PatchWork!) { + updateWork(data: $data) { + workId + workStatus + publicationDate + } +} +"#; + + let variables = json!({ + "data": { + "workId": work_id, + "workType": "MONOGRAPH", + "workStatus": "ACTIVE", + "edition": 1, + "imprintId": imprint_id, + "publicationDate": "2020-01-01" + } + }); + + let user = support::user_with_roles("user-1", &[("PUBLISHER_USER", org_id.as_str())]); + let response = support::execute_graphql(pool, Some(user), query, Some(variables)).await; + + support::assert_no_access(&response); +} + +#[tokio::test(flavor = "current_thread")] +async fn test_update_work_allows_work_lifecycle() { + let _guard = support::test_lock(); + let pool = support::db_pool(); + support::reset_db(&pool).expect("Failed to reset DB"); + + let org_id = format!("org-{}", Uuid::new_v4()); + let publisher_id = create_publisher(pool.clone(), org_id.as_str()).await; + let imprint_id = create_imprint(pool.clone(), publisher_id).await; + let work_id = create_work(pool.clone(), imprint_id).await; + + let query = r#" +mutation($data: PatchWork!) { + updateWork(data: $data) { + workId + workStatus + publicationDate + } +} +"#; + + let variables = json!({ + "data": { + "workId": work_id, + "workType": "MONOGRAPH", + "workStatus": "ACTIVE", + "edition": 1, + "imprintId": imprint_id, + "publicationDate": "2020-01-01" + } + }); + + let user = support::user_with_roles( + "user-1", + &[ + ("PUBLISHER_USER", org_id.as_str()), + ("WORK_LIFECYCLE", org_id.as_str()), + ], + ); + let response = support::execute_graphql(pool, Some(user), query, Some(variables)).await; + support::assert_no_errors(&response); + + let status = response + .pointer("/data/updateWork/workStatus") + .and_then(|v| v.as_str()) + .expect("Missing workStatus"); + assert_eq!(status, "ACTIVE"); + + let publication_date = response + .pointer("/data/updateWork/publicationDate") + .and_then(|v| v.as_str()) + .expect("Missing publicationDate"); + assert_eq!(publication_date, "2020-01-01"); +} + +#[tokio::test(flavor = "current_thread")] +async fn test_create_imprint_storage_fields_requires_superuser() { + let _guard = support::test_lock(); + let pool = support::db_pool(); + support::reset_db(&pool).expect("Failed to reset DB"); + + let org_id = format!("org-{}", Uuid::new_v4()); + let publisher_id = create_publisher(pool.clone(), org_id.as_str()).await; + + let query = r#" +mutation($data: NewImprint!) { + createImprint(data: $data) { + imprintId + } +} +"#; + + let variables = json!({ + "data": { + "publisherId": publisher_id, + "imprintName": format!("Restricted Imprint {}", Uuid::new_v4()), + "s3Bucket": "bucket-create", + "cdnDomain": "create.example.org", + "cloudfrontDistId": "dist-create", + } + }); + let response = support::execute_graphql( + pool, + Some(support::user_with_roles( + "user-1", + &[("PUBLISHER_USER", org_id.as_str())], + )), + query, + Some(variables), + ) + .await; + + support::assert_no_access(&response); +} + +#[tokio::test(flavor = "current_thread")] +async fn test_imprint_storage_fields_superuser_query_returns_nulls_when_unset() { + let _guard = support::test_lock(); + let pool = support::db_pool(); + support::reset_db(&pool).expect("Failed to reset DB"); + + let org_id = format!("org-{}", Uuid::new_v4()); + let publisher_id = create_publisher(pool.clone(), org_id.as_str()).await; + let imprint = create_imprint_record(pool.clone(), publisher_id, None, None, None).await; + + let query = r#" +query($imprintId: Uuid!) { + imprint(imprintId: $imprintId) { + imprintId + s3Bucket + cdnDomain + cloudfrontDistId + } +} +"#; + + let variables = json!({ "imprintId": imprint.imprint_id }); + let response = support::execute_graphql( + pool, + Some(support::superuser("superuser-1")), + query, + Some(variables), + ) + .await; + support::assert_no_errors(&response); + + let imprint_data = response + .pointer("/data/imprint") + .expect("Missing imprint in response"); + assert!(imprint_data["s3Bucket"].is_null()); + assert!(imprint_data["cdnDomain"].is_null()); + assert!(imprint_data["cloudfrontDistId"].is_null()); +} + +#[tokio::test(flavor = "current_thread")] +async fn test_imprint_storage_fields_require_superuser() { + let _guard = support::test_lock(); + let pool = support::db_pool(); + support::reset_db(&pool).expect("Failed to reset DB"); + + let org_id = format!("org-{}", Uuid::new_v4()); + let publisher_id = create_publisher(pool.clone(), org_id.as_str()).await; + let imprint = create_imprint_record( + pool.clone(), + publisher_id, + Some("bucket-example"), + Some("cdn.example.org"), + Some("dist-example"), + ) + .await; + + let query = r#" +query($imprintId: Uuid!) { + imprint(imprintId: $imprintId) { + imprintId + s3Bucket + cdnDomain + cloudfrontDistId + } +} +"#; + + let imprint_id = imprint.imprint_id.to_string(); + let variables = json!({ "imprintId": imprint.imprint_id }); + let cases = vec![ + ( + "publisher admin", + Some(support::user_with_roles( + "user-admin", + &[("PUBLISHER_ADMIN", org_id.as_str())], + )), + ), + ( + "publisher user", + Some(support::user_with_roles( + "user-basic", + &[("PUBLISHER_USER", org_id.as_str())], + )), + ), + ("anonymous", None), + ]; + + for (label, user) in cases { + let response = + support::execute_graphql(pool.clone(), user, query, Some(variables.clone())).await; + support::assert_no_access(&response); + + assert_eq!( + response + .pointer("/data/imprint/imprintId") + .and_then(|v| v.as_str()), + Some(imprint_id.as_str()), + "Expected imprint data to remain visible for {label}" + ); + assert!( + response + .pointer("/data/imprint/s3Bucket") + .is_some_and(|v| v.is_null()), + "Expected null s3Bucket for {label}, got: {response:?}" + ); + assert!( + response + .pointer("/data/imprint/cdnDomain") + .is_some_and(|v| v.is_null()), + "Expected null cdnDomain for {label}, got: {response:?}" + ); + assert!( + response + .pointer("/data/imprint/cloudfrontDistId") + .is_some_and(|v| v.is_null()), + "Expected null cloudfrontDistId for {label}, got: {response:?}" + ); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn test_imprint_query_without_storage_fields_still_works_for_non_superuser() { + let _guard = support::test_lock(); + let pool = support::db_pool(); + support::reset_db(&pool).expect("Failed to reset DB"); + + let org_id = format!("org-{}", Uuid::new_v4()); + let publisher_id = create_publisher(pool.clone(), org_id.as_str()).await; + let imprint = create_imprint_record(pool.clone(), publisher_id, None, None, None).await; + + let query = r#" +query($imprintId: Uuid!) { + imprint(imprintId: $imprintId) { + imprintId + imprintName + } +} +"#; + + let variables = json!({ "imprintId": imprint.imprint_id }); + let user = support::user_with_roles("user-1", &[("PUBLISHER_USER", org_id.as_str())]); + let response = support::execute_graphql(pool, Some(user), query, Some(variables)).await; + + support::assert_no_errors(&response); + let imprint_id = imprint.imprint_id.to_string(); + assert_eq!( + response + .pointer("/data/imprint/imprintId") + .and_then(|v| v.as_str()), + Some(imprint_id.as_str()) + ); + assert_eq!( + response + .pointer("/data/imprint/imprintName") + .and_then(|v| v.as_str()), + Some(imprint.imprint_name.as_str()) + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn test_update_imprint_storage_fields_requires_superuser() { + let _guard = support::test_lock(); + let pool = support::db_pool(); + support::reset_db(&pool).expect("Failed to reset DB"); + + let org_id = format!("org-{}", Uuid::new_v4()); + let publisher_id = create_publisher(pool.clone(), org_id.as_str()).await; + let imprint = create_imprint_record(pool.clone(), publisher_id, None, None, None).await; + + let update_query = r#" +mutation($data: PatchImprint!) { + updateImprint(data: $data) { + imprintId + } +} +"#; + + let variables = json!({ + "data": { + "imprintId": imprint.imprint_id, + "publisherId": publisher_id, + "imprintName": imprint.imprint_name, + "s3Bucket": "bucket-restricted", + "cdnDomain": "restricted.example.org", + "cloudfrontDistId": "dist-restricted", + } + }); + let response = support::execute_graphql( + pool, + Some(support::user_with_roles( + "user-admin", + &[("PUBLISHER_ADMIN", org_id.as_str())], + )), + update_query, + Some(variables), + ) + .await; + + support::assert_no_access(&response); +} + +#[tokio::test(flavor = "current_thread")] +async fn test_update_imprint_storage_fields_round_trip_for_superuser() { + let _guard = support::test_lock(); + let pool = support::db_pool(); + support::reset_db(&pool).expect("Failed to reset DB"); + + let org_id = format!("org-{}", Uuid::new_v4()); + let publisher_id = create_publisher(pool.clone(), org_id.as_str()).await; + let imprint = create_imprint_record(pool.clone(), publisher_id, None, None, None).await; + + let update_query = r#" +mutation($data: PatchImprint!) { + updateImprint(data: $data) { + imprintId + } +} +"#; + + let imprint_name = imprint.imprint_name.clone(); + let variables = json!({ + "data": { + "imprintId": imprint.imprint_id, + "publisherId": publisher_id, + "imprintName": imprint_name, + "s3Bucket": "bucket-roundtrip", + "cdnDomain": "roundtrip.example.org", + "cloudfrontDistId": "dist-roundtrip", + } + }); + let update_response = support::execute_graphql( + pool.clone(), + Some(support::superuser("superuser-1")), + update_query, + Some(variables), + ) + .await; + support::assert_no_errors(&update_response); + + let read_query = r#" +query($imprintId: Uuid!) { + imprint(imprintId: $imprintId) { + s3Bucket + cdnDomain + cloudfrontDistId + } +} +"#; + + let read_variables = json!({ "imprintId": imprint.imprint_id }); + let read_response = support::execute_graphql( + pool, + Some(support::superuser("superuser-1")), + read_query, + Some(read_variables), + ) + .await; + support::assert_no_errors(&read_response); + + assert_eq!( + read_response + .pointer("/data/imprint/s3Bucket") + .and_then(|v| v.as_str()), + Some("bucket-roundtrip") + ); + assert_eq!( + read_response + .pointer("/data/imprint/cdnDomain") + .and_then(|v| v.as_str()), + Some("roundtrip.example.org") + ); + assert_eq!( + read_response + .pointer("/data/imprint/cloudfrontDistId") + .and_then(|v| v.as_str()), + Some("dist-roundtrip") + ); +} diff --git a/thoth-api/tests/support/mod.rs b/thoth-api/tests/support/mod.rs new file mode 100644 index 00000000..42440fbf --- /dev/null +++ b/thoth-api/tests/support/mod.rs @@ -0,0 +1,202 @@ +#![cfg(feature = "backend")] + +use std::collections::HashMap; +use std::env; +use std::fs::OpenOptions; +use std::sync::{Arc, OnceLock}; + +use diesel::RunQueryDsl; +use fs2::FileExt; +use serde_json::Value; +use thoth_api::db::{init_pool, run_migrations, PgPool}; +use thoth_api::graphql::{create_schema, Context, GraphQLRequest}; +use thoth_api::storage::{create_cloudfront_client, create_s3_client, CloudFrontClient, S3Client}; +use zitadel::actix::introspection::IntrospectedUser; + +static MIGRATIONS: OnceLock> = OnceLock::new(); +static POOL: OnceLock> = OnceLock::new(); +static CLIENTS: OnceLock<(Arc, Arc)> = OnceLock::new(); + +pub struct TestDbGuard { + _file: std::fs::File, +} + +pub fn test_lock() -> TestDbGuard { + let mut path = env::temp_dir(); + path.push("thoth_test_db.lock"); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .unwrap_or_else(|err| panic!("Failed to open lock file {path:?}: {err}")); + file.lock_exclusive() + .unwrap_or_else(|err| panic!("Failed to lock test DB file {path:?}: {err}")); + TestDbGuard { _file: file } +} + +pub fn test_db_url() -> String { + dotenv::dotenv().ok(); + env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set for backend tests") +} + +pub fn db_pool() -> Arc { + let url = test_db_url(); + let migrations = MIGRATIONS + .get_or_init(|| run_migrations(&url).map_err(|err| err.to_string())) + .clone(); + migrations.expect("Failed to run migrations for test DB"); + let pool = POOL.get_or_init(|| Arc::new(init_pool(&url))); + pool.clone() +} + +fn test_clients() -> (Arc, Arc) { + let (s3_client, cloudfront_client) = CLIENTS.get_or_init(|| { + std::thread::spawn(|| { + let runtime = tokio::runtime::Runtime::new().expect("Failed to build Tokio runtime"); + runtime.block_on(async { + let s3 = create_s3_client("test-access-key", "test-secret-key", "us-east-1").await; + let cloudfront = + create_cloudfront_client("test-access-key", "test-secret-key", "us-east-1") + .await; + (Arc::new(s3), Arc::new(cloudfront)) + }) + }) + .join() + .expect("Failed to initialize AWS clients") + }); + (Arc::clone(s3_client), Arc::clone(cloudfront_client)) +} + +pub fn reset_db(pool: &PgPool) -> Result<(), diesel::result::Error> { + let mut connection = pool.get().expect("Failed to get DB connection"); + let sql = r#" +DO $$ +DECLARE + tbls TEXT; +BEGIN + SELECT string_agg(format('%I.%I', schemaname, tablename), ', ') + INTO tbls + FROM pg_tables + WHERE schemaname = 'public' + AND tablename != '__diesel_schema_migrations'; + + IF tbls IS NOT NULL THEN + EXECUTE 'TRUNCATE TABLE ' || tbls || ' RESTART IDENTITY CASCADE'; + END IF; +END $$; +"#; + diesel::sql_query(sql).execute(&mut connection).map(|_| ()) +} + +pub async fn execute_graphql( + pool: Arc, + user: Option, + query: &str, + variables: Option, +) -> Value { + let schema = create_schema(); + let (s3_client, cloudfront_client) = test_clients(); + let ctx = Context::new(pool, user, s3_client, cloudfront_client); + + let request_json = match variables { + Some(vars) => serde_json::json!({ "query": query, "variables": vars }), + None => serde_json::json!({ "query": query }), + }; + + let request: GraphQLRequest = + serde_json::from_value(request_json).expect("Failed to build GraphQL request"); + let response = request.execute(&schema, &ctx).await; + serde_json::to_value(response).expect("Failed to serialize GraphQL response") +} + +fn build_user( + user_id: &str, + project_roles: Option>>, +) -> IntrospectedUser { + IntrospectedUser { + user_id: user_id.to_string(), + username: None, + name: None, + given_name: None, + family_name: None, + preferred_username: None, + email: None, + email_verified: None, + locale: None, + project_roles, + metadata: None, + } +} + +pub fn user_with_roles(user_id: &str, role_scopes: &[(&str, &str)]) -> IntrospectedUser { + let mut roles: HashMap> = HashMap::new(); + for (role, org_id) in role_scopes { + roles + .entry((*role).to_string()) + .or_default() + .insert((*org_id).to_string(), "label".to_string()); + } + + build_user(user_id, if roles.is_empty() { None } else { Some(roles) }) +} + +pub fn superuser(user_id: &str) -> IntrospectedUser { + let mut roles: HashMap> = HashMap::new(); + roles.insert("SUPERUSER".to_string(), HashMap::new()); + build_user(user_id, Some(roles)) +} + +pub fn assert_no_errors(response: &Value) { + match response.get("errors") { + None => {} + Some(Value::Null) => {} + Some(Value::Array(errors)) => { + assert!(errors.is_empty(), "Expected no errors, got: {errors:?}"); + } + Some(other) => panic!("Unexpected errors shape: {other:?}"), + } +} + +pub fn first_error_type(response: &Value) -> Option<&str> { + response + .get("errors")? + .as_array()? + .first()? + .get("extensions")? + .get("type")? + .as_str() +} + +pub fn first_error_message(response: &Value) -> Option<&str> { + response + .get("errors")? + .as_array()? + .first()? + .get("message")? + .as_str() +} + +pub fn assert_no_access(response: &Value) { + let Some(errors) = response.get("errors").and_then(|v| v.as_array()) else { + panic!("Expected GraphQL errors, got: {response:?}"); + }; + if errors.is_empty() { + panic!("Expected GraphQL errors, got: {response:?}"); + } + + let error_type = first_error_type(response); + let message = first_error_message(response); + + if error_type == Some("NO_ACCESS") + || message == Some("Unauthorized") + || message == Some("Invalid credentials.") + { + return; + } + + panic!( + "Expected NO_ACCESS/Unauthorized error, got type={error_type:?} message={message:?} response={response:?}" + ); +} diff --git a/thoth-app-server/Cargo.toml b/thoth-app-server/Cargo.toml deleted file mode 100644 index 4049180d..00000000 --- a/thoth-app-server/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "thoth-app-server" -version = "0.13.16" -authors = ["Javier Arias ", "Ross Higman "] -edition = "2021" -license = "Apache-2.0" -description = "Actix instance serving Thoth's WASM GUI statically" -repository = "https://github.com/thoth-pub/thoth" -readme = "README.md" -build = "build.rs" - -[dependencies] -actix-web = "4.10" -actix-cors = "0.7.1" -env_logger = "0.11.7" - -[build-dependencies] -dotenv = "0.15.0" diff --git a/thoth-app-server/LICENSE b/thoth-app-server/LICENSE deleted file mode 100644 index 5194de71..00000000 --- a/thoth-app-server/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2020 Thoth Open Metadata - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/thoth-app-server/README.md b/thoth-app-server/README.md deleted file mode 100644 index 96f13746..00000000 --- a/thoth-app-server/README.md +++ /dev/null @@ -1,17 +0,0 @@ -
- - -

Thoth Client

- -

- Web server for Thoth's, metadata management and dissemination system, WASM GUI -

- -

- GitHub Workflow - Thoth Releases - Crate Info - License Info -

-
- diff --git a/thoth-app-server/build.rs b/thoth-app-server/build.rs deleted file mode 100644 index a5a64305..00000000 --- a/thoth-app-server/build.rs +++ /dev/null @@ -1,105 +0,0 @@ -use dotenv::dotenv; -use std::env; -use std::process::{exit, Command}; - -const TRUNK_VERSION: &str = "0.21.9"; - -fn is_wasm_target_installed() -> bool { - let output = Command::new("rustup") - .args(["target", "list", "--installed"]) - .output() - .expect("Failed to execute rustup"); - - let installed_targets = String::from_utf8_lossy(&output.stdout); - installed_targets.contains("wasm32-unknown-unknown") -} - -fn install_wasm_target() { - println!("Adding wasm32-unknown-unknown target..."); - let output = Command::new("rustup") - .args(["target", "add", "wasm32-unknown-unknown"]) - .output() - .expect("Failed to execute rustup"); - - if !output.status.success() { - eprintln!("{}", String::from_utf8_lossy(&output.stderr)); - exit(1); - } -} - -fn get_trunk_version() -> Option { - Command::new("trunk") - .arg("--version") - .output() - .ok() - .and_then(|output| String::from_utf8(output.stdout).ok()) - .and_then(|version_string| version_string.split_whitespace().last().map(String::from)) -} - -fn install_trunk() -> Result<(), Box> { - println!("Installing trunk {}...", TRUNK_VERSION); - - let output = Command::new("cargo") - .arg("install") - .arg("trunk") - .arg("--version") - .arg(TRUNK_VERSION) - .arg("--force") - .output()?; - - if !output.status.success() { - eprintln!("{}", String::from_utf8_lossy(&output.stderr)); - exit(1); - } - - Ok(()) -} - -fn main() { - println!("cargo:rerun-if-changed=../thoth-app/"); - // load environment variables from `.env` - dotenv().ok(); - - if !is_wasm_target_installed() { - install_wasm_target(); - } - - if let Some(version) = get_trunk_version() { - if !version.eq(TRUNK_VERSION) { - println!("Current trunk version: {}", version); - install_trunk().unwrap_or_else(|e| { - eprintln!("Error: {}", e); - exit(1); - }); - } - } else { - println!("trunk not found"); - install_trunk().unwrap_or_else(|e| { - eprintln!("Error: {}", e); - exit(1); - }); - } - - // need to change target directory to avoid deadlocking - env::set_var("CARGO_TARGET_DIR", "../thoth-app/target"); - let mut trunk_command = Command::new("trunk"); - trunk_command.args([ - "build", - "--config", - "../thoth-app/Trunk.toml", - "../thoth-app/index.html", - ]); - - // Add --release argument if not in debug mode - if cfg!(not(debug_assertions)) { - trunk_command.arg("--release"); - } - - let trunk_output = trunk_command.output().expect("Failed to execute trunk"); - - if !trunk_output.status.success() { - eprintln!("{}", String::from_utf8_lossy(&trunk_output.stderr)); - exit(1); - } - println!("{}", String::from_utf8_lossy(&trunk_output.stdout)); -} diff --git a/thoth-app-server/src/lib.rs b/thoth-app-server/src/lib.rs deleted file mode 100644 index a27bf378..00000000 --- a/thoth-app-server/src/lib.rs +++ /dev/null @@ -1,98 +0,0 @@ -use std::io; -use std::time::Duration; - -use actix_cors::Cors; -use actix_web::{get, middleware::Logger, web, App, HttpResponse, HttpServer}; - -mod manifest; -use crate::manifest::manifest_source; - -const NO_CACHE: &str = "no-cache"; -const STRICT_TRANSPORT_SECURITY: &str = "max-age=63072000; includeSubDomains; preload"; -const X_CONTENT_TYPE_OPTIONS: &str = "nosniff"; -const X_FRAME_OPTIONS: &str = "DENY"; -const REFERRER_POLICY: &str = "strict-origin-when-cross-origin"; -const PERMISSIONS_POLICY: &str = "geolocation=(), camera=(), microphone=()"; -const LOG_FORMAT: &str = r#"%{r}a %a "%r" %s %b "%{Referer}i" "%{User-Agent}i" %T"#; - -macro_rules! static_files { - ($(($cname:ident, $fname:ident) => ($source_path:expr, $dest_path:expr, $type:expr),)*) => ( - $( - const $cname: &[u8] = include_bytes!($source_path); - - #[get($dest_path)] - async fn $fname() -> HttpResponse { - HttpResponse::Ok() - .content_type($type) - .append_header(("Cache-Control", NO_CACHE)) - .append_header(("Strict-Transport-Security", STRICT_TRANSPORT_SECURITY)) - .append_header(("X-Content-Type-Options", X_CONTENT_TYPE_OPTIONS)) - .append_header(("X-Frame-Options", X_FRAME_OPTIONS)) - .append_header(("Referrer-Policy", REFERRER_POLICY)) - .append_header(("Permissions-Policy", PERMISSIONS_POLICY)) - .body($cname) - } - )* - - fn config(cfg: &mut web::ServiceConfig) { - $(cfg.service($fname);)* - } - - ) -} - -static_files! { - (JS, js_file) => ("../static/pkg/thoth-app.js", "/admin/thoth-app.js", "application/javascript"), - (WASM, wasm_file) => ("../static/pkg/thoth-app_bg.wasm", "/admin/thoth-app_bg.wasm", "application/wasm"), - (CSS, css_file) => ("../static/pkg/thoth.css", "/admin/thoth.css", "text/css; charset=utf-8"), -} - -const INDEX_FILE: &[u8] = include_bytes!("../static/pkg/index.html"); - -async fn index() -> HttpResponse { - HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .append_header(("Cache-Control", NO_CACHE)) - .append_header(("Strict-Transport-Security", STRICT_TRANSPORT_SECURITY)) - .append_header(("X-Content-Type-Options", X_CONTENT_TYPE_OPTIONS)) - .append_header(("X-Frame-Options", X_FRAME_OPTIONS)) - .append_header(("Referrer-Policy", REFERRER_POLICY)) - .append_header(("Permissions-Policy", PERMISSIONS_POLICY)) - .body(INDEX_FILE) -} - -#[get("/admin/manifest.json")] -async fn app_manifest() -> HttpResponse { - HttpResponse::Ok() - .content_type("application/json") - .append_header(("Strict-Transport-Security", STRICT_TRANSPORT_SECURITY)) - .append_header(("X-Content-Type-Options", X_CONTENT_TYPE_OPTIONS)) - .append_header(("X-Frame-Options", X_FRAME_OPTIONS)) - .append_header(("Referrer-Policy", REFERRER_POLICY)) - .append_header(("Permissions-Policy", PERMISSIONS_POLICY)) - .body(manifest_source()) -} - -#[actix_web::main] -pub async fn start_server( - host: String, - port: String, - threads: usize, - keep_alive: u64, -) -> io::Result<()> { - env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); - - HttpServer::new(move || { - App::new() - .wrap(Logger::new(LOG_FORMAT)) - .wrap(Cors::default().allowed_methods(vec!["GET", "POST", "OPTIONS"])) - .configure(config) - .default_service(web::route().to(index)) - .service(app_manifest) - }) - .workers(threads) - .keep_alive(Duration::from_secs(keep_alive)) - .bind(format!("{host}:{port}"))? - .run() - .await -} diff --git a/thoth-app-server/src/manifest.rs b/thoth-app-server/src/manifest.rs deleted file mode 100644 index 6a7ff814..00000000 --- a/thoth-app-server/src/manifest.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Utility module to generate the manifest.json file - -const VERSION: &str = env!("CARGO_PKG_VERSION"); - -pub fn manifest_source() -> String { - format!( - r##" -{{ - "name": "Thoth", - "version": "{VERSION}", - "description": "Bibliographical metadata management system.", - "display": "standalone", - "scope": "/admin", - "start_url": ".", - "background_color": "#FFDD57", - "theme_color": "#FFDD57", - "icons": [ - {{ - "src": "https://cdn.thoth.pub/android-icon-36x36.png", - "sizes": "36x36", - "type": "image\/png", - "density": "0.75" - }}, - {{ - "src": "https://cdn.thoth.pub/android-icon-48x48.png", - "sizes": "48x48", - "type": "image\/png", - "density": "1.0" - }}, - {{ - "src": "https://cdn.thoth.pub/android-icon-72x72.png", - "sizes": "72x72", - "type": "image\/png", - "density": "1.5" - }}, - {{ - "src": "https://cdn.thoth.pub/android-icon-96x96.png", - "sizes": "96x96", - "type": "image\/png", - "density": "2.0" - }}, - {{ - "src": "https://cdn.thoth.pub/android-icon-144x144.png", - "sizes": "144x144", - "type": "image\/png", - "density": "3.0" - }}, - {{ - "src": "https://cdn.thoth.pub/android-icon-192x192.png", - "sizes": "192x192", - "type": "image\/png", - "density": "4.0" - }} - ] -}} -"## - ) -} diff --git a/thoth-app-server/static b/thoth-app-server/static deleted file mode 120000 index 8f548506..00000000 --- a/thoth-app-server/static +++ /dev/null @@ -1 +0,0 @@ -../thoth-app \ No newline at end of file diff --git a/thoth-app/.gitignore b/thoth-app/.gitignore deleted file mode 100644 index 2dfbe6f5..00000000 --- a/thoth-app/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -pkg -dist diff --git a/thoth-app/Cargo.toml b/thoth-app/Cargo.toml deleted file mode 100644 index ddbe02f2..00000000 --- a/thoth-app/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "thoth-app" -version = "0.13.16" -authors = ["Javier Arias ", "Ross Higman "] -edition = "2021" -license = "Apache-2.0" -description = "WASM APP for bibliographic data" -repository = "https://github.com/thoth-pub/thoth" -readme = "README.md" -build = "build.rs" - -[badges] -maintenance = { status = "actively-developed" } - -[dependencies] -chrono = { version = "0.4.38", features = ["serde"] } -gloo-storage = "0.3.0" -gloo-timers = "0.3.0" -thiserror = "2.0" -yew = "0.19.3" -yew-agent = "0.1.0" -yew-router = "0.16.0" -yewtil = { version = "0.4.0", features = ["fetch"] } -wasm-bindgen = "0.2.100" -wasm-logger = "0.2.0" -web-sys = { version = "0.3.77", features = ["HtmlInputElement", "HtmlSelectElement", "HtmlTextAreaElement"] } -reqwest = { version = "0.12", features = ["json"] } -semver = "1.0.26" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -uuid = { version = "1.16.0", features = ["serde", "v4", "js"] } -thoth-api = { version = "=0.13.16", path = "../thoth-api" } -thoth-errors = { version = "=0.13.16", path = "../thoth-errors" } - -[build-dependencies] -dotenv = "0.15.0" diff --git a/thoth-app/LICENSE b/thoth-app/LICENSE deleted file mode 100644 index 5194de71..00000000 --- a/thoth-app/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2020 Thoth Open Metadata - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/thoth-app/README.md b/thoth-app/README.md deleted file mode 100644 index 78cde29e..00000000 --- a/thoth-app/README.md +++ /dev/null @@ -1,17 +0,0 @@ -
- - -

Thoth APP

- -

- WASM Frontend for Thoth metadata management and dissemination system -

- -

- GitHub Workflow - Thoth Releases - Crate Info - License Info -

-
- diff --git a/thoth-app/Trunk.toml b/thoth-app/Trunk.toml deleted file mode 100644 index 4082201b..00000000 --- a/thoth-app/Trunk.toml +++ /dev/null @@ -1,18 +0,0 @@ -[build] -# The output dir for all final assets. -dist = "pkg" -# The public URL from which assets are to be served. -public_url = "/admin/" -# Whether to include hash values in the output file names. -filehash = false -# Control minification -minify = "on_release" # can be one of: never, on_release, always - -[clean] -# The output dir for all final assets. -dist = "pkg" - -[tools] -# Default wasm-bindgen version to download. -wasm_bindgen = "0.2.100" -wasm_opt = "version_119" diff --git a/thoth-app/build.rs b/thoth-app/build.rs deleted file mode 100644 index 3a82ad9a..00000000 --- a/thoth-app/build.rs +++ /dev/null @@ -1,28 +0,0 @@ -use dotenv::dotenv; -use std::{env, fs}; - -const DOTENV_PATH: &str = "../.env"; - -/// This build script is responsible for optionally loading environment variables from a `.env` file, -/// setting them in Cargo's environment using `cargo:rustc-env`, and printing them out. -/// -/// Simply loading environment variables using `dotenv()` is not sufficient for them to be -/// available during the build process. Hence, they need to be explicitly set in Cargo's -/// environment using `cargo:rustc-env`. -fn main() { - println!("cargo:rerun-if-changed={DOTENV_PATH}"); - // load environment variables from `.env` - if dotenv().is_err() { - println!("No .env file found"); - return; - } - - // Need to set variables in cargo's environment, otherwise they're only available in this step. - // Iterate over environment variables and set only those present in the .env file - let env_file_content = fs::read_to_string(DOTENV_PATH).unwrap(); - for (key, value) in env::vars() { - if env_file_content.contains(&format!("{key}={value}")) { - println!("cargo:rustc-env={key}={value}"); - } - } -} diff --git a/thoth-app/index.html b/thoth-app/index.html deleted file mode 100644 index 65acecae..00000000 --- a/thoth-app/index.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - Thoth - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
-
-
-
-
-
-
- - - - - - - -
-
- - - diff --git a/thoth-app/main.js b/thoth-app/main.js deleted file mode 100644 index 6d84ae9e..00000000 --- a/thoth-app/main.js +++ /dev/null @@ -1,8 +0,0 @@ -import init, { run_app } from "./pkg/thoth_app.js"; - -async function main() { - await init("/admin/thoth_app_bg.wasm"); - run_app(); -} - -main(); diff --git a/thoth-app/src/agent/contributor_activity_checker.rs b/thoth-app/src/agent/contributor_activity_checker.rs deleted file mode 100644 index 63687a51..00000000 --- a/thoth-app/src/agent/contributor_activity_checker.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::collections::HashSet; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew_agent::{Agent, AgentLink, Context, Dispatched, HandlerId}; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request as NotificationRequest; -use crate::models::contributor::contributor_activity_query::ContributorActivityRequest; -use crate::models::contributor::contributor_activity_query::ContributorActivityRequestBody; -use crate::models::contributor::contributor_activity_query::ContributorActivityResponseData; -use crate::models::contributor::contributor_activity_query::FetchActionContributorActivity; -use crate::models::contributor::contributor_activity_query::FetchContributorActivity; -use crate::models::contributor::contributor_activity_query::Variables; - -pub enum Msg { - SetContributorActivityFetchState(FetchActionContributorActivity), -} - -pub enum Request { - RetrieveContributorActivity(Uuid), -} - -pub struct ContributorActivityChecker { - agent_link: AgentLink, - fetch_contributor_activity: FetchContributorActivity, - subscribers: HashSet, - notification_bus: NotificationDispatcher, -} - -impl Agent for ContributorActivityChecker { - type Input = Request; - type Message = Msg; - type Output = ContributorActivityResponseData; - type Reach = Context; - - fn create(link: AgentLink) -> Self { - Self { - agent_link: link, - fetch_contributor_activity: Default::default(), - subscribers: HashSet::new(), - notification_bus: NotificationBus::dispatcher(), - } - } - - fn update(&mut self, msg: Self::Message) { - match msg { - Msg::SetContributorActivityFetchState(fetch_state) => { - self.fetch_contributor_activity.apply(fetch_state); - match self.fetch_contributor_activity.as_ref().state() { - FetchState::NotFetching(_) => (), - FetchState::Fetching(_) => (), - FetchState::Fetched(body) => { - let response = &body.data; - for sub in self.subscribers.iter() { - self.agent_link.respond(*sub, response.clone()); - } - } - FetchState::Failed(_, err) => { - self.notification_bus - .send(NotificationRequest::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - } - } - } - } - } - - fn handle_input(&mut self, msg: Self::Input, _: HandlerId) { - match msg { - Request::RetrieveContributorActivity(contributor_id) => { - let body = ContributorActivityRequestBody { - variables: Variables { - contributor_id: Some(contributor_id), - }, - ..Default::default() - }; - let request = ContributorActivityRequest { body }; - self.fetch_contributor_activity = Fetch::new(request); - self.agent_link.send_future( - self.fetch_contributor_activity - .fetch(Msg::SetContributorActivityFetchState), - ); - self.agent_link - .send_message(Msg::SetContributorActivityFetchState(FetchAction::Fetching)); - } - } - } - - fn connected(&mut self, id: HandlerId) { - self.subscribers.insert(id); - } - - fn disconnected(&mut self, id: HandlerId) { - self.subscribers.remove(&id); - } -} diff --git a/thoth-app/src/agent/institution_activity_checker.rs b/thoth-app/src/agent/institution_activity_checker.rs deleted file mode 100644 index ec229f0b..00000000 --- a/thoth-app/src/agent/institution_activity_checker.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::collections::HashSet; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew_agent::{Agent, AgentLink, Context, Dispatched, HandlerId}; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request as NotificationRequest; -use crate::models::institution::institution_activity_query::FetchActionInstitutionActivity; -use crate::models::institution::institution_activity_query::FetchInstitutionActivity; -use crate::models::institution::institution_activity_query::InstitutionActivityRequest; -use crate::models::institution::institution_activity_query::InstitutionActivityRequestBody; -use crate::models::institution::institution_activity_query::InstitutionActivityResponseData; -use crate::models::institution::institution_activity_query::Variables; - -pub enum Msg { - SetInstitutionActivityFetchState(FetchActionInstitutionActivity), -} - -pub enum Request { - RetrieveInstitutionActivity(Uuid), -} - -pub struct InstitutionActivityChecker { - agent_link: AgentLink, - fetch_institution_activity: FetchInstitutionActivity, - subscribers: HashSet, - notification_bus: NotificationDispatcher, -} - -impl Agent for InstitutionActivityChecker { - type Input = Request; - type Message = Msg; - type Output = InstitutionActivityResponseData; - type Reach = Context; - - fn create(link: AgentLink) -> Self { - Self { - agent_link: link, - fetch_institution_activity: Default::default(), - subscribers: HashSet::new(), - notification_bus: NotificationBus::dispatcher(), - } - } - - fn update(&mut self, msg: Self::Message) { - match msg { - Msg::SetInstitutionActivityFetchState(fetch_state) => { - self.fetch_institution_activity.apply(fetch_state); - match self.fetch_institution_activity.as_ref().state() { - FetchState::NotFetching(_) => (), - FetchState::Fetching(_) => (), - FetchState::Fetched(body) => { - let response = &body.data; - for sub in self.subscribers.iter() { - self.agent_link.respond(*sub, response.clone()); - } - } - FetchState::Failed(_, err) => { - self.notification_bus - .send(NotificationRequest::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - } - } - } - } - } - - fn handle_input(&mut self, msg: Self::Input, _: HandlerId) { - match msg { - Request::RetrieveInstitutionActivity(institution_id) => { - let body = InstitutionActivityRequestBody { - variables: Variables { - institution_id: Some(institution_id), - }, - ..Default::default() - }; - let request = InstitutionActivityRequest { body }; - self.fetch_institution_activity = Fetch::new(request); - self.agent_link.send_future( - self.fetch_institution_activity - .fetch(Msg::SetInstitutionActivityFetchState), - ); - self.agent_link - .send_message(Msg::SetInstitutionActivityFetchState(FetchAction::Fetching)); - } - } - } - - fn connected(&mut self, id: HandlerId) { - self.subscribers.insert(id); - } - - fn disconnected(&mut self, id: HandlerId) { - self.subscribers.remove(&id); - } -} diff --git a/thoth-app/src/agent/mod.rs b/thoth-app/src/agent/mod.rs deleted file mode 100644 index 792b2890..00000000 --- a/thoth-app/src/agent/mod.rs +++ /dev/null @@ -1,66 +0,0 @@ -#[macro_export] -macro_rules! timer_agent { - ( - $agent:ident, - $agent_dispatcher:ident, - $agent_request:ident, - $agent_response:ident, - ) => { - use gloo_timers::callback::Interval; - use serde::Deserialize; - use serde::Serialize; - use yew::Callback; - use yew_agent::{Agent, AgentLink, Context, Dispatcher, HandlerId}; - - pub type $agent_dispatcher = Dispatcher<$agent>; - - pub enum $agent_request { - Start(Callback<()>), - Stop, - } - - #[derive(Deserialize, Serialize)] - pub struct $agent_response; - - pub struct $agent { - _link: AgentLink<$agent>, - timer_task: Option, - } - - impl Agent for $agent { - type Input = $agent_request; - type Message = (); - type Output = $agent_response; - type Reach = Context; - - fn create(_link: AgentLink) -> Self { - Self { - _link, - timer_task: None, - } - } - - fn update(&mut self, _msg: Self::Message) {} - - fn handle_input(&mut self, msg: Self::Input, _: HandlerId) { - match msg { - $agent_request::Start(callback) => { - self.timer_task = Some(Interval::new(60_000, move || callback.emit(()))); - } - $agent_request::Stop => { - if let Some(timer_task) = self.timer_task.take() { - // .take() sets self.timer_task to None so no need to do it explicitly - timer_task.cancel(); - } - } - } - } - } - }; -} - -pub mod contributor_activity_checker; -pub mod institution_activity_checker; -pub mod notification_bus; -pub mod session_timer; -pub mod version_timer; diff --git a/thoth-app/src/agent/notification_bus.rs b/thoth-app/src/agent/notification_bus.rs deleted file mode 100644 index 841a13a2..00000000 --- a/thoth-app/src/agent/notification_bus.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::collections::HashSet; -use std::fmt; -use yew_agent::{Agent, AgentLink, Context, Dispatcher, HandlerId}; - -pub type NotificationDispatcher = Dispatcher; - -#[derive(Debug)] -pub enum Request { - NotificationBusMsg((String, NotificationStatus)), -} - -#[derive(Debug, Clone)] -pub enum NotificationStatus { - Danger, - Success, - Warning, -} - -impl fmt::Display for NotificationStatus { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - NotificationStatus::Danger => write!(f, "is-danger"), - NotificationStatus::Success => write!(f, "is-success"), - NotificationStatus::Warning => write!(f, "is-warning"), - } - } -} - -pub struct NotificationBus { - link: AgentLink, - subscribers: HashSet, -} - -impl Agent for NotificationBus { - type Reach = Context; - type Message = (); - type Input = Request; - type Output = (String, NotificationStatus); - - fn create(link: AgentLink) -> Self { - NotificationBus { - link, - subscribers: HashSet::new(), - } - } - - fn update(&mut self, _: Self::Message) {} - - fn handle_input(&mut self, msg: Self::Input, _: HandlerId) { - match msg { - Request::NotificationBusMsg(s) => { - for sub in self.subscribers.iter() { - self.link.respond(*sub, s.clone()); - } - } - } - } - - fn connected(&mut self, id: HandlerId) { - self.subscribers.insert(id); - } - - fn disconnected(&mut self, id: HandlerId) { - self.subscribers.remove(&id); - } -} diff --git a/thoth-app/src/agent/session_timer.rs b/thoth-app/src/agent/session_timer.rs deleted file mode 100644 index 75daca87..00000000 --- a/thoth-app/src/agent/session_timer.rs +++ /dev/null @@ -1,6 +0,0 @@ -timer_agent! { - SessionTimerAgent, - SessionTimerDispatcher, - SessionTimerRequest, - SessionTimerResponse, -} diff --git a/thoth-app/src/agent/version_timer.rs b/thoth-app/src/agent/version_timer.rs deleted file mode 100644 index 7dd8de33..00000000 --- a/thoth-app/src/agent/version_timer.rs +++ /dev/null @@ -1,6 +0,0 @@ -timer_agent! { - VersionTimerAgent, - VersionTimerDispatcher, - VersionTimerRequest, - VersionTimerResponse, -} diff --git a/thoth-app/src/component/admin.rs b/thoth-app/src/component/admin.rs deleted file mode 100644 index 53476d3e..00000000 --- a/thoth-app/src/component/admin.rs +++ /dev/null @@ -1,223 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::account::model::AccountDetails; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::prelude::*; -use yew_router::scope_ext::HistoryHandle; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::books::BooksComponent; -use crate::component::chapters::ChaptersComponent; -use crate::component::contributor::ContributorComponent; -use crate::component::contributors::ContributorsComponent; -use crate::component::dashboard::DashboardComponent; -use crate::component::imprint::ImprintComponent; -use crate::component::imprints::ImprintsComponent; -use crate::component::institution::InstitutionComponent; -use crate::component::institutions::InstitutionsComponent; -use crate::component::menu::MenuComponent; -use crate::component::new_contributor::NewContributorComponent; -use crate::component::new_imprint::NewImprintComponent; -use crate::component::new_institution::NewInstitutionComponent; -use crate::component::new_publisher::NewPublisherComponent; -use crate::component::new_series::NewSeriesComponent; -use crate::component::new_work::NewWorkComponent; -use crate::component::publication::PublicationComponent; -use crate::component::publications::PublicationsComponent; -use crate::component::publisher::PublisherComponent; -use crate::component::publishers::PublishersComponent; -use crate::component::series::SeriesComponent; -use crate::component::serieses::SeriesesComponent; -use crate::component::work::WorkComponent; -use crate::component::works::WorksComponent; -use crate::route::AdminRoute; -use crate::route::AppRoute; -use crate::service::account::AccountService; -use crate::string::PERMISSIONS_ERROR; - -pub struct AdminComponent { - notification_bus: NotificationDispatcher, - current_route: AdminRoute, - previous_route: AdminRoute, - _listener: Option, -} - -pub enum Msg { - RedirectToLogin, - RouteChanged, -} - -#[derive(Clone, Properties, PartialEq, Eq)] -pub struct Props { - pub current_user: Option, -} - -impl Component for AdminComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - if !AccountService::new().is_loggedin() { - ctx.link().send_message(Msg::RedirectToLogin); - } - // Listen for when the route changes - let listener = ctx - .link() - .add_history_listener(ctx.link().callback(move |_| Msg::RouteChanged)); - // Start tracking current and previous route (previous is unknown at this point) - let current_route = ctx.link().route().unwrap(); - let previous_route = ctx.link().route().unwrap(); - - AdminComponent { - notification_bus: NotificationBus::dispatcher(), - current_route, - previous_route, - _listener: listener, - } - } - - fn rendered(&mut self, ctx: &Context, _first_render: bool) { - if ctx.props().current_user.is_some() - && ctx - .props() - .current_user - .as_ref() - .unwrap() - .resource_access - .restricted_to() - == Some(vec![]) - { - // Raise an error if user's permission set is empty - self.notification_bus.send(Request::NotificationBusMsg(( - PERMISSIONS_ERROR.into(), - NotificationStatus::Danger, - ))); - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::RedirectToLogin => { - ctx.link().history().unwrap().push(AppRoute::Login); - false - } - Msg::RouteChanged => { - if let Some(route) = ctx.link().route() { - // Route has changed - store it, and update the previous route value - self.previous_route.neq_assign(self.current_route.clone()); - self.current_route.neq_assign(route); - // Trigger a re-render to fire view() and update the copy of previous_route being - // passed to switch_admin() (without this, only switch_admin() fires on route change) - // This also ensures that menu.view() will be fired and update items' "is-active" classes - true - } else { - false - } - } - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - if ctx.props().current_user.is_none() { - ctx.link().send_message(Msg::RedirectToLogin); - } - true - } - - fn view(&self, ctx: &Context) -> Html { - if ctx.props().current_user.is_some() - && ctx - .props() - .current_user - .as_ref() - .unwrap() - .resource_access - .restricted_to() - != Some(vec![]) - { - let current_user = ctx.props().current_user.clone().unwrap(); - let route: AdminRoute = ctx.link().route().unwrap(); - let previous_route = self.previous_route.clone(); - let render = Switch::render(move |r| { - switch_admin(r, current_user.clone(), previous_route.clone()) - }); - - html! { -
-
-
- -
-
-
-
- { render } /> -
-
-
- } - } else { - html! {} - } - } -} - -fn switch_admin( - route: &AdminRoute, - current_user: AccountDetails, - previous_route: AdminRoute, -) -> Html { - match route { - AdminRoute::Dashboard => html! {}, - AdminRoute::Works => html! {}, - AdminRoute::Books => html! {}, - AdminRoute::Chapters => html! {}, - AdminRoute::Work { id } => html! {}, - AdminRoute::NewWork => html! {}, - AdminRoute::Publishers => html! {}, - AdminRoute::Publisher { id } => { - html! {} - } - AdminRoute::NewPublisher => html! {}, - AdminRoute::Imprints => html! {}, - AdminRoute::Imprint { id } => { - html! {} - } - AdminRoute::NewImprint => html! {}, - AdminRoute::Institutions => html! {}, - AdminRoute::Institution { id } => { - html! {} - } - AdminRoute::NewInstitution => html! {}, - AdminRoute::Publications => html! {}, - AdminRoute::Publication { id } => { - html! {} - } - AdminRoute::NewPublication => { - html! { -
-
- { "New publications can be added directly to the work." } -
-
- } - } - AdminRoute::Contributors => html! {}, - AdminRoute::Contributor { id } => { - html! {} - } - AdminRoute::NewContributor => html! {}, - AdminRoute::Serieses => html! {}, - AdminRoute::NewSeries => html! {}, - AdminRoute::Series { id } => html! {}, - AdminRoute::Error => html! { - to={ AppRoute::Error }/> - }, - } -} diff --git a/thoth-app/src/component/affiliations_form.rs b/thoth-app/src/component/affiliations_form.rs deleted file mode 100644 index d08543db..00000000 --- a/thoth-app/src/component/affiliations_form.rs +++ /dev/null @@ -1,512 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::model::affiliation::AffiliationWithInstitution; -use thoth_api::model::institution::Institution; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::institution_select::InstitutionSelectComponent; -use crate::component::utils::FormNumberInput; -use crate::component::utils::FormTextInput; -use crate::models::affiliation::affiliations_query::AffiliationsRequest; -use crate::models::affiliation::affiliations_query::AffiliationsRequestBody; -use crate::models::affiliation::affiliations_query::FetchActionAffiliations; -use crate::models::affiliation::affiliations_query::FetchAffiliations; -use crate::models::affiliation::affiliations_query::Variables; -use crate::models::affiliation::create_affiliation_mutation::CreateAffiliationRequest; -use crate::models::affiliation::create_affiliation_mutation::CreateAffiliationRequestBody; -use crate::models::affiliation::create_affiliation_mutation::PushActionCreateAffiliation; -use crate::models::affiliation::create_affiliation_mutation::PushCreateAffiliation; -use crate::models::affiliation::create_affiliation_mutation::Variables as CreateVariables; -use crate::models::affiliation::delete_affiliation_mutation::DeleteAffiliationRequest; -use crate::models::affiliation::delete_affiliation_mutation::DeleteAffiliationRequestBody; -use crate::models::affiliation::delete_affiliation_mutation::PushActionDeleteAffiliation; -use crate::models::affiliation::delete_affiliation_mutation::PushDeleteAffiliation; -use crate::models::affiliation::delete_affiliation_mutation::Variables as DeleteVariables; -use crate::models::affiliation::update_affiliation_mutation::PushActionUpdateAffiliation; -use crate::models::affiliation::update_affiliation_mutation::PushUpdateAffiliation; -use crate::models::affiliation::update_affiliation_mutation::UpdateAffiliationRequest; -use crate::models::affiliation::update_affiliation_mutation::UpdateAffiliationRequestBody; -use crate::models::affiliation::update_affiliation_mutation::Variables as UpdateVariables; -use crate::string::CANCEL_BUTTON; -use crate::string::EDIT_BUTTON; -use crate::string::REMOVE_BUTTON; - -use super::ToElementValue; -use super::ToOption; - -pub struct AffiliationsFormComponent { - fetch_affiliations: FetchAffiliations, - affiliations: Option>, - affiliation: AffiliationWithInstitution, - show_modal_form: bool, - in_edit_mode: bool, - create_affiliation: PushCreateAffiliation, - delete_affiliation: PushDeleteAffiliation, - update_affiliation: PushUpdateAffiliation, - notification_bus: NotificationDispatcher, -} - -pub enum Msg { - ToggleModalFormDisplay(bool, Option), - SetAffiliationsFetchState(FetchActionAffiliations), - GetAffiliations, - SetAffiliationCreateState(PushActionCreateAffiliation), - CreateAffiliation, - SetAffiliationUpdateState(PushActionUpdateAffiliation), - UpdateAffiliation, - SetAffiliationDeleteState(PushActionDeleteAffiliation), - DeleteAffiliation(Uuid), - AddAffiliation(Institution), - ChangeInstitution(Institution), - ChangePosition(String), - ChangeOrdinal(String), -} - -#[derive(Clone, Properties, PartialEq, Eq)] -pub struct Props { - pub contribution_id: Uuid, -} - -impl Component for AffiliationsFormComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let fetch_affiliations = Default::default(); - let affiliations: Option> = Default::default(); - let affiliation: AffiliationWithInstitution = Default::default(); - let show_modal_form = false; - let in_edit_mode = false; - let create_affiliation = Default::default(); - let delete_affiliation = Default::default(); - let update_affiliation = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - - ctx.link().send_message(Msg::GetAffiliations); - - AffiliationsFormComponent { - fetch_affiliations, - affiliations, - affiliation, - show_modal_form, - in_edit_mode, - create_affiliation, - delete_affiliation, - update_affiliation, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleModalFormDisplay(show_form, a) => { - self.show_modal_form = show_form; - self.in_edit_mode = a.is_some(); - if show_form { - if let Some(affiliation) = a { - // Editing existing affiliation: load its current values. - self.affiliation = affiliation; - } - } - true - } - Msg::SetAffiliationsFetchState(fetch_state) => { - self.fetch_affiliations.apply(fetch_state); - self.affiliations = match self.fetch_affiliations.as_ref().state() { - FetchState::NotFetching(_) => None, - FetchState::Fetching(_) => None, - FetchState::Fetched(body) => match &body.data.contribution { - Some(c) => c.affiliations.clone(), - None => Default::default(), - }, - FetchState::Failed(_, _err) => None, - }; - true - } - Msg::GetAffiliations => { - let body = AffiliationsRequestBody { - variables: Variables { - contribution_id: ctx.props().contribution_id, - }, - ..Default::default() - }; - let request = AffiliationsRequest { body }; - self.fetch_affiliations = Fetch::new(request); - - ctx.link().send_future( - self.fetch_affiliations - .fetch(Msg::SetAffiliationsFetchState), - ); - ctx.link() - .send_message(Msg::SetAffiliationsFetchState(FetchAction::Fetching)); - false - } - Msg::SetAffiliationCreateState(fetch_state) => { - self.create_affiliation.apply(fetch_state); - match self.create_affiliation.clone().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_affiliation { - Some(a) => { - let affiliation = a.clone(); - let mut affiliations: Vec = - self.affiliations.clone().unwrap_or_default(); - affiliations.push(affiliation); - self.affiliations = Some(affiliations); - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - true - } - None => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateAffiliation => { - let body = CreateAffiliationRequestBody { - variables: CreateVariables { - contribution_id: ctx.props().contribution_id, - institution_id: self.affiliation.institution_id, - position: self.affiliation.position.clone(), - affiliation_ordinal: self.affiliation.affiliation_ordinal, - }, - ..Default::default() - }; - let request = CreateAffiliationRequest { body }; - self.create_affiliation = Fetch::new(request); - ctx.link().send_future( - self.create_affiliation - .fetch(Msg::SetAffiliationCreateState), - ); - ctx.link() - .send_message(Msg::SetAffiliationCreateState(FetchAction::Fetching)); - false - } - Msg::SetAffiliationUpdateState(fetch_state) => { - self.update_affiliation.apply(fetch_state); - match self.update_affiliation.clone().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.update_affiliation { - Some(a) => { - let mut affiliations: Vec = - self.affiliations.clone().unwrap_or_default(); - if let Some(affiliation) = affiliations - .iter_mut() - .find(|af| af.affiliation_id == a.affiliation_id) - { - *affiliation = a.clone(); - self.affiliations = Some(affiliations); - } else { - // This should not be possible: the updated affiliation returned from the - // database does not match any of the locally-stored affiliation data. - // Refreshing the page will reload the local data from the database. - self.notification_bus.send(Request::NotificationBusMsg(( - "Changes were saved but display failed to update. Refresh your browser to view current data.".to_string(), - NotificationStatus::Warning, - ))); - } - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - true - } - None => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::UpdateAffiliation => { - let body = UpdateAffiliationRequestBody { - variables: UpdateVariables { - affiliation_id: self.affiliation.affiliation_id, - contribution_id: ctx.props().contribution_id, - institution_id: self.affiliation.institution_id, - position: self.affiliation.position.clone(), - affiliation_ordinal: self.affiliation.affiliation_ordinal, - }, - ..Default::default() - }; - let request = UpdateAffiliationRequest { body }; - self.update_affiliation = Fetch::new(request); - ctx.link().send_future( - self.update_affiliation - .fetch(Msg::SetAffiliationUpdateState), - ); - ctx.link() - .send_message(Msg::SetAffiliationUpdateState(FetchAction::Fetching)); - false - } - Msg::SetAffiliationDeleteState(fetch_state) => { - self.delete_affiliation.apply(fetch_state); - match self.delete_affiliation.clone().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_affiliation { - Some(affiliation) => { - let to_keep: Vec = self - .affiliations - .clone() - .unwrap_or_default() - .into_iter() - .filter(|a| a.affiliation_id != affiliation.affiliation_id) - .collect(); - self.affiliations = Some(to_keep); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteAffiliation(affiliation_id) => { - let body = DeleteAffiliationRequestBody { - variables: DeleteVariables { affiliation_id }, - ..Default::default() - }; - let request = DeleteAffiliationRequest { body }; - self.delete_affiliation = Fetch::new(request); - ctx.link().send_future( - self.delete_affiliation - .fetch(Msg::SetAffiliationDeleteState), - ); - ctx.link() - .send_message(Msg::SetAffiliationDeleteState(FetchAction::Fetching)); - false - } - Msg::AddAffiliation(institution) => { - self.affiliation.institution_id = institution.institution_id; - self.affiliation.institution = institution; - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(true, None)); - true - } - Msg::ChangeInstitution(institution) => { - self.affiliation.institution_id = institution.institution_id; - self.affiliation.institution = institution; - true - } - Msg::ChangePosition(val) => self.affiliation.position.neq_assign(val.to_opt_string()), - Msg::ChangeOrdinal(ordinal) => { - let ordinal = ordinal.parse::().unwrap_or(0); - self.affiliation.affiliation_ordinal.neq_assign(ordinal); - false // otherwise we re-render the component and reset the value - } - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - ctx.link().send_message(Msg::GetAffiliations); - false - } - - fn view(&self, ctx: &Context) -> Html { - // Ensure the form has a unique ID, as there may be multiple copies of - // the form on the same parent page, and ID clashes can lead to bugs - let form_id = format!("affiliations-form-{}", ctx.props().contribution_id); - let affiliations = self.affiliations.clone().unwrap_or_default(); - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleModalFormDisplay(false, None) - }); - - html! { -
-
- - -
- - - - - - - // Empty columns for "Edit" and "Remove" buttons - - - - - - {for affiliations.iter().map(|a| self.render_affiliation(ctx, a))} - - - - -
- { "Institution" } - - { "Position" } - - { "Affiliation Ordinal" } -
-
- } - } -} - -impl AffiliationsFormComponent { - fn modal_form_status(&self) -> String { - match self.show_modal_form { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } - - fn modal_form_title(&self) -> String { - match self.in_edit_mode { - true => "Edit Affiliation".to_string(), - false => "New Affiliation".to_string(), - } - } - - fn modal_form_button(&self) -> String { - match self.in_edit_mode { - true => "Save Affiliation".to_string(), - false => "Add Affiliation".to_string(), - } - } - - fn modal_form_action(&self, ctx: &Context) -> Callback { - match self.in_edit_mode { - true => ctx.link().callback(|e: FocusEvent| { - e.prevent_default(); - Msg::UpdateAffiliation - }), - false => ctx.link().callback(|e: FocusEvent| { - e.prevent_default(); - Msg::CreateAffiliation - }), - } - } - - fn render_affiliation(&self, ctx: &Context, a: &AffiliationWithInstitution) -> Html { - let affiliation = a.clone(); - let affiliation_id = a.affiliation_id; - html! { - - {&a.institution.institution_name} - {&a.position.clone().unwrap_or_default()} - {&a.affiliation_ordinal.clone()} - - - { EDIT_BUTTON } - - - - - { REMOVE_BUTTON } - - - - } - } -} diff --git a/thoth-app/src/component/books.rs b/thoth-app/src/component/books.rs deleted file mode 100644 index d4e6e6bd..00000000 --- a/thoth-app/src/component/books.rs +++ /dev/null @@ -1,37 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use crate::models::book::books_query::BooksRequest; -use crate::models::book::books_query::BooksRequestBody; -use crate::models::book::books_query::FetchActionBooks; -use crate::models::book::books_query::FetchBooks; -use crate::models::book::books_query::Variables; -use thoth_api::model::work::WorkField; -use thoth_api::model::work::WorkOrderBy; -use thoth_api::model::work::WorkWithRelations; - -use super::ToElementValue; - -pagination_component! { - BooksComponent, - WorkWithRelations, - books, - book_count, - BooksRequest, - FetchActionBooks, - FetchBooks, - BooksRequestBody, - Variables, - SEARCH_WORKS, - PAGINATION_COUNT_BOOKS, - vec![ - WorkField::WorkId.to_string(), - WorkField::FullTitle.to_string(), - WorkField::WorkType.to_string(), - "Contributors".to_string(), - WorkField::Doi.to_string(), - "Publisher".to_string(), - WorkField::UpdatedAt.to_string(), - ], - WorkOrderBy, - WorkField, -} diff --git a/thoth-app/src/component/chapters.rs b/thoth-app/src/component/chapters.rs deleted file mode 100644 index 08fa46b7..00000000 --- a/thoth-app/src/component/chapters.rs +++ /dev/null @@ -1,37 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use crate::models::chapter::chapters_query::ChaptersRequest; -use crate::models::chapter::chapters_query::ChaptersRequestBody; -use crate::models::chapter::chapters_query::FetchActionChapters; -use crate::models::chapter::chapters_query::FetchChapters; -use crate::models::chapter::chapters_query::Variables; -use thoth_api::model::work::WorkField; -use thoth_api::model::work::WorkOrderBy; -use thoth_api::model::work::WorkWithRelations; - -use super::ToElementValue; - -pagination_component! { - ChaptersComponent, - WorkWithRelations, - chapters, - chapter_count, - ChaptersRequest, - FetchActionChapters, - FetchChapters, - ChaptersRequestBody, - Variables, - SEARCH_WORKS, - PAGINATION_COUNT_CHAPTERS, - vec![ - WorkField::WorkId.to_string(), - WorkField::FullTitle.to_string(), - WorkField::WorkType.to_string(), - "Contributors".to_string(), - WorkField::Doi.to_string(), - "Publisher".to_string(), - WorkField::UpdatedAt.to_string(), - ], - WorkOrderBy, - WorkField, -} diff --git a/thoth-app/src/component/contributions_form.rs b/thoth-app/src/component/contributions_form.rs deleted file mode 100644 index 34c30bbb..00000000 --- a/thoth-app/src/component/contributions_form.rs +++ /dev/null @@ -1,615 +0,0 @@ -use std::str::FromStr; -use thoth_api::model::contribution::Contribution; -use thoth_api::model::contribution::ContributionType; -use thoth_api::model::contributor::Contributor; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::affiliations_form::AffiliationsFormComponent; -use crate::component::contributor_select::ContributorSelectComponent; -use crate::component::utils::FormBooleanSelect; -use crate::component::utils::FormContributionTypeSelect; -use crate::component::utils::FormNumberInput; -use crate::component::utils::FormTextInput; -use crate::models::contribution::contribution_types_query::FetchActionContributionTypes; -use crate::models::contribution::contribution_types_query::FetchContributionTypes; -use crate::models::contribution::create_contribution_mutation::CreateContributionRequest; -use crate::models::contribution::create_contribution_mutation::CreateContributionRequestBody; -use crate::models::contribution::create_contribution_mutation::PushActionCreateContribution; -use crate::models::contribution::create_contribution_mutation::PushCreateContribution; -use crate::models::contribution::create_contribution_mutation::Variables as CreateVariables; -use crate::models::contribution::delete_contribution_mutation::DeleteContributionRequest; -use crate::models::contribution::delete_contribution_mutation::DeleteContributionRequestBody; -use crate::models::contribution::delete_contribution_mutation::PushActionDeleteContribution; -use crate::models::contribution::delete_contribution_mutation::PushDeleteContribution; -use crate::models::contribution::delete_contribution_mutation::Variables as DeleteVariables; -use crate::models::contribution::update_contribution_mutation::PushActionUpdateContribution; -use crate::models::contribution::update_contribution_mutation::PushUpdateContribution; -use crate::models::contribution::update_contribution_mutation::UpdateContributionRequest; -use crate::models::contribution::update_contribution_mutation::UpdateContributionRequestBody; -use crate::models::contribution::update_contribution_mutation::Variables as UpdateVariables; -use crate::models::contribution::ContributionTypeValues; -use crate::string::CANCEL_BUTTON; -use crate::string::EDIT_BUTTON; -use crate::string::EMPTY_CONTRIBUTIONS; -use crate::string::NO; -use crate::string::REMOVE_BUTTON; -use crate::string::YES; - -use super::ToElementValue; -use super::ToOption; - -pub struct ContributionsFormComponent { - data: ContributionsFormData, - contribution: Contribution, - show_modal_form: bool, - in_edit_mode: bool, - fetch_contribution_types: FetchContributionTypes, - create_contribution: PushCreateContribution, - delete_contribution: PushDeleteContribution, - update_contribution: PushUpdateContribution, - notification_bus: NotificationDispatcher, -} - -#[derive(Default)] -struct ContributionsFormData { - contribution_types: Vec, -} - -pub enum Msg { - ToggleModalFormDisplay(bool, Option), - SetContributionTypesFetchState(FetchActionContributionTypes), - GetContributionTypes, - SetContributionCreateState(PushActionCreateContribution), - CreateContribution, - SetContributionUpdateState(PushActionUpdateContribution), - UpdateContribution, - SetContributionDeleteState(PushActionDeleteContribution), - DeleteContribution(Uuid), - AddContribution(Contributor), - ChangeContributor(Contributor), - ChangeFirstName(String), - ChangeLastName(String), - ChangeFullName(String), - ChangeBiography(String), - ChangeContributiontype(ContributionType), - ChangeMainContribution(bool), - ChangeOrdinal(String), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub contributions: Option>, - pub work_id: Uuid, - pub update_contributions: Callback>>, -} - -impl Component for ContributionsFormComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let data: ContributionsFormData = Default::default(); - let contribution: Contribution = Default::default(); - let show_modal_form = false; - let in_edit_mode = false; - let fetch_contribution_types = Default::default(); - let create_contribution = Default::default(); - let delete_contribution = Default::default(); - let update_contribution = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - - ctx.link().send_message(Msg::GetContributionTypes); - - ContributionsFormComponent { - data, - contribution, - show_modal_form, - in_edit_mode, - fetch_contribution_types, - create_contribution, - delete_contribution, - update_contribution, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleModalFormDisplay(show_form, c) => { - self.show_modal_form = show_form; - self.in_edit_mode = c.is_some(); - if show_form { - if let Some(contribution) = c { - // Editing existing contribution: load its current values. - self.contribution = contribution; - } - } - true - } - Msg::SetContributionTypesFetchState(fetch_state) => { - self.fetch_contribution_types.apply(fetch_state); - self.data.contribution_types = match self.fetch_contribution_types.as_ref().state() - { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.contribution_types.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetContributionTypes => { - ctx.link().send_future( - self.fetch_contribution_types - .fetch(Msg::SetContributionTypesFetchState), - ); - ctx.link() - .send_message(Msg::SetContributionTypesFetchState(FetchAction::Fetching)); - false - } - Msg::SetContributionCreateState(fetch_state) => { - self.create_contribution.apply(fetch_state); - match self.create_contribution.clone().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_contribution { - Some(i) => { - let contribution = i.clone(); - let mut contributions: Vec = - ctx.props().contributions.clone().unwrap_or_default(); - contributions.push(contribution); - ctx.props().update_contributions.emit(Some(contributions)); - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - true - } - None => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateContribution => { - let body = CreateContributionRequestBody { - variables: CreateVariables { - work_id: ctx.props().work_id, - contributor_id: self.contribution.contributor_id, - contribution_type: self.contribution.contribution_type, - main_contribution: self.contribution.main_contribution, - biography: self.contribution.biography.clone(), - first_name: self.contribution.first_name.clone(), - last_name: self.contribution.last_name.clone(), - full_name: self.contribution.full_name.clone(), - contribution_ordinal: self.contribution.contribution_ordinal, - }, - ..Default::default() - }; - let request = CreateContributionRequest { body }; - self.create_contribution = Fetch::new(request); - ctx.link().send_future( - self.create_contribution - .fetch(Msg::SetContributionCreateState), - ); - ctx.link() - .send_message(Msg::SetContributionCreateState(FetchAction::Fetching)); - false - } - Msg::SetContributionUpdateState(fetch_state) => { - self.update_contribution.apply(fetch_state); - match self.update_contribution.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.update_contribution { - Some(c) => { - let mut contributions: Vec = - ctx.props().contributions.clone().unwrap_or_default(); - if let Some(contribution) = contributions - .iter_mut() - .find(|cn| cn.contribution_id == c.contribution_id) - { - *contribution = c.clone(); - ctx.props().update_contributions.emit(Some(contributions)); - } else { - // This should not be possible: the updated contribution returned from the - // database does not match any of the locally-stored contribution data. - // Refreshing the page will reload the local data from the database. - self.notification_bus.send(Request::NotificationBusMsg(( - "Changes were saved but display failed to update. Refresh your browser to view current data.".to_string(), - NotificationStatus::Warning, - ))); - } - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - true - } - None => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::UpdateContribution => { - let body = UpdateContributionRequestBody { - variables: UpdateVariables { - contribution_id: self.contribution.contribution_id, - work_id: ctx.props().work_id, - contributor_id: self.contribution.contributor_id, - contribution_type: self.contribution.contribution_type, - main_contribution: self.contribution.main_contribution, - biography: self.contribution.biography.clone(), - first_name: self.contribution.first_name.clone(), - last_name: self.contribution.last_name.clone(), - full_name: self.contribution.full_name.clone(), - contribution_ordinal: self.contribution.contribution_ordinal, - }, - ..Default::default() - }; - let request = UpdateContributionRequest { body }; - self.update_contribution = Fetch::new(request); - ctx.link().send_future( - self.update_contribution - .fetch(Msg::SetContributionUpdateState), - ); - ctx.link() - .send_message(Msg::SetContributionUpdateState(FetchAction::Fetching)); - false - } - Msg::SetContributionDeleteState(fetch_state) => { - self.delete_contribution.apply(fetch_state); - match self.delete_contribution.clone().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_contribution { - Some(contribution) => { - let to_keep: Vec = ctx - .props() - .contributions - .clone() - .unwrap_or_default() - .into_iter() - .filter(|c| c.contribution_id != contribution.contribution_id) - .collect(); - ctx.props().update_contributions.emit(Some(to_keep)); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteContribution(contribution_id) => { - let body = DeleteContributionRequestBody { - variables: DeleteVariables { contribution_id }, - ..Default::default() - }; - let request = DeleteContributionRequest { body }; - self.delete_contribution = Fetch::new(request); - ctx.link().send_future( - self.delete_contribution - .fetch(Msg::SetContributionDeleteState), - ); - ctx.link() - .send_message(Msg::SetContributionDeleteState(FetchAction::Fetching)); - false - } - Msg::AddContribution(contributor) => { - self.contribution.contributor_id = contributor.contributor_id; - self.contribution.first_name = contributor.first_name; - self.contribution.last_name = contributor.last_name; - self.contribution.full_name = contributor.full_name; - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(true, None)); - true - } - Msg::ChangeContributor(contributor) => { - self.contribution.contributor_id = contributor.contributor_id; - // Update user-editable name fields to default to canonical name, if changed - self.contribution - .first_name - .neq_assign(contributor.first_name.clone()); - self.contribution - .last_name - .neq_assign(contributor.last_name.clone()); - self.contribution - .full_name - .neq_assign(contributor.full_name.clone()); - true - } - Msg::ChangeFirstName(val) => { - self.contribution.first_name.neq_assign(val.to_opt_string()) - } - Msg::ChangeLastName(val) => self - .contribution - .last_name - .neq_assign(val.trim().to_owned()), - Msg::ChangeFullName(val) => self - .contribution - .full_name - .neq_assign(val.trim().to_owned()), - Msg::ChangeBiography(val) => { - self.contribution.biography.neq_assign(val.to_opt_string()) - } - Msg::ChangeContributiontype(val) => self.contribution.contribution_type.neq_assign(val), - Msg::ChangeMainContribution(val) => self.contribution.main_contribution.neq_assign(val), - Msg::ChangeOrdinal(ordinal) => { - let ordinal = ordinal.parse::().unwrap_or(0); - self.contribution.contribution_ordinal.neq_assign(ordinal); - false // otherwise we re-render the component and reset the value - } - } - } - - fn view(&self, ctx: &Context) -> Html { - let contributions = ctx.props().contributions.clone().unwrap_or_default(); - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleModalFormDisplay(false, None) - }); - html! { - - } - } -} - -impl ContributionsFormComponent { - fn modal_form_status(&self) -> String { - match self.show_modal_form { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } - - fn modal_form_title(&self) -> String { - match self.in_edit_mode { - true => "Edit Contribution".to_string(), - false => "New Contribution".to_string(), - } - } - - fn modal_form_button(&self) -> String { - match self.in_edit_mode { - true => "Save Contribution".to_string(), - false => "Add Contribution".to_string(), - } - } - - fn modal_form_action(&self, ctx: &Context) -> Callback { - match self.in_edit_mode { - true => ctx.link().callback(|e: FocusEvent| { - e.prevent_default(); - Msg::UpdateContribution - }), - false => ctx.link().callback(|e: FocusEvent| { - e.prevent_default(); - Msg::CreateContribution - }), - } - } - - fn render_contribution(&self, ctx: &Context, c: &Contribution) -> Html { - let contribution = c.clone(); - let contribution_id = c.contribution_id; - html! { -
- - - -
-
- -
- {&c.full_name} -
-
-
- -
- {&c.contribution_type} -
-
-
- -
- {&c.biography.clone().unwrap_or_default()} -
-
-
- -
- { - match c.main_contribution { - true => { YES }, - false => { NO } - } - } -
-
-
- -
- {&c.contribution_ordinal.clone()} -
-
- - -
- -
- } - } -} diff --git a/thoth-app/src/component/contributor.rs b/thoth-app/src/component/contributor.rs deleted file mode 100644 index 0b00648f..00000000 --- a/thoth-app/src/component/contributor.rs +++ /dev/null @@ -1,440 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::account::model::AccountDetails; -use thoth_api::model::contribution::ContributionWithWork; -use thoth_api::model::contributor::Contributor; -use thoth_api::model::{Orcid, ORCID_DOMAIN}; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Bridge; -use yew_agent::Bridged; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::Link; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::contributor_activity_checker::ContributorActivityChecker; -use crate::agent::contributor_activity_checker::Request as ContributorActivityRequest; -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::delete_dialogue::ConfirmDeleteComponent; -use crate::component::utils::FormTextInput; -use crate::component::utils::FormTextInputExtended; -use crate::component::utils::FormUrlInput; -use crate::component::utils::Loader; -use crate::models::contributor::contributor_activity_query::ContributorActivityResponseData; -use crate::models::contributor::contributor_query::ContributorRequest; -use crate::models::contributor::contributor_query::ContributorRequestBody; -use crate::models::contributor::contributor_query::FetchActionContributor; -use crate::models::contributor::contributor_query::FetchContributor; -use crate::models::contributor::contributor_query::Variables; -use crate::models::contributor::delete_contributor_mutation::DeleteContributorRequest; -use crate::models::contributor::delete_contributor_mutation::DeleteContributorRequestBody; -use crate::models::contributor::delete_contributor_mutation::PushActionDeleteContributor; -use crate::models::contributor::delete_contributor_mutation::PushDeleteContributor; -use crate::models::contributor::delete_contributor_mutation::Variables as DeleteVariables; -use crate::models::contributor::update_contributor_mutation::PushActionUpdateContributor; -use crate::models::contributor::update_contributor_mutation::PushUpdateContributor; -use crate::models::contributor::update_contributor_mutation::UpdateContributorRequest; -use crate::models::contributor::update_contributor_mutation::UpdateContributorRequestBody; -use crate::models::contributor::update_contributor_mutation::Variables as UpdateVariables; -use crate::models::EditRoute; -use crate::route::AdminRoute; -use crate::string::SAVE_BUTTON; - -use super::ToElementValue; -use super::ToOption; - -pub struct ContributorComponent { - contributor: Contributor, - // Track the user-entered ORCID string, which may not be validly formatted - orcid: String, - orcid_warning: String, - fetch_contributor: FetchContributor, - push_contributor: PushUpdateContributor, - delete_contributor: PushDeleteContributor, - notification_bus: NotificationDispatcher, - _contributor_activity_checker: Box>, - contributor_activity: Vec, -} - -pub enum Msg { - GetContributorActivity(ContributorActivityResponseData), - SetContributorFetchState(FetchActionContributor), - GetContributor, - SetContributorPushState(PushActionUpdateContributor), - UpdateContributor, - SetContributorDeleteState(PushActionDeleteContributor), - DeleteContributor, - ChangeFirstName(String), - ChangeLastName(String), - ChangeFullName(String), - ChangeOrcid(String), - ChangeWebsite(String), -} - -#[derive(PartialEq, Eq, Properties)] -pub struct Props { - pub contributor_id: Uuid, - pub current_user: AccountDetails, -} - -impl Component for ContributorComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let body = ContributorRequestBody { - variables: Variables { - contributor_id: Some(ctx.props().contributor_id), - }, - ..Default::default() - }; - let request = ContributorRequest { body }; - let fetch_contributor = Fetch::new(request); - let push_contributor = Default::default(); - let delete_contributor = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let contributor: Contributor = Default::default(); - let orcid = Default::default(); - let orcid_warning = Default::default(); - let mut _contributor_activity_checker = - ContributorActivityChecker::bridge(ctx.link().callback(Msg::GetContributorActivity)); - let contributor_activity = Default::default(); - - ctx.link().send_message(Msg::GetContributor); - _contributor_activity_checker.send( - ContributorActivityRequest::RetrieveContributorActivity(ctx.props().contributor_id), - ); - - ContributorComponent { - contributor, - orcid, - orcid_warning, - fetch_contributor, - push_contributor, - delete_contributor, - notification_bus, - _contributor_activity_checker, - contributor_activity, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::GetContributorActivity(response) => { - let mut should_render = false; - if let Some(contributor) = response.contributor { - if let Some(contributions) = contributor.contributions { - if !contributions.is_empty() { - self.contributor_activity = contributions; - should_render = true; - } - } - } - should_render - } - Msg::SetContributorFetchState(fetch_state) => { - self.fetch_contributor.apply(fetch_state); - match self.fetch_contributor.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => { - self.contributor = match &body.data.contributor { - Some(c) => c.to_owned(), - None => Default::default(), - }; - // Initialise user-entered ORCID variable to match ORCID in database - self.orcid = self - .contributor - .orcid - .clone() - .unwrap_or_default() - .to_string(); - true - } - FetchState::Failed(_, _err) => false, - } - } - Msg::GetContributor => { - ctx.link() - .send_future(self.fetch_contributor.fetch(Msg::SetContributorFetchState)); - ctx.link() - .send_message(Msg::SetContributorFetchState(FetchAction::Fetching)); - false - } - Msg::SetContributorPushState(fetch_state) => { - self.push_contributor.apply(fetch_state); - match self.push_contributor.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.update_contributor { - Some(c) => { - // Save was successful: update user-entered ORCID variable to match ORCID in database - self.orcid = self - .contributor - .orcid - .clone() - .unwrap_or_default() - .to_string(); - self.orcid_warning.clear(); - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Saved {}", c.full_name), - NotificationStatus::Success, - ))); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::UpdateContributor => { - // Only update the ORCID value with the current user-entered string - // if it is validly formatted - otherwise keep the database version. - // If no ORCID was provided, no format check is required. - if self.orcid.is_empty() { - self.contributor.orcid.neq_assign(None); - } else if let Ok(result) = self.orcid.parse::() { - self.contributor.orcid.neq_assign(Some(result)); - } - let body = UpdateContributorRequestBody { - variables: UpdateVariables { - contributor_id: self.contributor.contributor_id, - first_name: self.contributor.first_name.clone(), - last_name: self.contributor.last_name.clone(), - full_name: self.contributor.full_name.clone(), - orcid: self.contributor.orcid.clone(), - website: self.contributor.website.clone(), - }, - ..Default::default() - }; - let request = UpdateContributorRequest { body }; - self.push_contributor = Fetch::new(request); - ctx.link() - .send_future(self.push_contributor.fetch(Msg::SetContributorPushState)); - ctx.link() - .send_message(Msg::SetContributorPushState(FetchAction::Fetching)); - false - } - Msg::SetContributorDeleteState(fetch_state) => { - self.delete_contributor.apply(fetch_state); - match self.delete_contributor.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_contributor { - Some(c) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Deleted {}", c.full_name), - NotificationStatus::Success, - ))); - ctx.link().history().unwrap().push(AdminRoute::Contributors); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteContributor => { - let body = DeleteContributorRequestBody { - variables: DeleteVariables { - contributor_id: self.contributor.contributor_id, - }, - ..Default::default() - }; - let request = DeleteContributorRequest { body }; - self.delete_contributor = Fetch::new(request); - ctx.link().send_future( - self.delete_contributor - .fetch(Msg::SetContributorDeleteState), - ); - ctx.link() - .send_message(Msg::SetContributorDeleteState(FetchAction::Fetching)); - false - } - Msg::ChangeFirstName(value) => self - .contributor - .first_name - .neq_assign(value.to_opt_string()), - Msg::ChangeLastName(last_name) => self - .contributor - .last_name - .neq_assign(last_name.trim().to_owned()), - Msg::ChangeFullName(full_name) => self - .contributor - .full_name - .neq_assign(full_name.trim().to_owned()), - Msg::ChangeOrcid(value) => { - if self.orcid.neq_assign(value.trim().to_owned()) { - // If ORCID is not correctly formatted, display a warning. - // Don't update self.contributor.orcid yet, as user may later - // overwrite a new valid value with an invalid one. - self.orcid_warning.clear(); - match self.orcid.parse::() { - Err(e) => { - match e { - // If no ORCID was provided, no warning is required. - ThothError::OrcidEmptyError => {} - _ => self.orcid_warning = e.to_string(), - } - } - Ok(value) => self.orcid = value.to_string(), - } - true - } else { - false - } - } - Msg::ChangeWebsite(value) => self.contributor.website.neq_assign(value.to_opt_string()), - } - } - - fn view(&self, ctx: &Context) -> Html { - match self.fetch_contributor.as_ref().state() { - FetchState::NotFetching(_) => html! {}, - FetchState::Fetching(_) => html! {}, - FetchState::Fetched(_body) => { - let callback = ctx.link().callback(|event: FocusEvent| { - event.prevent_default(); - Msg::UpdateContributor - }); - let mut delete_callback = Some(ctx.link().callback(|_| Msg::DeleteContributor)); - let mut delete_deactivated = false; - // If user doesn't have permission to delete this contributor (i.e. because it's connected to a work - // from a publisher they're not associated with), deactivate the delete button and unset its callback - if let Some(publishers) = ctx.props().current_user.resource_access.restricted_to() { - for contribution in &self.contributor_activity { - if !publishers - .contains(&contribution.work.imprint.publisher.publisher_id.to_string()) - { - delete_callback = None; - delete_deactivated = true; - break; - } - } - } - html! { - <> - - - { if !self.contributor_activity.is_empty() { - html! { - - } - } else { - html! {} - } - } - -
- - - - - - -
-
- -
-
- - - } - } - FetchState::Failed(_, err) => html! { - { ThothError::from(err).to_string() } - }, - } - } -} diff --git a/thoth-app/src/component/contributor_select.rs b/thoth-app/src/component/contributor_select.rs deleted file mode 100644 index 0c77e191..00000000 --- a/thoth-app/src/component/contributor_select.rs +++ /dev/null @@ -1,195 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use gloo_timers::callback::Timeout; -use thoth_api::model::contributor::Contributor; -use yew::html; -use yew::prelude::*; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; - -use crate::models::contributor::contributors_query::ContributorsRequest; -use crate::models::contributor::contributors_query::ContributorsRequestBody; -use crate::models::contributor::contributors_query::FetchActionContributors; -use crate::models::contributor::contributors_query::FetchContributors; -use crate::models::contributor::contributors_query::Variables; -use crate::models::Dropdown; -use crate::DEFAULT_DEBOUNCING_TIMEOUT; - -use super::ToElementValue; - -pub struct ContributorSelectComponent { - contributors: Vec, - fetch_contributors: FetchContributors, - search_callback: Callback<()>, - search_query: String, - debounce_timeout: Option, - show_results: bool, -} - -pub enum Msg { - SetContributorsFetchState(FetchActionContributors), - GetContributors, - SearchQueryChanged(String), - SearchContributor, - ToggleSearchResultDisplay(bool), - SelectContributor(Contributor), -} - -#[derive(PartialEq, Properties)] -pub struct Props { - pub callback: Callback, -} - -impl Component for ContributorSelectComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let contributors: Vec = Default::default(); - let body = ContributorsRequestBody { - variables: Variables { - limit: Some(100), - ..Default::default() - }, - ..Default::default() - }; - let request = ContributorsRequest { body }; - let fetch_contributors = Fetch::new(request); - let search_callback = ctx.link().callback(|_| Msg::SearchContributor); - let search_query: String = Default::default(); - let debounce_timeout: Option = None; - let show_results = false; - - ctx.link().send_message(Msg::GetContributors); - - ContributorSelectComponent { - contributors, - fetch_contributors, - search_callback, - search_query, - debounce_timeout, - show_results, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetContributorsFetchState(fetch_state) => { - self.fetch_contributors.apply(fetch_state); - self.contributors = match self.fetch_contributors.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.contributors.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetContributors => { - ctx.link().send_future( - self.fetch_contributors - .fetch(Msg::SetContributorsFetchState), - ); - ctx.link() - .send_message(Msg::SetContributorsFetchState(FetchAction::Fetching)); - false - } - Msg::SearchQueryChanged(value) => { - self.search_query = value; - // cancel previous timeout - self.debounce_timeout = self.debounce_timeout.take().and_then(|timeout| { - timeout.cancel(); - None - }); - - if !self.search_query.is_empty() { - // start new timeout - let search_callback = self.search_callback.clone(); - let timeout = Timeout::new(DEFAULT_DEBOUNCING_TIMEOUT, move || { - search_callback.emit(()); - }); - self.debounce_timeout = Some(timeout); - } else { - self.contributors = Default::default(); - } - false - } - Msg::SearchContributor => { - let body = ContributorsRequestBody { - variables: Variables { - filter: Some(self.search_query.clone()), - limit: Some(25), - ..Default::default() - }, - ..Default::default() - }; - let request = ContributorsRequest { body }; - self.fetch_contributors = Fetch::new(request); - ctx.link().send_message(Msg::GetContributors); - false - } - Msg::ToggleSearchResultDisplay(value) => { - self.show_results = value; - true - } - Msg::SelectContributor(contributor) => { - ctx.props().callback.emit(contributor); - false - } - } - } - - fn view(&self, ctx: &Context) -> Html { - let show_results = self.show_results && !self.contributors.is_empty(); - let dropdown_status = match show_results { - true => "dropdown is-active".to_string(), - false => "dropdown".to_string(), - }; - - html! { -
- - { - if show_results { - html! { - - } - } else { - html! {} - } - } -
- } - } -} diff --git a/thoth-app/src/component/contributors.rs b/thoth-app/src/component/contributors.rs deleted file mode 100644 index 3be14b09..00000000 --- a/thoth-app/src/component/contributors.rs +++ /dev/null @@ -1,34 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use crate::models::contributor::contributors_query::ContributorsRequest; -use crate::models::contributor::contributors_query::ContributorsRequestBody; -use crate::models::contributor::contributors_query::FetchActionContributors; -use crate::models::contributor::contributors_query::FetchContributors; -use crate::models::contributor::contributors_query::Variables; -use thoth_api::model::contributor::Contributor; -use thoth_api::model::contributor::ContributorField; -use thoth_api::model::contributor::ContributorOrderBy; - -use super::ToElementValue; - -pagination_component! { - ContributorsComponent, - Contributor, - contributors, - contributor_count, - ContributorsRequest, - FetchActionContributors, - FetchContributors, - ContributorsRequestBody, - Variables, - SEARCH_CONTRIBUTORS, - PAGINATION_COUNT_CONTRIBUTORS, - vec![ - ContributorField::ContributorId.to_string(), - ContributorField::FullName.to_string(), - ContributorField::Orcid.to_string(), - ContributorField::UpdatedAt.to_string(), - ], - ContributorOrderBy, - ContributorField, -} diff --git a/thoth-app/src/component/dashboard.rs b/thoth-app/src/component/dashboard.rs deleted file mode 100644 index 56710bae..00000000 --- a/thoth-app/src/component/dashboard.rs +++ /dev/null @@ -1,224 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::account::model::AccountAccess; -use thoth_api::account::model::AccountDetails; -use thoth_errors::ThothError; -use yew::html; -use yew::prelude::*; -use yew_router::prelude::Link; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::component::utils::Loader; -use crate::component::utils::Reloader; -use crate::models::stats::stats_query::FetchActionStats; -use crate::models::stats::stats_query::FetchStats; -use crate::models::stats::stats_query::StatsRequest; -use crate::models::stats::stats_query::StatsRequestBody; -use crate::models::stats::stats_query::Variables; -use crate::route::AdminRoute; - -pub struct DashboardComponent { - get_stats: FetchStats, - // Store props value locally in order to test whether it has been updated on props change - resource_access: AccountAccess, -} - -pub enum Msg { - SetStatsFetchState(FetchActionStats), - GetStats, -} - -#[derive(PartialEq, Eq, Properties)] -pub struct Props { - pub current_user: AccountDetails, -} - -impl Component for DashboardComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - ctx.link().send_message(Msg::GetStats); - - DashboardComponent { - get_stats: Default::default(), - resource_access: ctx.props().current_user.resource_access.clone(), - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetStatsFetchState(fetch_state) => { - self.get_stats.apply(fetch_state); - true - } - Msg::GetStats => { - let body = StatsRequestBody { - variables: Variables { - publishers: ctx.props().current_user.resource_access.restricted_to(), - }, - ..Default::default() - }; - let request = StatsRequest { body }; - self.get_stats = Fetch::new(request); - - ctx.link() - .send_future(self.get_stats.fetch(Msg::SetStatsFetchState)); - ctx.link() - .send_message(Msg::SetStatsFetchState(FetchAction::Fetching)); - false - } - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - let updated_permissions = self - .resource_access - .neq_assign(ctx.props().current_user.resource_access.clone()); - if updated_permissions { - ctx.link().send_message(Msg::GetStats); - } - false - } - - fn view(&self, ctx: &Context) -> Html { - match self.get_stats.as_ref().state() { - FetchState::NotFetching(_) => { - html! {} - } - FetchState::Fetching(_) => html! {}, - FetchState::Fetched(body) => html! { -
-
-
-
-
-
-

- {format!("{} Works", body.data.work_count)} -

- - to={ AdminRoute::Works } - > - {"See all"} - > -
-
-
-
-

- {format!("{} Books", body.data.book_count)} -

- - to={ AdminRoute::Books } - > - {"See all"} - > -
-
-
-
-

- {format!("{} Chapters", body.data.chapter_count)} -

- - to={ AdminRoute::Chapters } - > - {"See all"} - > -
-
-
-
-
-
- -
-
-

- {format!("{} Contributors", body.data.contributor_count)} -

- - to={ AdminRoute::Contributors } - > - {"See all"} - > -
-
-
-
-

- {format!("{} Publishers", body.data.publisher_count)} -

- - to={ AdminRoute::Publishers } - > - {"See all"} - > -
-
-
-
-
-
-
-
-

- {format!("{} Series", body.data.series_count)} -

- - to={ AdminRoute::Serieses } - > - {"See all"} - > -
-
-
-
-

- {format!("{} Imprints", body.data.imprint_count)} -

- - to={ AdminRoute::Imprints } - > - {"See all"} - > -
-
-
-
-

- {format!("{} Institutions", body.data.institution_count)} -

- - to={ AdminRoute::Institutions } - > - {"See all"} - > -
-
-
-
-
-
- }, - FetchState::Failed(_, err) => html! { - { ThothError::from(err).to_string() } - }, - } - } -} diff --git a/thoth-app/src/component/delete_dialogue.rs b/thoth-app/src/component/delete_dialogue.rs deleted file mode 100644 index 0635577f..00000000 --- a/thoth-app/src/component/delete_dialogue.rs +++ /dev/null @@ -1,106 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use crate::string::CANCEL_BUTTON; -use crate::string::DELETE_BUTTON; -use yew::html; -use yew::prelude::*; - -pub struct ConfirmDeleteComponent { - show: bool, -} - -#[derive(PartialEq, Properties)] -pub struct Props { - pub onclick: Option>, - pub object_name: String, - #[prop_or_default] - pub deactivated: bool, -} - -pub enum Msg { - ToggleConfirmDeleteDisplay(bool), -} - -impl Component for ConfirmDeleteComponent { - type Message = Msg; - type Properties = Props; - - fn create(_ctx: &Context) -> Self { - ConfirmDeleteComponent { show: false } - } - - fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleConfirmDeleteDisplay(value) => { - self.show = value; - true - } - } - } - - fn view(&self, ctx: &Context) -> Html { - let open_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleConfirmDeleteDisplay(true) - }); - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleConfirmDeleteDisplay(false) - }); - html! { - <> - -
- - -
- - } - } -} - -impl ConfirmDeleteComponent { - fn confirm_delete_status(&self) -> String { - match self.show { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } -} diff --git a/thoth-app/src/component/fundings_form.rs b/thoth-app/src/component/fundings_form.rs deleted file mode 100644 index ea62dbe2..00000000 --- a/thoth-app/src/component/fundings_form.rs +++ /dev/null @@ -1,390 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::model::funding::FundingWithInstitution; -use thoth_api::model::institution::Institution; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::institution_select::InstitutionSelectComponent; -use crate::component::utils::FormTextInput; -use crate::models::funding::create_funding_mutation::CreateFundingRequest; -use crate::models::funding::create_funding_mutation::CreateFundingRequestBody; -use crate::models::funding::create_funding_mutation::PushActionCreateFunding; -use crate::models::funding::create_funding_mutation::PushCreateFunding; -use crate::models::funding::create_funding_mutation::Variables as CreateVariables; -use crate::models::funding::delete_funding_mutation::DeleteFundingRequest; -use crate::models::funding::delete_funding_mutation::DeleteFundingRequestBody; -use crate::models::funding::delete_funding_mutation::PushActionDeleteFunding; -use crate::models::funding::delete_funding_mutation::PushDeleteFunding; -use crate::models::funding::delete_funding_mutation::Variables as DeleteVariables; -use crate::string::CANCEL_BUTTON; -use crate::string::EMPTY_FUNDINGS; -use crate::string::REMOVE_BUTTON; - -use super::ToElementValue; -use super::ToOption; - -pub struct FundingsFormComponent { - new_funding: FundingWithInstitution, - show_add_form: bool, - push_funding: PushCreateFunding, - delete_funding: PushDeleteFunding, - notification_bus: NotificationDispatcher, -} - -#[allow(clippy::large_enum_variant)] -pub enum Msg { - ToggleAddFormDisplay(bool), - SetFundingPushState(PushActionCreateFunding), - CreateFunding, - SetFundingDeleteState(PushActionDeleteFunding), - DeleteFunding(Uuid), - AddFunding(Institution), - ChangeProgram(String), - ChangeProjectName(String), - ChangeProjectShortname(String), - ChangeGrant(String), - ChangeJurisdiction(String), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub fundings: Option>, - pub work_id: Uuid, - pub update_fundings: Callback>>, -} - -impl Component for FundingsFormComponent { - type Message = Msg; - type Properties = Props; - - fn create(_: &Context) -> Self { - let new_funding: FundingWithInstitution = Default::default(); - let show_add_form = false; - let push_funding = Default::default(); - let delete_funding = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - - FundingsFormComponent { - new_funding, - show_add_form, - push_funding, - delete_funding, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleAddFormDisplay(value) => { - self.show_add_form = value; - true - } - Msg::SetFundingPushState(fetch_state) => { - self.push_funding.apply(fetch_state); - match self.push_funding.clone().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_funding { - Some(i) => { - let funding = i.clone(); - let mut fundings: Vec = - ctx.props().fundings.clone().unwrap_or_default(); - fundings.push(funding); - ctx.props().update_fundings.emit(Some(fundings)); - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - true - } - None => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateFunding => { - let body = CreateFundingRequestBody { - variables: CreateVariables { - work_id: ctx.props().work_id, - institution_id: self.new_funding.institution_id, - program: self.new_funding.program.clone(), - project_name: self.new_funding.project_name.clone(), - project_shortname: self.new_funding.project_shortname.clone(), - grant_number: self.new_funding.grant_number.clone(), - jurisdiction: self.new_funding.jurisdiction.clone(), - }, - ..Default::default() - }; - let request = CreateFundingRequest { body }; - self.push_funding = Fetch::new(request); - ctx.link() - .send_future(self.push_funding.fetch(Msg::SetFundingPushState)); - ctx.link() - .send_message(Msg::SetFundingPushState(FetchAction::Fetching)); - false - } - Msg::SetFundingDeleteState(fetch_state) => { - self.delete_funding.apply(fetch_state); - match self.delete_funding.clone().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_funding { - Some(funding) => { - let to_keep: Vec = ctx - .props() - .fundings - .clone() - .unwrap_or_default() - .into_iter() - .filter(|f| f.funding_id != funding.funding_id) - .collect(); - ctx.props().update_fundings.emit(Some(to_keep)); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteFunding(funding_id) => { - let body = DeleteFundingRequestBody { - variables: DeleteVariables { funding_id }, - ..Default::default() - }; - let request = DeleteFundingRequest { body }; - self.delete_funding = Fetch::new(request); - ctx.link() - .send_future(self.delete_funding.fetch(Msg::SetFundingDeleteState)); - ctx.link() - .send_message(Msg::SetFundingDeleteState(FetchAction::Fetching)); - false - } - Msg::AddFunding(institution) => { - self.new_funding.institution_id = institution.institution_id; - self.new_funding.institution = institution; - ctx.link().send_message(Msg::ToggleAddFormDisplay(true)); - true - } - Msg::ChangeProgram(val) => self.new_funding.program.neq_assign(val.to_opt_string()), - Msg::ChangeProjectName(val) => self - .new_funding - .project_name - .neq_assign(val.to_opt_string()), - Msg::ChangeProjectShortname(val) => self - .new_funding - .project_shortname - .neq_assign(val.to_opt_string()), - Msg::ChangeGrant(val) => self - .new_funding - .grant_number - .neq_assign(val.to_opt_string()), - Msg::ChangeJurisdiction(val) => self - .new_funding - .jurisdiction - .neq_assign(val.to_opt_string()), - } - } - - fn view(&self, ctx: &Context) -> Html { - let fundings = ctx.props().fundings.clone().unwrap_or_default(); - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleAddFormDisplay(false) - }); - let institution_select_callback = ctx.link().callback(Msg::AddFunding); - - html! { - - } - } -} - -impl FundingsFormComponent { - fn add_form_status(&self) -> String { - match self.show_add_form { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } - - fn render_funding(&self, ctx: &Context, f: &FundingWithInstitution) -> Html { - let funding_id = f.funding_id; - html! { -
- - - -
-
- -
- {&f.institution.institution_name} -
-
-
- -
- {&f.program.clone().unwrap_or_default()} -
-
-
- -
- {&f.project_name.clone().unwrap_or_default()} -
-
-
- -
- {&f.project_shortname.clone().unwrap_or_default()} -
-
-
- -
- {&f.grant_number.clone().unwrap_or_default()} -
-
-
- -
- {&f.jurisdiction.clone().unwrap_or_default()} -
-
- -
-
- } - } -} diff --git a/thoth-app/src/component/imprint.rs b/thoth-app/src/component/imprint.rs deleted file mode 100644 index 4541b31b..00000000 --- a/thoth-app/src/component/imprint.rs +++ /dev/null @@ -1,444 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::account::model::AccountAccess; -use thoth_api::account::model::AccountDetails; -use thoth_api::model::imprint::ImprintWithPublisher; -use thoth_api::model::publisher::Publisher; -use thoth_api::model::{Doi, DOI_DOMAIN}; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::delete_dialogue::ConfirmDeleteComponent; -use crate::component::utils::FormPublisherSelect; -use crate::component::utils::FormTextInput; -use crate::component::utils::FormTextInputExtended; -use crate::component::utils::FormUrlInput; -use crate::component::utils::Loader; -use crate::models::imprint::delete_imprint_mutation::DeleteImprintRequest; -use crate::models::imprint::delete_imprint_mutation::DeleteImprintRequestBody; -use crate::models::imprint::delete_imprint_mutation::PushActionDeleteImprint; -use crate::models::imprint::delete_imprint_mutation::PushDeleteImprint; -use crate::models::imprint::delete_imprint_mutation::Variables as DeleteVariables; -use crate::models::imprint::imprint_query::FetchActionImprint; -use crate::models::imprint::imprint_query::FetchImprint; -use crate::models::imprint::imprint_query::ImprintRequest; -use crate::models::imprint::imprint_query::ImprintRequestBody; -use crate::models::imprint::imprint_query::Variables; -use crate::models::imprint::update_imprint_mutation::PushActionUpdateImprint; -use crate::models::imprint::update_imprint_mutation::PushUpdateImprint; -use crate::models::imprint::update_imprint_mutation::UpdateImprintRequest; -use crate::models::imprint::update_imprint_mutation::UpdateImprintRequestBody; -use crate::models::imprint::update_imprint_mutation::Variables as UpdateVariables; -use crate::models::publisher::publishers_query::FetchActionPublishers; -use crate::models::publisher::publishers_query::FetchPublishers; -use crate::models::publisher::publishers_query::PublishersRequest; -use crate::models::publisher::publishers_query::PublishersRequestBody; -use crate::models::publisher::publishers_query::Variables as PublishersVariables; -use crate::route::AdminRoute; -use crate::string::SAVE_BUTTON; - -use super::ToElementValue; -use super::ToOption; - -pub struct ImprintComponent { - imprint: ImprintWithPublisher, - fetch_imprint: FetchImprint, - push_imprint: PushUpdateImprint, - delete_imprint: PushDeleteImprint, - data: ImprintFormData, - fetch_publishers: FetchPublishers, - notification_bus: NotificationDispatcher, - // Store props value locally in order to test whether it has been updated on props change - resource_access: AccountAccess, - // Track the user-entered DOI string, which may not be validly formatted - crossmark_doi: String, - crossmark_doi_warning: String, -} - -#[derive(Default)] -struct ImprintFormData { - publishers: Vec, -} - -pub enum Msg { - SetPublishersFetchState(FetchActionPublishers), - GetPublishers, - SetImprintFetchState(FetchActionImprint), - GetImprint, - SetImprintPushState(PushActionUpdateImprint), - UpdateImprint, - SetImprintDeleteState(PushActionDeleteImprint), - DeleteImprint, - ChangePublisher(Uuid), - ChangeImprintName(String), - ChangeImprintUrl(String), - ChangeCrossmarkDoi(String), -} - -#[derive(PartialEq, Eq, Properties)] -pub struct Props { - pub imprint_id: Uuid, - pub current_user: AccountDetails, -} - -impl Component for ImprintComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let fetch_imprint: FetchImprint = Default::default(); - let data: ImprintFormData = Default::default(); - let fetch_publishers: FetchPublishers = Default::default(); - let push_imprint = Default::default(); - let delete_imprint = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let imprint: ImprintWithPublisher = Default::default(); - let resource_access = ctx.props().current_user.resource_access.clone(); - let crossmark_doi = Default::default(); - let crossmark_doi_warning = Default::default(); - - ctx.link().send_message(Msg::GetImprint); - ctx.link().send_message(Msg::GetPublishers); - - ImprintComponent { - imprint, - fetch_imprint, - push_imprint, - delete_imprint, - data, - fetch_publishers, - notification_bus, - resource_access, - crossmark_doi, - crossmark_doi_warning, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetPublishersFetchState(fetch_state) => { - self.fetch_publishers.apply(fetch_state); - self.data.publishers = match self.fetch_publishers.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.publishers.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetPublishers => { - let body = PublishersRequestBody { - variables: PublishersVariables { - limit: Some(100), - publishers: ctx.props().current_user.resource_access.restricted_to(), - ..Default::default() - }, - ..Default::default() - }; - let request = PublishersRequest { body }; - self.fetch_publishers = Fetch::new(request); - - ctx.link() - .send_future(self.fetch_publishers.fetch(Msg::SetPublishersFetchState)); - ctx.link() - .send_message(Msg::SetPublishersFetchState(FetchAction::Fetching)); - false - } - Msg::SetImprintFetchState(fetch_state) => { - self.fetch_imprint.apply(fetch_state); - match self.fetch_imprint.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => { - self.imprint = match &body.data.imprint { - Some(c) => c.to_owned(), - None => Default::default(), - }; - // Initialise user-entered DOI variable to match DOI in database - self.crossmark_doi = self - .imprint - .crossmark_doi - .clone() - .unwrap_or_default() - .to_string(); - // If user doesn't have permission to edit this object, redirect to dashboard - if let Some(publishers) = - ctx.props().current_user.resource_access.restricted_to() - { - if !publishers - .contains(&self.imprint.publisher.publisher_id.to_string()) - { - ctx.link().history().unwrap().push(AdminRoute::Dashboard); - } - } - true - } - FetchState::Failed(_, _err) => false, - } - } - Msg::GetImprint => { - let body = ImprintRequestBody { - variables: Variables { - imprint_id: Some(ctx.props().imprint_id), - }, - ..Default::default() - }; - let request = ImprintRequest { body }; - self.fetch_imprint = Fetch::new(request); - - ctx.link() - .send_future(self.fetch_imprint.fetch(Msg::SetImprintFetchState)); - ctx.link() - .send_message(Msg::SetImprintFetchState(FetchAction::Fetching)); - false - } - Msg::SetImprintPushState(fetch_state) => { - self.push_imprint.apply(fetch_state); - match self.push_imprint.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.update_imprint { - Some(i) => { - self.crossmark_doi = self - .imprint - .crossmark_doi - .clone() - .unwrap_or_default() - .to_string(); - self.crossmark_doi_warning.clear(); - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Saved {}", i.imprint_name), - NotificationStatus::Success, - ))); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::UpdateImprint => { - // Only update the DOI value with the current user-entered string - // if it is validly formatted - otherwise keep the default. - // If no DOI was provided, no format check is required. - if self.crossmark_doi.is_empty() { - self.imprint.crossmark_doi.neq_assign(None); - } else if let Ok(result) = self.crossmark_doi.parse::() { - self.imprint.crossmark_doi.neq_assign(Some(result)); - } - let body = UpdateImprintRequestBody { - variables: UpdateVariables { - imprint_id: self.imprint.imprint_id, - imprint_name: self.imprint.imprint_name.clone(), - imprint_url: self.imprint.imprint_url.clone(), - crossmark_doi: self.imprint.crossmark_doi.clone(), - publisher_id: self.imprint.publisher.publisher_id, - }, - ..Default::default() - }; - let request = UpdateImprintRequest { body }; - self.push_imprint = Fetch::new(request); - ctx.link() - .send_future(self.push_imprint.fetch(Msg::SetImprintPushState)); - ctx.link() - .send_message(Msg::SetImprintPushState(FetchAction::Fetching)); - false - } - Msg::SetImprintDeleteState(fetch_state) => { - self.delete_imprint.apply(fetch_state); - match self.delete_imprint.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_imprint { - Some(i) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Deleted {}", i.imprint_name), - NotificationStatus::Success, - ))); - ctx.link().history().unwrap().push(AdminRoute::Imprints); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteImprint => { - let body = DeleteImprintRequestBody { - variables: DeleteVariables { - imprint_id: self.imprint.imprint_id, - }, - ..Default::default() - }; - let request = DeleteImprintRequest { body }; - self.delete_imprint = Fetch::new(request); - ctx.link() - .send_future(self.delete_imprint.fetch(Msg::SetImprintDeleteState)); - ctx.link() - .send_message(Msg::SetImprintDeleteState(FetchAction::Fetching)); - false - } - Msg::ChangePublisher(publisher_id) => { - if let Some(publisher) = self - .data - .publishers - .iter() - .find(|p| p.publisher_id == publisher_id) - { - self.imprint.publisher.neq_assign(publisher.clone()) - } else { - // Publisher not found: clear existing selection - self.imprint.publisher.neq_assign(Default::default()) - } - } - Msg::ChangeImprintName(imprint_name) => self - .imprint - .imprint_name - .neq_assign(imprint_name.trim().to_owned()), - Msg::ChangeImprintUrl(value) => { - self.imprint.imprint_url.neq_assign(value.to_opt_string()) - } - Msg::ChangeCrossmarkDoi(value) => { - if self.crossmark_doi.neq_assign(value.trim().to_owned()) { - // If DOI is not correctly formatted, display a warning. - // Don't update self.imprint.crossmark_doi yet, as user may later - // overwrite a new valid value with an invalid one. - self.crossmark_doi_warning.clear(); - match self.crossmark_doi.parse::() { - Err(e) => { - match e { - // If no DOI was provided, no warning is required. - ThothError::DoiEmptyError => {} - _ => self.crossmark_doi_warning = e.to_string(), - } - } - Ok(value) => self.crossmark_doi = value.to_string(), - } - true - } else { - false - } - } - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - let updated_permissions = self - .resource_access - .neq_assign(ctx.props().current_user.resource_access.clone()); - if updated_permissions { - ctx.link().send_message(Msg::GetPublishers); - } - false - } - - fn view(&self, ctx: &Context) -> Html { - match self.fetch_imprint.as_ref().state() { - FetchState::NotFetching(_) => html! {}, - FetchState::Fetching(_) => html! {}, - FetchState::Fetched(_body) => { - let callback = ctx.link().callback(|event: FocusEvent| { - event.prevent_default(); - Msg::UpdateImprint - }); - html! { - <> - - -
- - - - - -
-
- -
-
- - - } - } - FetchState::Failed(_, err) => html! { - { ThothError::from(err).to_string() } - }, - } - } -} diff --git a/thoth-app/src/component/imprints.rs b/thoth-app/src/component/imprints.rs deleted file mode 100644 index 097eeed5..00000000 --- a/thoth-app/src/component/imprints.rs +++ /dev/null @@ -1,35 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use crate::models::imprint::imprints_query::FetchActionImprints; -use crate::models::imprint::imprints_query::FetchImprints; -use crate::models::imprint::imprints_query::ImprintsRequest; -use crate::models::imprint::imprints_query::ImprintsRequestBody; -use crate::models::imprint::imprints_query::Variables; -use thoth_api::model::imprint::ImprintField; -use thoth_api::model::imprint::ImprintOrderBy; -use thoth_api::model::imprint::ImprintWithPublisher; - -use super::ToElementValue; - -pagination_component! { - ImprintsComponent, - ImprintWithPublisher, - imprints, - imprint_count, - ImprintsRequest, - FetchActionImprints, - FetchImprints, - ImprintsRequestBody, - Variables, - SEARCH_IMPRINTS, - PAGINATION_COUNT_IMPRINTS, - vec![ - ImprintField::ImprintId.to_string(), - ImprintField::ImprintName.to_string(), - "Publisher".to_string(), - ImprintField::ImprintUrl.to_string(), - ImprintField::UpdatedAt.to_string(), - ], - ImprintOrderBy, - ImprintField, -} diff --git a/thoth-app/src/component/institution.rs b/thoth-app/src/component/institution.rs deleted file mode 100644 index bd1d9be2..00000000 --- a/thoth-app/src/component/institution.rs +++ /dev/null @@ -1,532 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use std::str::FromStr; -use thoth_api::account::model::AccountDetails; -use thoth_api::model::institution::CountryCode; -use thoth_api::model::institution::Institution; -use thoth_api::model::work::WorkWithRelations; -use thoth_api::model::{Doi, Ror, DOI_DOMAIN, ROR_DOMAIN}; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Bridge; -use yew_agent::Bridged; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::Link; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::institution_activity_checker::InstitutionActivityChecker; -use crate::agent::institution_activity_checker::Request as InstitutionActivityRequest; -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::delete_dialogue::ConfirmDeleteComponent; -use crate::component::utils::FormCountryCodeSelect; -use crate::component::utils::FormTextInput; -use crate::component::utils::FormTextInputExtended; -use crate::component::utils::Loader; -use crate::models::institution::country_codes_query::FetchActionCountryCodes; -use crate::models::institution::country_codes_query::FetchCountryCodes; -use crate::models::institution::delete_institution_mutation::DeleteInstitutionRequest; -use crate::models::institution::delete_institution_mutation::DeleteInstitutionRequestBody; -use crate::models::institution::delete_institution_mutation::PushActionDeleteInstitution; -use crate::models::institution::delete_institution_mutation::PushDeleteInstitution; -use crate::models::institution::delete_institution_mutation::Variables as DeleteVariables; -use crate::models::institution::institution_activity_query::InstitutionActivityResponseData; -use crate::models::institution::institution_query::FetchActionInstitution; -use crate::models::institution::institution_query::FetchInstitution; -use crate::models::institution::institution_query::InstitutionRequest; -use crate::models::institution::institution_query::InstitutionRequestBody; -use crate::models::institution::institution_query::Variables; -use crate::models::institution::update_institution_mutation::PushActionUpdateInstitution; -use crate::models::institution::update_institution_mutation::PushUpdateInstitution; -use crate::models::institution::update_institution_mutation::UpdateInstitutionRequest; -use crate::models::institution::update_institution_mutation::UpdateInstitutionRequestBody; -use crate::models::institution::update_institution_mutation::Variables as UpdateVariables; -use crate::models::institution::CountryCodeValues; -use crate::models::EditRoute; -use crate::route::AdminRoute; -use crate::string::SAVE_BUTTON; - -use super::ToElementValue; - -pub struct InstitutionComponent { - institution: Institution, - fetch_country_codes: FetchCountryCodes, - // Track the user-entered DOI string, which may not be validly formatted - institution_doi: String, - institution_doi_warning: String, - // Track the user-entered ROR string, which may not be validly formatted - ror: String, - ror_warning: String, - fetch_institution: FetchInstitution, - push_institution: PushUpdateInstitution, - delete_institution: PushDeleteInstitution, - data: InstitutionFormData, - notification_bus: NotificationDispatcher, - _institution_activity_checker: Box>, - funded_works: Vec, - affiliated_works: Vec, -} - -#[derive(Default)] -struct InstitutionFormData { - country_codes: Vec, -} - -pub enum Msg { - SetCountryCodesFetchState(FetchActionCountryCodes), - GetCountryCodes, - GetInstitutionActivity(InstitutionActivityResponseData), - SetInstitutionFetchState(FetchActionInstitution), - GetInstitution, - SetInstitutionPushState(PushActionUpdateInstitution), - UpdateInstitution, - SetInstitutionDeleteState(PushActionDeleteInstitution), - DeleteInstitution, - ChangeInstitutionName(String), - ChangeInstitutionDoi(String), - ChangeRor(String), - ChangeCountryCode(String), -} - -#[derive(PartialEq, Eq, Properties)] -pub struct Props { - pub institution_id: Uuid, - pub current_user: AccountDetails, -} - -impl Component for InstitutionComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let body = InstitutionRequestBody { - variables: Variables { - institution_id: Some(ctx.props().institution_id), - }, - ..Default::default() - }; - let request = InstitutionRequest { body }; - let fetch_institution = Fetch::new(request); - let push_institution = Default::default(); - let delete_institution = Default::default(); - let data: InstitutionFormData = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let institution: Institution = Default::default(); - let fetch_country_codes = Default::default(); - let institution_doi = Default::default(); - let institution_doi_warning = Default::default(); - let ror = Default::default(); - let ror_warning = Default::default(); - let mut _institution_activity_checker = - InstitutionActivityChecker::bridge(ctx.link().callback(Msg::GetInstitutionActivity)); - let funded_works = Default::default(); - let affiliated_works = Default::default(); - - ctx.link().send_message(Msg::GetInstitution); - ctx.link().send_message(Msg::GetCountryCodes); - _institution_activity_checker.send( - InstitutionActivityRequest::RetrieveInstitutionActivity(ctx.props().institution_id), - ); - - InstitutionComponent { - institution, - fetch_country_codes, - institution_doi, - institution_doi_warning, - ror, - ror_warning, - fetch_institution, - push_institution, - delete_institution, - data, - notification_bus, - _institution_activity_checker, - funded_works, - affiliated_works, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetCountryCodesFetchState(fetch_state) => { - self.fetch_country_codes.apply(fetch_state); - self.data.country_codes = match self.fetch_country_codes.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.country_codes.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetCountryCodes => { - ctx.link().send_future( - self.fetch_country_codes - .fetch(Msg::SetCountryCodesFetchState), - ); - ctx.link() - .send_message(Msg::SetCountryCodesFetchState(FetchAction::Fetching)); - false - } - Msg::GetInstitutionActivity(response) => { - let mut should_render = false; - if let Some(institution) = response.institution { - if let Some(fundings) = institution.fundings { - if !fundings.is_empty() { - self.funded_works = fundings.iter().map(|f| f.work.clone()).collect(); - self.funded_works.sort_by_key(|f| f.work_id); - self.funded_works.dedup_by_key(|f| f.work_id); - should_render = true; - } - } - if let Some(affiliations) = institution.affiliations { - if !affiliations.is_empty() { - self.affiliated_works = affiliations - .iter() - .map(|a| a.contribution.work.clone()) - .collect(); - self.affiliated_works.sort_by_key(|a| a.work_id); - self.affiliated_works.dedup_by_key(|a| a.work_id); - should_render = true; - } - } - } - should_render - } - Msg::SetInstitutionFetchState(fetch_state) => { - self.fetch_institution.apply(fetch_state); - match self.fetch_institution.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => { - self.institution = match &body.data.institution { - Some(c) => c.to_owned(), - None => Default::default(), - }; - // Initialise user-entered DOI variable to match DOI in database - self.institution_doi = self - .institution - .institution_doi - .clone() - .unwrap_or_default() - .to_string(); - // Initialise user-entered ROR variable to match ROR in database - self.ror = self.institution.ror.clone().unwrap_or_default().to_string(); - true - } - FetchState::Failed(_, _err) => false, - } - } - Msg::GetInstitution => { - ctx.link() - .send_future(self.fetch_institution.fetch(Msg::SetInstitutionFetchState)); - ctx.link() - .send_message(Msg::SetInstitutionFetchState(FetchAction::Fetching)); - false - } - Msg::SetInstitutionPushState(fetch_state) => { - self.push_institution.apply(fetch_state); - match self.push_institution.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.update_institution { - Some(i) => { - // Save was successful: update user-entered DOI variable to match DOI in database - self.institution_doi = self - .institution - .institution_doi - .clone() - .unwrap_or_default() - .to_string(); - self.institution_doi_warning.clear(); - // Save was successful: update user-entered ROR variable to match ROR in database - self.ror = self.institution.ror.clone().unwrap_or_default().to_string(); - self.ror_warning.clear(); - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Saved {}", i.institution_name), - NotificationStatus::Success, - ))); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::UpdateInstitution => { - // Only update the DOI value with the current user-entered string - // if it is validly formatted - otherwise keep the database version. - // If no DOI was provided, no format check is required. - if self.institution_doi.is_empty() { - self.institution.institution_doi.neq_assign(None); - } else if let Ok(result) = self.institution_doi.parse::() { - self.institution.institution_doi.neq_assign(Some(result)); - } - // Only update the ROR value with the current user-entered string - // if it is validly formatted - otherwise keep the database version. - // If no ROR was provided, no format check is required. - if self.ror.is_empty() { - self.institution.ror.neq_assign(None); - } else if let Ok(result) = self.ror.parse::() { - self.institution.ror.neq_assign(Some(result)); - } - let body = UpdateInstitutionRequestBody { - variables: UpdateVariables { - institution_id: self.institution.institution_id, - institution_name: self.institution.institution_name.clone(), - institution_doi: self.institution.institution_doi.clone(), - ror: self.institution.ror.clone(), - country_code: self.institution.country_code, - }, - ..Default::default() - }; - let request = UpdateInstitutionRequest { body }; - self.push_institution = Fetch::new(request); - ctx.link() - .send_future(self.push_institution.fetch(Msg::SetInstitutionPushState)); - ctx.link() - .send_message(Msg::SetInstitutionPushState(FetchAction::Fetching)); - false - } - Msg::SetInstitutionDeleteState(fetch_state) => { - self.delete_institution.apply(fetch_state); - match self.delete_institution.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_institution { - Some(i) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Deleted {}", i.institution_name), - NotificationStatus::Success, - ))); - ctx.link().history().unwrap().push(AdminRoute::Institutions); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteInstitution => { - let body = DeleteInstitutionRequestBody { - variables: DeleteVariables { - institution_id: self.institution.institution_id, - }, - ..Default::default() - }; - let request = DeleteInstitutionRequest { body }; - self.delete_institution = Fetch::new(request); - ctx.link().send_future( - self.delete_institution - .fetch(Msg::SetInstitutionDeleteState), - ); - ctx.link() - .send_message(Msg::SetInstitutionDeleteState(FetchAction::Fetching)); - false - } - Msg::ChangeInstitutionName(institution_name) => self - .institution - .institution_name - .neq_assign(institution_name.trim().to_owned()), - Msg::ChangeInstitutionDoi(value) => { - if self.institution_doi.neq_assign(value.trim().to_owned()) { - // If DOI is not correctly formatted, display a warning. - // Don't update self.institution.institution_doi yet, as user may later - // overwrite a new valid value with an invalid one. - self.institution_doi_warning.clear(); - match self.institution_doi.parse::() { - Err(e) => { - match e { - // If no DOI was provided, no warning is required. - ThothError::DoiEmptyError => {} - _ => self.institution_doi_warning = e.to_string(), - } - } - Ok(value) => self.institution_doi = value.to_string(), - } - true - } else { - false - } - } - Msg::ChangeRor(value) => { - if self.ror.neq_assign(value.trim().to_owned()) { - // If ROR is not correctly formatted, display a warning. - // Don't update self.institution.ror yet, as user may later - // overwrite a new valid value with an invalid one. - self.ror_warning.clear(); - match self.ror.parse::() { - Err(e) => { - match e { - // If no ROR was provided, no warning is required. - ThothError::RorEmptyError => {} - _ => self.ror_warning = e.to_string(), - } - } - Ok(value) => self.ror = value.to_string(), - } - true - } else { - false - } - } - Msg::ChangeCountryCode(value) => self - .institution - .country_code - .neq_assign(CountryCode::from_str(&value).ok()), - } - } - - fn view(&self, ctx: &Context) -> Html { - match self.fetch_institution.as_ref().state() { - FetchState::NotFetching(_) => html! {}, - FetchState::Fetching(_) => html! {}, - FetchState::Fetched(_body) => { - let callback = ctx.link().callback(|event: FocusEvent| { - event.prevent_default(); - Msg::UpdateInstitution - }); - let mut delete_callback = Some(ctx.link().callback(|_| Msg::DeleteInstitution)); - let mut delete_deactivated = false; - // If user doesn't have permission to delete this institution (i.e. because it's connected to a work - // from a publisher they're not associated with), deactivate the delete button and unset its callback - if let Some(publishers) = ctx.props().current_user.resource_access.restricted_to() { - for work in [self.affiliated_works.clone(), self.funded_works.clone()].concat() - { - if !publishers.contains(&work.imprint.publisher.publisher_id.to_string()) { - delete_callback = None; - delete_deactivated = true; - break; - } - } - } - html! { - <> - - - { self.render_associated_works(&self.funded_works, "Funded: ") } - - { self.render_associated_works(&self.affiliated_works, "Member(s) contributed to: ") } - -
- - - - - -
-
- -
-
- - - } - } - FetchState::Failed(_, err) => html! { - { ThothError::from(err).to_string() } - }, - } - } -} - -impl InstitutionComponent { - fn render_associated_works(&self, w: &[WorkWithRelations], explanatory_text: &str) -> Html { - { - if !w.is_empty() { - html! { - - } - } else { - html! {} - } - } - } -} diff --git a/thoth-app/src/component/institution_select.rs b/thoth-app/src/component/institution_select.rs deleted file mode 100644 index a825e723..00000000 --- a/thoth-app/src/component/institution_select.rs +++ /dev/null @@ -1,195 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use gloo_timers::callback::Timeout; -use thoth_api::model::institution::Institution; -use yew::html; -use yew::prelude::*; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; - -use crate::models::institution::institutions_query::FetchActionInstitutions; -use crate::models::institution::institutions_query::FetchInstitutions; -use crate::models::institution::institutions_query::InstitutionsRequest; -use crate::models::institution::institutions_query::InstitutionsRequestBody; -use crate::models::institution::institutions_query::Variables; -use crate::models::Dropdown; -use crate::DEFAULT_DEBOUNCING_TIMEOUT; - -use super::ToElementValue; - -pub struct InstitutionSelectComponent { - institutions: Vec, - fetch_institutions: FetchInstitutions, - search_callback: Callback<()>, - search_query: String, - debounce_timeout: Option, - show_results: bool, -} - -pub enum Msg { - SetInstitutionsFetchState(FetchActionInstitutions), - GetInstitutions, - SearchQueryChanged(String), - SearchInstitution, - ToggleSearchResultDisplay(bool), - SelectInstitution(Institution), -} - -#[derive(PartialEq, Properties)] -pub struct Props { - pub callback: Callback, -} - -impl Component for InstitutionSelectComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let institutions: Vec = Default::default(); - let body = InstitutionsRequestBody { - variables: Variables { - limit: Some(100), - ..Default::default() - }, - ..Default::default() - }; - let request = InstitutionsRequest { body }; - let fetch_institutions = Fetch::new(request); - let search_callback = ctx.link().callback(|_| Msg::SearchInstitution); - let search_query: String = Default::default(); - let debounce_timeout: Option = None; - let show_results = false; - - ctx.link().send_message(Msg::GetInstitutions); - - InstitutionSelectComponent { - institutions, - fetch_institutions, - search_callback, - search_query, - debounce_timeout, - show_results, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetInstitutionsFetchState(fetch_state) => { - self.fetch_institutions.apply(fetch_state); - self.institutions = match self.fetch_institutions.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.institutions.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetInstitutions => { - ctx.link().send_future( - self.fetch_institutions - .fetch(Msg::SetInstitutionsFetchState), - ); - ctx.link() - .send_message(Msg::SetInstitutionsFetchState(FetchAction::Fetching)); - false - } - Msg::SearchQueryChanged(value) => { - self.search_query = value; - // cancel previous timeout - self.debounce_timeout = self.debounce_timeout.take().and_then(|timeout| { - timeout.cancel(); - None - }); - - if !self.search_query.is_empty() { - // start new timeout - let search_callback = self.search_callback.clone(); - let timeout = Timeout::new(DEFAULT_DEBOUNCING_TIMEOUT, move || { - search_callback.emit(()); - }); - self.debounce_timeout = Some(timeout); - } else { - self.institutions = Default::default(); - } - false - } - Msg::SearchInstitution => { - let body = InstitutionsRequestBody { - variables: Variables { - filter: Some(self.search_query.clone()), - limit: Some(25), - ..Default::default() - }, - ..Default::default() - }; - let request = InstitutionsRequest { body }; - self.fetch_institutions = Fetch::new(request); - ctx.link().send_message(Msg::GetInstitutions); - false - } - Msg::ToggleSearchResultDisplay(value) => { - self.show_results = value; - true - } - Msg::SelectInstitution(institution) => { - ctx.props().callback.emit(institution); - false - } - } - } - - fn view(&self, ctx: &Context) -> Html { - let show_results = self.show_results && !self.institutions.is_empty(); - let dropdown_status = match show_results { - true => "dropdown is-active".to_string(), - false => "dropdown".to_string(), - }; - - html! { -
- - { - if show_results { - html! { - - } - } else { - html! {} - } - } -
- } - } -} diff --git a/thoth-app/src/component/institutions.rs b/thoth-app/src/component/institutions.rs deleted file mode 100644 index 642e8a9e..00000000 --- a/thoth-app/src/component/institutions.rs +++ /dev/null @@ -1,36 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use crate::models::institution::institutions_query::FetchActionInstitutions; -use crate::models::institution::institutions_query::FetchInstitutions; -use crate::models::institution::institutions_query::InstitutionsRequest; -use crate::models::institution::institutions_query::InstitutionsRequestBody; -use crate::models::institution::institutions_query::Variables; -use thoth_api::model::institution::Institution; -use thoth_api::model::institution::InstitutionField; -use thoth_api::model::institution::InstitutionOrderBy; - -use super::ToElementValue; - -pagination_component! { - InstitutionsComponent, - Institution, - institutions, - institution_count, - InstitutionsRequest, - FetchActionInstitutions, - FetchInstitutions, - InstitutionsRequestBody, - Variables, - SEARCH_INSTITUTIONS, - PAGINATION_COUNT_INSTITUTIONS, - vec![ - InstitutionField::InstitutionId.to_string(), - InstitutionField::InstitutionName.to_string(), - InstitutionField::InstitutionDoi.to_string(), - InstitutionField::Ror.to_string(), - InstitutionField::CountryCode.to_string(), - InstitutionField::UpdatedAt.to_string(), - ], - InstitutionOrderBy, - InstitutionField, -} diff --git a/thoth-app/src/component/issues_form.rs b/thoth-app/src/component/issues_form.rs deleted file mode 100644 index 1d1c8832..00000000 --- a/thoth-app/src/component/issues_form.rs +++ /dev/null @@ -1,516 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use gloo_timers::callback::Timeout; -use thoth_api::account::model::AccountAccess; -use thoth_api::account::model::AccountDetails; -use thoth_api::model::issue::IssueWithSeries; -use thoth_api::model::series::SeriesWithImprint; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormNumberInput; -use crate::models::issue::create_issue_mutation::CreateIssueRequest; -use crate::models::issue::create_issue_mutation::CreateIssueRequestBody; -use crate::models::issue::create_issue_mutation::PushActionCreateIssue; -use crate::models::issue::create_issue_mutation::PushCreateIssue; -use crate::models::issue::create_issue_mutation::Variables as CreateVariables; -use crate::models::issue::delete_issue_mutation::DeleteIssueRequest; -use crate::models::issue::delete_issue_mutation::DeleteIssueRequestBody; -use crate::models::issue::delete_issue_mutation::PushActionDeleteIssue; -use crate::models::issue::delete_issue_mutation::PushDeleteIssue; -use crate::models::issue::delete_issue_mutation::Variables as DeleteVariables; -use crate::models::series::serieses_query::FetchActionSerieses; -use crate::models::series::serieses_query::FetchSerieses; -use crate::models::series::serieses_query::SeriesesRequest; -use crate::models::series::serieses_query::SeriesesRequestBody; -use crate::models::series::serieses_query::Variables; -use crate::models::Dropdown; -use crate::string::CANCEL_BUTTON; -use crate::string::EMPTY_ISSUES; -use crate::string::REMOVE_BUTTON; -use crate::DEFAULT_DEBOUNCING_TIMEOUT; - -use super::ToElementValue; - -pub struct IssuesFormComponent { - data: IssuesFormData, - new_issue: IssueWithSeries, - show_add_form: bool, - show_results: bool, - fetch_serieses: FetchSerieses, - push_issue: PushCreateIssue, - delete_issue: PushDeleteIssue, - notification_bus: NotificationDispatcher, - // Store props value locally in order to test whether it has been updated on props change - resource_access: AccountAccess, - search_callback: Callback<()>, - search_query: String, - debounce_timeout: Option, -} - -#[derive(Default)] -struct IssuesFormData { - serieses: Vec, -} - -#[allow(clippy::large_enum_variant)] -pub enum Msg { - ToggleAddFormDisplay(bool), - SetSeriesesFetchState(FetchActionSerieses), - GetSerieses, - SetIssuePushState(PushActionCreateIssue), - CreateIssue, - SetIssueDeleteState(PushActionDeleteIssue), - DeleteIssue(Uuid), - AddIssue(SeriesWithImprint), - ToggleSearchResultDisplay(bool), - SearchQueryChanged(String), - SearchSeries, - ChangeOrdinal(String), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub issues: Option>, - pub work_id: Uuid, - pub imprint_id: Uuid, - pub current_user: AccountDetails, - pub update_issues: Callback>>, -} - -impl Component for IssuesFormComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let data: IssuesFormData = Default::default(); - let new_issue: IssueWithSeries = Default::default(); - let show_add_form = false; - let show_results = false; - let body = SeriesesRequestBody { - variables: Variables { - limit: Some(100), - publishers: ctx.props().current_user.resource_access.restricted_to(), - ..Default::default() - }, - ..Default::default() - }; - let request = SeriesesRequest { body }; - let fetch_serieses = Fetch::new(request); - let push_issue = Default::default(); - let delete_issue = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let resource_access = ctx.props().current_user.resource_access.clone(); - let search_callback = ctx.link().callback(|_| Msg::SearchSeries); - let search_query: String = Default::default(); - - ctx.link().send_message(Msg::GetSerieses); - - IssuesFormComponent { - data, - new_issue, - show_add_form, - show_results, - fetch_serieses, - push_issue, - delete_issue, - notification_bus, - resource_access, - search_callback, - search_query, - debounce_timeout: None, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleAddFormDisplay(value) => { - self.show_add_form = value; - true - } - Msg::SetSeriesesFetchState(fetch_state) => { - self.fetch_serieses.apply(fetch_state); - self.data.serieses = match self.fetch_serieses.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.serieses.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetSerieses => { - ctx.link() - .send_future(self.fetch_serieses.fetch(Msg::SetSeriesesFetchState)); - ctx.link() - .send_message(Msg::SetSeriesesFetchState(FetchAction::Fetching)); - false - } - Msg::SetIssuePushState(fetch_state) => { - self.push_issue.apply(fetch_state); - match self.push_issue.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_issue { - Some(i) => { - let issue = i.clone(); - let mut issues: Vec = - ctx.props().issues.clone().unwrap_or_default(); - issues.push(issue); - ctx.props().update_issues.emit(Some(issues)); - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - true - } - None => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateIssue => { - let body = CreateIssueRequestBody { - variables: CreateVariables { - work_id: ctx.props().work_id, - series_id: self.new_issue.series_id, - issue_ordinal: self.new_issue.issue_ordinal, - }, - ..Default::default() - }; - let request = CreateIssueRequest { body }; - self.push_issue = Fetch::new(request); - ctx.link() - .send_future(self.push_issue.fetch(Msg::SetIssuePushState)); - ctx.link() - .send_message(Msg::SetIssuePushState(FetchAction::Fetching)); - false - } - Msg::SetIssueDeleteState(fetch_state) => { - self.delete_issue.apply(fetch_state); - match self.delete_issue.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_issue { - Some(issue) => { - let to_keep: Vec = ctx - .props() - .issues - .clone() - .unwrap_or_default() - .into_iter() - .filter(|i| i.issue_id != issue.issue_id) - .collect(); - ctx.props().update_issues.emit(Some(to_keep)); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteIssue(issue_id) => { - let body = DeleteIssueRequestBody { - variables: DeleteVariables { issue_id }, - ..Default::default() - }; - let request = DeleteIssueRequest { body }; - self.delete_issue = Fetch::new(request); - ctx.link() - .send_future(self.delete_issue.fetch(Msg::SetIssueDeleteState)); - ctx.link() - .send_message(Msg::SetIssueDeleteState(FetchAction::Fetching)); - false - } - Msg::AddIssue(series) => { - self.new_issue.series_id = series.series_id; - self.new_issue.series = series; - ctx.link().send_message(Msg::ToggleAddFormDisplay(true)); - true - } - Msg::ToggleSearchResultDisplay(value) => { - self.show_results = value; - true - } - Msg::SearchQueryChanged(value) => { - self.search_query = value; - // cancel previous timeout - self.debounce_timeout = self.debounce_timeout.take().and_then(|timeout| { - timeout.cancel(); - None - }); - // start new timeout - let search_callback = self.search_callback.clone(); - let timeout = Timeout::new(DEFAULT_DEBOUNCING_TIMEOUT, move || { - search_callback.emit(()); - }); - self.debounce_timeout = Some(timeout); - false - } - Msg::SearchSeries => { - let body = SeriesesRequestBody { - variables: Variables { - filter: Some(self.search_query.clone()), - limit: Some(25), - publishers: ctx.props().current_user.resource_access.restricted_to(), - ..Default::default() - }, - ..Default::default() - }; - let request = SeriesesRequest { body }; - self.fetch_serieses = Fetch::new(request); - ctx.link().send_message(Msg::GetSerieses); - false - } - Msg::ChangeOrdinal(ordinal) => { - let ordinal = ordinal.parse::().unwrap_or(0); - self.new_issue.issue_ordinal.neq_assign(ordinal); - false // otherwise we re-render the component and reset the value - } - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - let updated_permissions = self - .resource_access - .neq_assign(ctx.props().current_user.resource_access.clone()); - if updated_permissions { - ctx.link().send_message(Msg::GetSerieses); - false - } else { - true - } - } - - fn view(&self, ctx: &Context) -> Html { - let issues = ctx.props().issues.clone().unwrap_or_default(); - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleAddFormDisplay(false) - }); - html! { - - } - } -} - -impl IssuesFormComponent { - fn add_form_status(&self) -> String { - match self.show_add_form { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } - - fn search_dropdown_status(&self) -> String { - match self.show_results { - true => "dropdown is-active".to_string(), - false => "dropdown".to_string(), - } - } - - fn render_issue(&self, ctx: &Context, i: &IssueWithSeries) -> Html { - let issue_id = i.issue_id; - html! { -
- - - -
-
- -
- {&i.series.series_name} -
-
- -
- -
- {&i.series.series_type} -
-
- -
- -
- {&i.series.issn_print.as_ref().unwrap_or(&String::default())} -
-
- -
- -
- {&i.series.issn_print.as_ref().unwrap_or(&String::default())} -
-
- -
- -
- {&i.issue_ordinal} -
-
- - -
-
- } - } -} diff --git a/thoth-app/src/component/languages_form.rs b/thoth-app/src/component/languages_form.rs deleted file mode 100644 index c237c60a..00000000 --- a/thoth-app/src/component/languages_form.rs +++ /dev/null @@ -1,425 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use std::str::FromStr; -use thoth_api::model::language::Language; -use thoth_api::model::language::LanguageCode; -use thoth_api::model::language::LanguageRelation; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormBooleanSelect; -use crate::component::utils::FormLanguageCodeSelect; -use crate::component::utils::FormLanguageRelationSelect; -use crate::models::language::create_language_mutation::CreateLanguageRequest; -use crate::models::language::create_language_mutation::CreateLanguageRequestBody; -use crate::models::language::create_language_mutation::PushActionCreateLanguage; -use crate::models::language::create_language_mutation::PushCreateLanguage; -use crate::models::language::create_language_mutation::Variables; -use crate::models::language::delete_language_mutation::DeleteLanguageRequest; -use crate::models::language::delete_language_mutation::DeleteLanguageRequestBody; -use crate::models::language::delete_language_mutation::PushActionDeleteLanguage; -use crate::models::language::delete_language_mutation::PushDeleteLanguage; -use crate::models::language::delete_language_mutation::Variables as DeleteVariables; -use crate::models::language::language_codes_query::FetchActionLanguageCodes; -use crate::models::language::language_codes_query::FetchLanguageCodes; -use crate::models::language::language_relations_query::FetchActionLanguageRelations; -use crate::models::language::language_relations_query::FetchLanguageRelations; -use crate::models::language::LanguageCodeValues; -use crate::models::language::LanguageRelationValues; -use crate::string::CANCEL_BUTTON; -use crate::string::EMPTY_LANGUAGES; -use crate::string::NO; -use crate::string::REMOVE_BUTTON; -use crate::string::YES; - -use super::ToElementValue; - -pub struct LanguagesFormComponent { - data: LanguagesFormData, - new_language: Language, - show_add_form: bool, - fetch_language_codes: FetchLanguageCodes, - fetch_language_relations: FetchLanguageRelations, - push_language: PushCreateLanguage, - delete_language: PushDeleteLanguage, - notification_bus: NotificationDispatcher, -} - -#[derive(Default)] -struct LanguagesFormData { - language_codes: Vec, - language_relations: Vec, -} - -pub enum Msg { - ToggleAddFormDisplay(bool), - SetLanguageCodesFetchState(FetchActionLanguageCodes), - GetLanguageCodes, - SetLanguageRelationsFetchState(FetchActionLanguageRelations), - GetLanguageRelations, - SetLanguagePushState(PushActionCreateLanguage), - CreateLanguage, - SetLanguageDeleteState(PushActionDeleteLanguage), - DeleteLanguage(Uuid), - ChangeLanguageCode(LanguageCode), - ChangeLanguageRelation(LanguageRelation), - ChangeMainLanguage(bool), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub languages: Option>, - pub work_id: Uuid, - pub update_languages: Callback>>, -} - -impl Component for LanguagesFormComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let data: LanguagesFormData = Default::default(); - let show_add_form = false; - let new_language: Language = Default::default(); - let fetch_language_codes = Default::default(); - let fetch_language_relations = Default::default(); - let push_language = Default::default(); - let delete_language = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - - ctx.link().send_message(Msg::GetLanguageCodes); - ctx.link().send_message(Msg::GetLanguageRelations); - - LanguagesFormComponent { - data, - new_language, - show_add_form, - fetch_language_codes, - fetch_language_relations, - push_language, - delete_language, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleAddFormDisplay(value) => { - self.show_add_form = value; - true - } - Msg::SetLanguageCodesFetchState(fetch_state) => { - self.fetch_language_codes.apply(fetch_state); - self.data.language_codes = match self.fetch_language_codes.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.language_codes.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetLanguageCodes => { - ctx.link().send_future( - self.fetch_language_codes - .fetch(Msg::SetLanguageCodesFetchState), - ); - ctx.link() - .send_message(Msg::SetLanguageCodesFetchState(FetchAction::Fetching)); - false - } - Msg::SetLanguageRelationsFetchState(fetch_state) => { - self.fetch_language_relations.apply(fetch_state); - self.data.language_relations = match self.fetch_language_relations.as_ref().state() - { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.language_relations.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetLanguageRelations => { - ctx.link().send_future( - self.fetch_language_relations - .fetch(Msg::SetLanguageRelationsFetchState), - ); - ctx.link() - .send_message(Msg::SetLanguageRelationsFetchState(FetchAction::Fetching)); - false - } - Msg::SetLanguagePushState(fetch_state) => { - self.push_language.apply(fetch_state); - match self.push_language.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_language { - Some(l) => { - let language = l.clone(); - let mut languages: Vec = - ctx.props().languages.clone().unwrap_or_default(); - languages.push(language); - ctx.props().update_languages.emit(Some(languages)); - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - true - } - None => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateLanguage => { - let body = CreateLanguageRequestBody { - variables: Variables { - work_id: ctx.props().work_id, - language_relation: self.new_language.language_relation, - language_code: self.new_language.language_code, - main_language: self.new_language.main_language, - }, - ..Default::default() - }; - let request = CreateLanguageRequest { body }; - self.push_language = Fetch::new(request); - ctx.link() - .send_future(self.push_language.fetch(Msg::SetLanguagePushState)); - ctx.link() - .send_message(Msg::SetLanguagePushState(FetchAction::Fetching)); - false - } - Msg::SetLanguageDeleteState(fetch_state) => { - self.delete_language.apply(fetch_state); - match self.delete_language.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_language { - Some(language) => { - let to_keep: Vec = ctx - .props() - .languages - .clone() - .unwrap_or_default() - .into_iter() - .filter(|l| l.language_id != language.language_id) - .collect(); - ctx.props().update_languages.emit(Some(to_keep)); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteLanguage(language_id) => { - let body = DeleteLanguageRequestBody { - variables: DeleteVariables { language_id }, - ..Default::default() - }; - let request = DeleteLanguageRequest { body }; - self.delete_language = Fetch::new(request); - ctx.link() - .send_future(self.delete_language.fetch(Msg::SetLanguageDeleteState)); - ctx.link() - .send_message(Msg::SetLanguageDeleteState(FetchAction::Fetching)); - false - } - Msg::ChangeLanguageRelation(val) => self.new_language.language_relation.neq_assign(val), - Msg::ChangeLanguageCode(code) => self.new_language.language_code.neq_assign(code), - Msg::ChangeMainLanguage(val) => self.new_language.main_language.neq_assign(val), - } - } - - fn view(&self, ctx: &Context) -> Html { - let languages = ctx.props().languages.clone().unwrap_or_default(); - let open_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleAddFormDisplay(true) - }); - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleAddFormDisplay(false) - }); - html! { - - } - } -} - -impl LanguagesFormComponent { - fn add_form_status(&self) -> String { - match self.show_add_form { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } - - fn render_language(&self, ctx: &Context, l: &Language) -> Html { - let language_id = l.language_id; - html! { -
- - - -
-
- -
- {&l.language_code} -
-
- -
- -
- {&l.language_relation} -
-
- -
- -
- { - match &l.main_language { - true => { YES }, - false => { NO } - } - } -
-
- - -
-
- } - } -} diff --git a/thoth-app/src/component/locations_form.rs b/thoth-app/src/component/locations_form.rs deleted file mode 100644 index 91e42661..00000000 --- a/thoth-app/src/component/locations_form.rs +++ /dev/null @@ -1,566 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use std::str::FromStr; -use thoth_api::account::model::AccountDetails; -use thoth_api::model::location::Location; -use thoth_api::model::location::LocationPlatform; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormBooleanSelect; -use crate::component::utils::FormLocationPlatformSelect; -use crate::component::utils::FormUrlInput; -use crate::models::location::create_location_mutation::CreateLocationRequest; -use crate::models::location::create_location_mutation::CreateLocationRequestBody; -use crate::models::location::create_location_mutation::PushActionCreateLocation; -use crate::models::location::create_location_mutation::PushCreateLocation; -use crate::models::location::create_location_mutation::Variables as CreateVariables; -use crate::models::location::delete_location_mutation::DeleteLocationRequest; -use crate::models::location::delete_location_mutation::DeleteLocationRequestBody; -use crate::models::location::delete_location_mutation::PushActionDeleteLocation; -use crate::models::location::delete_location_mutation::PushDeleteLocation; -use crate::models::location::delete_location_mutation::Variables as DeleteVariables; -use crate::models::location::location_platforms_query::FetchActionLocationPlatforms; -use crate::models::location::location_platforms_query::FetchLocationPlatforms; -use crate::models::location::update_location_mutation::PushActionUpdateLocation; -use crate::models::location::update_location_mutation::PushUpdateLocation; -use crate::models::location::update_location_mutation::UpdateLocationRequest; -use crate::models::location::update_location_mutation::UpdateLocationRequestBody; -use crate::models::location::update_location_mutation::Variables as UpdateVariables; -use crate::models::location::LocationPlatformValues; -use crate::string::CANCEL_BUTTON; -use crate::string::EDIT_BUTTON; -use crate::string::EMPTY_LOCATIONS; -use crate::string::NO; -use crate::string::REMOVE_BUTTON; -use crate::string::YES; - -use super::ToElementValue; -use super::ToOption; - -pub struct LocationsFormComponent { - data: LocationsFormData, - location: Location, - show_modal_form: bool, - in_edit_mode: bool, - fetch_location_platforms: FetchLocationPlatforms, - create_location: PushCreateLocation, - delete_location: PushDeleteLocation, - update_location: PushUpdateLocation, - notification_bus: NotificationDispatcher, -} - -#[derive(Default)] -struct LocationsFormData { - location_platforms: Vec, -} - -pub enum Msg { - ToggleModalFormDisplay(bool, Option), - SetLocationPlatformsFetchState(FetchActionLocationPlatforms), - GetLocationPlatforms, - SetLocationCreateState(PushActionCreateLocation), - CreateLocation, - SetLocationDeleteState(PushActionDeleteLocation), - DeleteLocation(Uuid), - SetLocationUpdateState(PushActionUpdateLocation), - UpdateLocation, - ChangeLandingPage(String), - ChangeFullTextUrl(String), - ChangeLocationPlatform(LocationPlatform), - ChangeCanonical(bool), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub locations: Option>, - pub publication_id: Uuid, - pub update_locations: Callback<()>, - pub current_user: AccountDetails, -} - -impl Component for LocationsFormComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let data: LocationsFormData = Default::default(); - let show_modal_form = false; - let in_edit_mode = false; - // The first location needs to be canonical = true (as it will be - // the only location); subsequent locations need to be canonical = false - let location = Location { - canonical: ctx.props().locations.as_ref().unwrap_or(&vec![]).is_empty(), - ..Default::default() - }; - let fetch_location_platforms = Default::default(); - let create_location = Default::default(); - let delete_location = Default::default(); - let update_location = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - - ctx.link().send_message(Msg::GetLocationPlatforms); - - LocationsFormComponent { - data, - location, - show_modal_form, - in_edit_mode, - fetch_location_platforms, - create_location, - delete_location, - update_location, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleModalFormDisplay(show_form, l) => { - self.show_modal_form = show_form; - self.in_edit_mode = l.is_some(); - - if self.in_edit_mode { - if let Some(location) = l { - // Editing existing location: load its current values. - self.location = location; - } - } else { - self.location = Default::default(); - self.location.canonical = true; - self.location.location_platform = LocationPlatform::Other; - } - true - } - Msg::SetLocationPlatformsFetchState(fetch_state) => { - self.fetch_location_platforms.apply(fetch_state); - self.data.location_platforms = match self.fetch_location_platforms.as_ref().state() - { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => { - if ctx.props().current_user.resource_access.is_superuser { - body.data.location_platforms.enum_values.clone() - // remove Thoth from LocationPlatform enum for non-superusers - } else { - body.data - .location_platforms - .enum_values - .clone() - .into_iter() - .filter(|platform| platform.name != LocationPlatform::Thoth) - .collect() - } - } - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetLocationPlatforms => { - ctx.link().send_future( - self.fetch_location_platforms - .fetch(Msg::SetLocationPlatformsFetchState), - ); - ctx.link() - .send_message(Msg::SetLocationPlatformsFetchState(FetchAction::Fetching)); - false - } - Msg::SetLocationCreateState(fetch_state) => { - self.create_location.apply(fetch_state); - match self.create_location.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_location { - Some(l) => { - let location = l.clone(); - let mut locations: Vec = - ctx.props().locations.clone().unwrap_or_default(); - locations.push(location); - ctx.props().update_locations.emit(()); - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - true - } - None => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateLocation => { - let body = CreateLocationRequestBody { - variables: CreateVariables { - publication_id: ctx.props().publication_id, - landing_page: self.location.landing_page.clone(), - full_text_url: self.location.full_text_url.clone(), - location_platform: self.location.location_platform, - canonical: self.location.canonical, - }, - ..Default::default() - }; - let request = CreateLocationRequest { body }; - self.create_location = Fetch::new(request); - ctx.link() - .send_future(self.create_location.fetch(Msg::SetLocationCreateState)); - ctx.link() - .send_message(Msg::SetLocationCreateState(FetchAction::Fetching)); - false - } - Msg::SetLocationUpdateState(fetch_state) => { - self.update_location.apply(fetch_state); - match self.update_location.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.update_location { - Some(_l) => { - ctx.props().update_locations.emit(()); - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - // changed the return value to false below, but this doesn't fix the display - // issue where the page jumps during refresh when modal is exited - false - } - None => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::UpdateLocation => { - let body = UpdateLocationRequestBody { - variables: UpdateVariables { - location_id: self.location.location_id, - publication_id: self.location.publication_id, - landing_page: self.location.landing_page.clone(), - full_text_url: self.location.full_text_url.clone(), - location_platform: self.location.location_platform, - canonical: self.location.canonical, - }, - ..Default::default() - }; - let request = UpdateLocationRequest { body }; - self.update_location = Fetch::new(request); - ctx.link() - .send_future(self.update_location.fetch(Msg::SetLocationUpdateState)); - ctx.link() - .send_message(Msg::SetLocationUpdateState(FetchAction::Fetching)); - - false - } - Msg::SetLocationDeleteState(fetch_state) => { - self.delete_location.apply(fetch_state); - match self.delete_location.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_location { - Some(_location) => { - ctx.props().update_locations.emit(()); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteLocation(location_id) => { - let body = DeleteLocationRequestBody { - variables: DeleteVariables { location_id }, - ..Default::default() - }; - let request = DeleteLocationRequest { body }; - self.delete_location = Fetch::new(request); - ctx.link() - .send_future(self.delete_location.fetch(Msg::SetLocationDeleteState)); - ctx.link() - .send_message(Msg::SetLocationDeleteState(FetchAction::Fetching)); - false - } - Msg::ChangeLandingPage(val) => { - self.location.landing_page.neq_assign(val.to_opt_string()) - } - Msg::ChangeFullTextUrl(val) => { - self.location.full_text_url.neq_assign(val.to_opt_string()) - } - Msg::ChangeLocationPlatform(code) => self.location.location_platform.neq_assign(code), - Msg::ChangeCanonical(val) => self.location.canonical.neq_assign(val), - } - } - - fn view(&self, ctx: &Context) -> Html { - let locations = ctx.props().locations.clone().unwrap_or_default(); - let open_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleModalFormDisplay(true, None) - }); - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleModalFormDisplay(false, None) - }); - html! { - - } - } -} - -impl LocationsFormComponent { - fn modal_form_status(&self) -> String { - match self.show_modal_form { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } - - fn modal_form_title(&self) -> String { - match self.in_edit_mode { - true => "Edit Location".to_string(), - false => "New Location".to_string(), - } - } - - fn modal_form_button(&self) -> String { - match self.in_edit_mode { - true => "Save Location".to_string(), - false => "Add Location".to_string(), - } - } - - fn modal_form_action(&self, ctx: &Context) -> Callback { - match self.in_edit_mode { - true => ctx.link().callback(|e: FocusEvent| { - e.prevent_default(); - Msg::UpdateLocation - }), - false => ctx.link().callback(|e: FocusEvent| { - e.prevent_default(); - Msg::CreateLocation - }), - } - } - - fn render_location(&self, ctx: &Context, l: &Location) -> Html { - let location = l.clone(); - let location_id = l.location_id; - let mut delete_callback = Some( - ctx.link() - .callback(move |_| Msg::DeleteLocation(location_id)), - ); - let mut edit_callback = Some( - ctx.link() - .callback(move |_| Msg::ToggleModalFormDisplay(true, Some(location.clone()))), - ); - let mut delete_deactivated = false; - let mut edit_deactivated = false; - - // If the location is canonical and other (non-canonical) locations exist, prevent it from - // being deleted by deactivating the delete button and unsetting its callback attribute - if l.canonical && ctx.props().locations.as_ref().unwrap_or(&vec![]).len() > 1 { - delete_callback = None; - delete_deactivated = true; - } - // If not superuser, restrict deleting and editing locations with Thoth location platform - if !ctx.props().current_user.resource_access.is_superuser - && l.location_platform == LocationPlatform::Thoth - { - delete_callback = None; - delete_deactivated = true; - edit_callback = None; - edit_deactivated = true; - } - - html! { -
- - - -
-
- -
- {&l.landing_page.clone().unwrap_or_default()} -
-
-
- -
- {&l.full_text_url.clone().unwrap_or_default()} -
-
-
- -
- {&l.location_platform} -
-
-
- -
- { - match l.canonical { - true => { YES }, - false => { NO } - } - } -
-
- - -
-
- } - } -} diff --git a/thoth-app/src/component/login.rs b/thoth-app/src/component/login.rs deleted file mode 100644 index b8c912d4..00000000 --- a/thoth-app/src/component/login.rs +++ /dev/null @@ -1,163 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::account::model::AccountDetails; -use thoth_api::account::model::LoginCredentials; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::route::AdminRoute; -use crate::service::account::AccountError; -use crate::service::account::AccountService; -use crate::string::AUTHENTICATION_ERROR; -use crate::string::INPUT_EMAIL; -use crate::string::INPUT_PASSWORD; -use crate::string::RESPONSE_ERROR; -use crate::string::TEXT_LOGIN; - -use super::ToElementValue; - -pub struct LoginComponent { - request: LoginCredentials, - account_service: AccountService, - notification_bus: NotificationDispatcher, -} - -#[derive(PartialEq, Properties)] -pub struct Props { - pub callback: Callback, - pub current_user: Option, -} - -pub enum Msg { - RedirectToAdmin, - Request, - Response(Result), - ChangeEmail(String), - ChangePassword(String), -} - -impl Component for LoginComponent { - type Message = Msg; - type Properties = Props; - - fn create(_ctx: &Context) -> Self { - LoginComponent { - request: Default::default(), - account_service: AccountService::new(), - notification_bus: NotificationBus::dispatcher(), - } - } - - fn rendered(&mut self, ctx: &Context, first_render: bool) { - // if user is logged in there's no point in seeing the login page - if first_render && ctx.props().current_user.is_some() { - ctx.link().send_message(Msg::RedirectToAdmin); - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - if ctx.props().current_user.is_some() { - ctx.link().send_message(Msg::RedirectToAdmin); - } - true - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::RedirectToAdmin => { - ctx.link().history().unwrap().push(AdminRoute::Dashboard); - false - } - Msg::Request => { - let mut service = self.account_service.clone(); - let request = self.request.clone(); - ctx.link() - .send_future(async move { Msg::Response(service.login(request).await) }); - true - } - Msg::Response(Ok(account_details)) => { - let token = account_details.token.clone().unwrap(); - self.account_service.set_token(token); - ctx.props().callback.emit(account_details); - ctx.link().send_message(Msg::RedirectToAdmin); - true - } - Msg::Response(Err(err)) => { - match err { - AccountError::AuthenticationError => { - self.notification_bus.send(Request::NotificationBusMsg(( - AUTHENTICATION_ERROR.into(), - NotificationStatus::Warning, - ))); - } - AccountError::ResponseError => { - self.notification_bus.send(Request::NotificationBusMsg(( - RESPONSE_ERROR.into(), - NotificationStatus::Danger, - ))); - } - }; - true - } - Msg::ChangeEmail(email) => self.request.email.neq_assign(email), - Msg::ChangePassword(password) => self.request.password.neq_assign(password), - } - } - - fn view(&self, ctx: &Context) -> Html { - html! { -
-
-
-
-

- - - - -

-
-
-

- - - - -

-
-
-

- -

-
-
-
-
- } - } -} diff --git a/thoth-app/src/component/menu.rs b/thoth-app/src/component/menu.rs deleted file mode 100644 index 02889223..00000000 --- a/thoth-app/src/component/menu.rs +++ /dev/null @@ -1,138 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use yew::html; -use yew::prelude::*; -use yew::virtual_dom::VNode; -use yew_router::prelude::*; - -use crate::route::AdminRoute; - -pub struct MenuComponent {} - -#[derive(PartialEq, Eq, Properties)] -pub struct Props { - pub route: AdminRoute, -} - -impl MenuComponent { - fn is_active(&self, route: AdminRoute, ctx: &Context) -> Classes { - // This relies on the history listener in admin.rs triggering a props update - // on route change; changes of route do not otherwise re-render this component - if ctx.props().route == route { - "is-active".into() - } else { - "".into() - } - } -} - -impl Component for MenuComponent { - type Message = (); - type Properties = Props; - - fn create(_ctx: &Context) -> Self { - MenuComponent {} - } - - fn view(&self, ctx: &Context) -> VNode { - html! { - - } - } -} diff --git a/thoth-app/src/component/mod.rs b/thoth-app/src/component/mod.rs deleted file mode 100644 index 90cef113..00000000 --- a/thoth-app/src/component/mod.rs +++ /dev/null @@ -1,497 +0,0 @@ -#![allow(clippy::let_unit_value)] -#![allow(clippy::unnecessary_operation)] - -#[macro_export] -macro_rules! pagination_helpers { - ($component:ident, $pagination_text:ident, $search_text:ident) => { - use $crate::string::$pagination_text; - use $crate::string::$search_text; - - impl $component { - fn search_text(&self) -> String { - format!("{}", $search_text) - } - - fn display_count(&self) -> String { - let offset_display = match self.offset == 0 && self.result_count > 0 { - true => 1, - false => self.offset, - }; - let limit_display = match (self.limit + self.offset) > self.result_count { - true => self.result_count, - false => self.limit + self.offset, - }; - format!("{} {}–{} of {}", $pagination_text, offset_display, limit_display, self.result_count) - } - - fn is_previous_disabled(&self) -> bool { - self.offset < self.page_size - } - - fn is_next_disabled(&self) -> bool { - self.limit + self.offset >= self.result_count - } - - #[allow(dead_code)] - fn pagination_controls(&self, ctx: &Context) -> Html { - html! { - - } - } - } - } -} - -#[macro_export] -macro_rules! pagination_component { - ( - $component:ident, - $entity:ty, - $result:ident, - $result_count:ident, - $request:ident, - $fetch_action:ty, - $fetch_data:ty, - $request_body:ident, - $request_variables:ident, - $search_text:ident, - $pagination_text:ident, - $table_headers:expr, - $order_struct:ty, - $order_field:ty, - ) => { - use gloo_timers::callback::Timeout; - use std::str::FromStr; - use thoth_api::account::model::AccountAccess; - use thoth_api::account::model::AccountDetails; - use thoth_api::graphql::utils::Direction::*; - use thoth_errors::ThothError; - use yew::Callback; - use yew::html; - use yew::prelude::Component; - use yew::prelude::Context; - use yew::prelude::Html; - use yew::prelude::InputEvent; - use yew::prelude::Properties; - use yew_router::history::History; - use yew_router::prelude::Link; - use yew_router::prelude::RouterScopeExt; - use yewtil::fetch::Fetch; - use yewtil::fetch::FetchAction; - use yewtil::fetch::FetchState; - use yewtil::NeqAssign; - - use $crate::component::utils::Loader; - use $crate::component::utils::Reloader; - use $crate::models::{EditRoute, CreateRoute, MetadataTable}; - use $crate::route::AdminRoute; - use $crate::DEFAULT_DEBOUNCING_TIMEOUT; - - pub struct $component { - limit: i32, - offset: i32, - page_size: i32, - search_callback: Callback<()>, - search_query: String, - debounce_timeout: Option, - order: $order_struct, - data: Vec<$entity>, - table_headers: Vec, - result_count: i32, - fetch_data: $fetch_data, - // Store props value locally in order to test whether it has been updated on props change - resource_access: AccountAccess, - } - - pagination_helpers! {$component, $pagination_text, $search_text} - - pub enum Msg { - SetFetchState($fetch_action), - GetData, - PaginateData, - SearchQueryChanged(String), - NextPage, - PreviousPage, - ChangeRoute(AdminRoute), - SortColumn($order_field), - } - - #[derive(PartialEq, Eq, Properties)] - pub struct Props { - pub current_user: AccountDetails, - } - - impl Component for $component { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let offset: i32 = Default::default(); - let page_size: i32 = 20; - let limit: i32 = page_size; - let search_callback = ctx.link().callback(|_| Msg::PaginateData); - let search_query: String = Default::default(); - let order = Default::default(); - let result_count: i32 = Default::default(); - let data = Default::default(); - let fetch_data = Default::default(); - let table_headers = $table_headers; - // Store props value locally in order to test whether it has been updated on props change - let resource_access = ctx.props().current_user.resource_access.clone(); - - ctx.link().send_message(Msg::PaginateData); - - $component { - limit, - offset, - page_size, - search_callback, - search_query, - debounce_timeout: None, - order, - data, - table_headers, - result_count, - fetch_data, - resource_access, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetFetchState(fetch_state) => { - self.fetch_data.apply(fetch_state); - self.data = match self.fetch_data.as_ref().state() { - FetchState::Fetched(body) => body.data.$result.clone(), - _ => Default::default(), - }; - self.result_count = match self.fetch_data.as_ref().state() { - FetchState::Fetched(body) => body.data.$result_count, - _ => Default::default(), - }; - true - } - Msg::GetData => { - ctx.link() - .send_future(self.fetch_data.fetch(Msg::SetFetchState)); - ctx.link() - .send_message(Msg::SetFetchState(FetchAction::Fetching)); - false - } - Msg::PaginateData => { - let filter = self.search_query.clone(); - let order = self.order.clone(); - let body = $request_body { - variables: $request_variables { - limit: Some(self.limit), - offset: Some(self.offset), - filter: Some(filter), - order: Some(order), - publishers: ctx.props().current_user.resource_access.restricted_to(), - }, - ..Default::default() - }; - let request = $request { body }; - self.fetch_data = Fetch::new(request); - ctx.link().send_message(Msg::GetData); - false - } - Msg::SearchQueryChanged(query) => { - self.offset = 0; - self.search_query = query; - - // cancel previous timeout - self.debounce_timeout = self.debounce_timeout.take().and_then(|timeout| { - timeout.cancel(); - None - }); - // start new timeout - let search_callback = self.search_callback.clone(); - let timeout = Timeout::new(DEFAULT_DEBOUNCING_TIMEOUT, move || { - search_callback.emit(()); - }); - self.debounce_timeout = Some(timeout); - false - } - Msg::NextPage => { - if self.limit < self.result_count && !self.is_next_disabled() { - self.offset += self.page_size; - ctx.link().send_message(Msg::PaginateData); - } - false - } - Msg::PreviousPage => { - if self.offset > 0 && !self.is_previous_disabled() { - self.offset -= self.page_size; - ctx.link().send_message(Msg::PaginateData); - } - false - } - Msg::ChangeRoute(r) => { - ctx.link().history().unwrap().push(r); - false - } - Msg::SortColumn(header) => { - // Clicking on a header, if enabled, sorts the table by that column ascending - // Clicking on the current sort column header reverses the sort direction - self.order.direction = match self.order.field.neq_assign(header) { - true => Asc, - false => match self.order.direction { - Asc => Desc, - Desc => Asc, - }, - }; - self.offset = 0; - ctx.link().send_message(Msg::PaginateData); - false - } - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - let updated_permissions = - self.resource_access.neq_assign(ctx.props().current_user.resource_access.clone()); - if updated_permissions { - ctx.link().send_message(Msg::PaginateData); - } - false - } - - fn view(&self, ctx: &Context) -> Html { - let route = <$entity>::create_route(); - html! { - <> - - { self.pagination_controls(ctx) } - { - match self.fetch_data.as_ref().state() { - FetchState::NotFetching(_) => { - html! {} - }, - FetchState::Fetching(_) => html! {}, - FetchState::Fetched(_body) => html! { - - - - { - for self.table_headers.iter().map(|h| { - { - // If the header is a sortable field, make it clickable - match <$order_field>::from_str(&h) { - Ok(header) => { - html! { - - } - } - Err(_) => { - html! {} - } - } - } - }) - } - - - - - { - for self.data.iter().map(|r| { - let route = r.edit_route().clone(); - r.as_table_row( - ctx.link().callback(move |_| { - Msg::ChangeRoute(route.clone()) - }) - ) - }) - } - -
- {h} - {h}
- }, - FetchState::Failed(_, err) => html! { - { ThothError::from(err).to_string() } - }, - } - } - - } - } - } - }; -} - -pub trait ToOption { - fn to_opt_string(self) -> Option; - fn to_opt_float(self) -> Option; - fn to_opt_int(self) -> Option; - fn to_opt_date(self) -> Option; -} - -impl ToOption for String { - fn to_opt_string(self) -> Option { - match self.trim().is_empty() { - true => None, - false => Some(self.trim().to_owned()), - } - } - - fn to_opt_float(self) -> Option { - let value = self.parse().unwrap_or(0.0); - match value == 0.0 { - true => None, - false => Some(value), - } - } - - fn to_opt_int(self) -> Option { - let value = self.parse().unwrap_or(0); - match value == 0 { - true => None, - false => Some(value), - } - } - - fn to_opt_date(self) -> Option { - chrono::NaiveDate::parse_from_str(&self, "%Y-%m-%d").ok() - } -} - -pub trait ToElementValue { - fn to_value(self) -> String; -} - -impl ToElementValue for yew::InputEvent { - fn to_value(self) -> String { - use wasm_bindgen::JsCast; - use web_sys::{HtmlInputElement, HtmlTextAreaElement}; - let target = self.target().expect("Failed to get InputEvent target"); - if target.has_type::() { - target.unchecked_into::().value() - } else if target.has_type::() { - target.unchecked_into::().value() - } else { - // We currently only expect to encounter Input and TextArea elements from InputEvents - unimplemented!() - } - } -} - -impl ToElementValue for yew::Event { - fn to_value(self) -> String { - use wasm_bindgen::JsCast; - use web_sys::HtmlSelectElement; - let target = self.target().expect("Failed to get Event target"); - if target.has_type::() { - target.unchecked_into::().value() - } else { - // We currently only expect to encounter Select elements from Events - unimplemented!() - } - } -} - -impl ToElementValue for Option { - fn to_value(self) -> String { - match self { - None => "".to_string(), - Some(date) => date.format("%Y-%m-%d").to_string(), - } - } -} - -pub mod admin; -pub mod affiliations_form; -pub mod books; -pub mod chapters; -pub mod contributions_form; -pub mod contributor; -pub mod contributor_select; -pub mod contributors; -pub mod dashboard; -pub mod delete_dialogue; -pub mod fundings_form; -pub mod imprint; -pub mod imprints; -pub mod institution; -pub mod institution_select; -pub mod institutions; -pub mod issues_form; -pub mod languages_form; -pub mod locations_form; -pub mod login; -pub mod menu; -pub mod navbar; -pub mod new_chapter; -pub mod new_contributor; -pub mod new_imprint; -pub mod new_institution; -pub mod new_publisher; -pub mod new_series; -pub mod new_work; -pub mod notification; -pub mod prices_form; -pub mod publication; -pub mod publication_modal; -pub mod publications; -pub mod publications_form; -pub mod publisher; -pub mod publishers; -pub mod reference_modal; -pub mod references_form; -pub mod related_works_form; -pub mod root; -pub mod series; -pub mod serieses; -pub mod subjects_form; -pub mod utils; -pub mod work; -pub mod work_status_modal; -pub mod works; diff --git a/thoth-app/src/component/navbar.rs b/thoth-app/src/component/navbar.rs deleted file mode 100644 index 4577f9d2..00000000 --- a/thoth-app/src/component/navbar.rs +++ /dev/null @@ -1,87 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::account::model::AccountDetails; -use yew::html; -use yew::prelude::*; -use yew::virtual_dom::VNode; -use yew_router::prelude::*; - -use crate::route::AppRoute; - -pub struct NavbarComponent {} - -pub enum Msg { - Logout, -} - -#[derive(PartialEq, Properties)] -pub struct Props { - pub current_user: Option, - pub callback: Callback<()>, -} - -impl Component for NavbarComponent { - type Message = Msg; - type Properties = Props; - - fn create(_ctx: &Context) -> Self { - NavbarComponent {} - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::Logout => { - ctx.props().callback.emit(()); - true - } - } - } - - fn view(&self, ctx: &Context) -> VNode { - let logout = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::Logout - }); - html! { - - } - } -} diff --git a/thoth-app/src/component/new_chapter.rs b/thoth-app/src/component/new_chapter.rs deleted file mode 100644 index 960d0d3a..00000000 --- a/thoth-app/src/component/new_chapter.rs +++ /dev/null @@ -1,379 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::model::work::WorkType; -use thoth_api::model::work::WorkWithRelations; -use thoth_api::model::work_relation::RelationType; -use thoth_api::model::work_relation::WorkRelationWithRelatedWork; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormNumberInput; -use crate::component::utils::FormTextInput; -use crate::models::work::create_work_mutation::CreateWorkRequest; -use crate::models::work::create_work_mutation::CreateWorkRequestBody; -use crate::models::work::create_work_mutation::PushActionCreateWork; -use crate::models::work::create_work_mutation::PushCreateWork; -use crate::models::work::create_work_mutation::Variables; -use crate::models::work_relation::create_work_relation_mutation::CreateWorkRelationRequest; -use crate::models::work_relation::create_work_relation_mutation::CreateWorkRelationRequestBody; -use crate::models::work_relation::create_work_relation_mutation::PushActionCreateWorkRelation; -use crate::models::work_relation::create_work_relation_mutation::PushCreateWorkRelation; -use crate::models::work_relation::create_work_relation_mutation::Variables as CreateVariables; -use crate::string::CANCEL_BUTTON; -use crate::string::NEW_CHAPTER_INFO; - -use super::ToElementValue; - -pub struct NewChapterComponent { - new_chapter_title: String, - new_relation: WorkRelationWithRelatedWork, - show_add_form: bool, - push_work: PushCreateWork, - push_relation: PushCreateWorkRelation, - notification_bus: NotificationDispatcher, -} - -#[allow(clippy::large_enum_variant)] -pub enum Msg { - ToggleAddFormDisplay(bool), - SetRelationPushState(PushActionCreateWorkRelation), - CreateWorkRelation(Uuid), - SetWorkPushState(PushActionCreateWork), - CreateWork, - ChangeOrdinal(String), - ChangeTitle(String), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub work: WorkWithRelations, - pub relations: Option>, - pub update_relations: Callback>>, -} - -impl Component for NewChapterComponent { - type Message = Msg; - type Properties = Props; - - fn create(_ctx: &Context) -> Self { - let new_relation: WorkRelationWithRelatedWork = Default::default(); - let new_chapter_title = Default::default(); - let show_add_form = false; - let push_relation = Default::default(); - let push_work = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - - NewChapterComponent { - new_relation, - new_chapter_title, - show_add_form, - push_relation, - push_work, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleAddFormDisplay(value) => { - if value { - // On opening form, set chapter number to one higher than the current maximum - // (may not be the most appropriate value if user has left gaps in numbering) - let max_chapter_num = ctx - .props() - .relations - .clone() - .unwrap_or_default() - .into_iter() - .filter(|r| r.relation_type == RelationType::HasChild) - .max_by_key(|r| r.relation_ordinal) - .map(|r| r.relation_ordinal) - .unwrap_or(0); - self.new_relation.relation_ordinal = max_chapter_num + 1; - } - self.show_add_form = value; - true - } - Msg::SetRelationPushState(fetch_state) => { - self.push_relation.apply(fetch_state); - match self.push_relation.clone().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_work_relation { - Some(r) => { - let relation = r.clone(); - let mut relations: Vec = - ctx.props().relations.clone().unwrap_or_default(); - relations.push(relation); - ctx.props().update_relations.emit(Some(relations)); - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - true - } - None => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - format!( - "Created new work with title {}, but failed to add it to Related Works list", - self.new_chapter_title - ), - NotificationStatus::Warning, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - format!( - "Created new work with title {}, but failed to add it to Related Works list: {}", - self.new_chapter_title, - ThothError::from(err), - ), - NotificationStatus::Warning, - ))); - false - } - } - } - Msg::CreateWorkRelation(new_chapter_id) => { - let body = CreateWorkRelationRequestBody { - variables: CreateVariables { - relator_work_id: ctx.props().work.work_id, - related_work_id: new_chapter_id, - relation_type: RelationType::HasChild, - relation_ordinal: self.new_relation.relation_ordinal, - }, - ..Default::default() - }; - let request = CreateWorkRelationRequest { body }; - self.push_relation = Fetch::new(request); - ctx.link() - .send_future(self.push_relation.fetch(Msg::SetRelationPushState)); - ctx.link() - .send_message(Msg::SetRelationPushState(FetchAction::Fetching)); - false - } - Msg::SetWorkPushState(fetch_state) => { - self.push_work.apply(fetch_state); - match self.push_work.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_work { - Some(w) => { - // New Book Chapter successfully created. - // Now add a new Work Relation linking it to the parent. - ctx.link().send_message(Msg::CreateWorkRelation(w.work_id)); - true - } - None => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to create new chapter".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateWork => { - // First, create a new Book Chapter with values inherited from current Work. - let body = CreateWorkRequestBody { - variables: Variables { - work_type: WorkType::BookChapter, - work_status: ctx.props().work.work_status, - full_title: self.new_chapter_title.clone(), - title: self.new_chapter_title.clone(), - publication_date: ctx.props().work.publication_date, - place: ctx.props().work.place.clone(), - license: ctx.props().work.license.clone(), - imprint_id: ctx.props().work.imprint.imprint_id, - // All others can be set to None/blank/default - ..Default::default() - }, - ..Default::default() - }; - let request = CreateWorkRequest { body }; - self.push_work = Fetch::new(request); - ctx.link() - .send_future(self.push_work.fetch(Msg::SetWorkPushState)); - ctx.link() - .send_message(Msg::SetWorkPushState(FetchAction::Fetching)); - false - } - Msg::ChangeOrdinal(ordinal) => { - let ordinal = ordinal.parse::().unwrap_or(0); - self.new_relation.relation_ordinal.neq_assign(ordinal); - false // otherwise we re-render the component and reset the value - } - Msg::ChangeTitle(title) => self.new_chapter_title.neq_assign(title.trim().to_owned()), - } - } - - fn view(&self, ctx: &Context) -> Html { - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleAddFormDisplay(false) - }); - html! { - <> - -
- - -
- - } - } -} - -impl NewChapterComponent { - fn add_form_status(&self) -> String { - match self.show_add_form { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } -} diff --git a/thoth-app/src/component/new_contributor.rs b/thoth-app/src/component/new_contributor.rs deleted file mode 100644 index fe20637e..00000000 --- a/thoth-app/src/component/new_contributor.rs +++ /dev/null @@ -1,326 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use gloo_timers::callback::Timeout; -use thoth_api::model::contributor::Contributor; -use thoth_api::model::{Orcid, ORCID_DOMAIN}; -use thoth_errors::ThothError; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormTextInput; -use crate::component::utils::FormTextInputExtended; -use crate::component::utils::FormUrlInput; -use crate::models::contributor::contributors_query::ContributorsRequest; -use crate::models::contributor::contributors_query::ContributorsRequestBody; -use crate::models::contributor::contributors_query::FetchActionContributors; -use crate::models::contributor::contributors_query::FetchContributors; -use crate::models::contributor::contributors_query::Variables as SearchVariables; -use crate::models::contributor::create_contributor_mutation::CreateContributorRequest; -use crate::models::contributor::create_contributor_mutation::CreateContributorRequestBody; -use crate::models::contributor::create_contributor_mutation::PushActionCreateContributor; -use crate::models::contributor::create_contributor_mutation::PushCreateContributor; -use crate::models::contributor::create_contributor_mutation::Variables; -use crate::models::EditRoute; -use crate::string::SAVE_BUTTON; -use crate::DEFAULT_DEBOUNCING_TIMEOUT; - -use super::ToElementValue; -use super::ToOption; - -// Account for possibility of e.g. Chinese full names with only 2 characters. -const MIN_FULLNAME_LEN: usize = 2; - -pub struct NewContributorComponent { - contributor: Contributor, - // Track the user-entered ORCID string, which may not be validly formatted - orcid: String, - orcid_warning: String, - push_contributor: PushCreateContributor, - notification_bus: NotificationDispatcher, - show_duplicate_tooltip: bool, - fetch_contributors: FetchContributors, - contributors: Vec, - search_callback: Callback<()>, - debounce_timeout: Option, -} - -pub enum Msg { - SetContributorPushState(PushActionCreateContributor), - CreateContributor, - SetContributorsFetchState(FetchActionContributors), - GetContributors, - ChangeFirstName(String), - ChangeLastName(String), - ChangeFullName(String), - SearchContributor, - ChangeOrcid(String), - ChangeWebsite(String), - ToggleDuplicateTooltip(bool), -} - -impl Component for NewContributorComponent { - type Message = Msg; - type Properties = (); - - fn create(ctx: &Context) -> Self { - let push_contributor = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let contributor: Contributor = Default::default(); - let orcid = Default::default(); - let orcid_warning = Default::default(); - let show_duplicate_tooltip = false; - let fetch_contributors = Default::default(); - let contributors = Default::default(); - let search_callback = ctx.link().callback(|_| Msg::SearchContributor); - - NewContributorComponent { - contributor, - orcid, - orcid_warning, - push_contributor, - notification_bus, - show_duplicate_tooltip, - fetch_contributors, - contributors, - search_callback, - debounce_timeout: None, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetContributorPushState(fetch_state) => { - self.push_contributor.apply(fetch_state); - match self.push_contributor.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_contributor { - Some(c) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Saved {}", c.full_name), - NotificationStatus::Success, - ))); - ctx.link().history().unwrap().push(c.edit_route()); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateContributor => { - // Only update the ORCID value with the current user-entered string - // if it is validly formatted - otherwise keep the default. - // If no ORCID was provided, no format check is required. - if self.orcid.is_empty() { - self.contributor.orcid.neq_assign(None); - } else if let Ok(result) = self.orcid.parse::() { - self.contributor.orcid.neq_assign(Some(result)); - } - let body = CreateContributorRequestBody { - variables: Variables { - first_name: self.contributor.first_name.clone(), - last_name: self.contributor.last_name.clone(), - full_name: self.contributor.full_name.clone(), - orcid: self.contributor.orcid.clone(), - website: self.contributor.website.clone(), - }, - ..Default::default() - }; - let request = CreateContributorRequest { body }; - self.push_contributor = Fetch::new(request); - ctx.link() - .send_future(self.push_contributor.fetch(Msg::SetContributorPushState)); - ctx.link() - .send_message(Msg::SetContributorPushState(FetchAction::Fetching)); - false - } - Msg::SetContributorsFetchState(fetch_state) => { - self.fetch_contributors.apply(fetch_state); - self.contributors = match self.fetch_contributors.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.contributors.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetContributors => { - ctx.link().send_future( - self.fetch_contributors - .fetch(Msg::SetContributorsFetchState), - ); - ctx.link() - .send_message(Msg::SetContributorsFetchState(FetchAction::Fetching)); - false - } - Msg::ChangeFirstName(value) => self - .contributor - .first_name - .neq_assign(value.to_opt_string()), - Msg::ChangeLastName(last_name) => self - .contributor - .last_name - .neq_assign(last_name.trim().to_owned()), - Msg::ChangeFullName(full_name) => { - if self - .contributor - .full_name - .neq_assign(full_name.trim().to_owned()) - { - if self.contributor.full_name.len() < MIN_FULLNAME_LEN { - // Don't show similar names tooltip - name too short. - self.contributors.clear(); - true - } else { - // Search for similar existing names to show in tooltip. - self.debounce_timeout = self.debounce_timeout.take().and_then(|timeout| { - timeout.cancel(); - None - }); - let search_callback = self.search_callback.clone(); - let timeout = Timeout::new(DEFAULT_DEBOUNCING_TIMEOUT, move || { - search_callback.emit(()); - }); - self.debounce_timeout = Some(timeout); - false - } - } else { - false - } - } - Msg::SearchContributor => { - let body = ContributorsRequestBody { - variables: SearchVariables { - filter: Some(self.contributor.full_name.clone()), - limit: Some(25), - ..Default::default() - }, - ..Default::default() - }; - let request = ContributorsRequest { body }; - self.fetch_contributors = Fetch::new(request); - ctx.link().send_message(Msg::GetContributors); - false - } - Msg::ChangeOrcid(value) => { - if self.orcid.neq_assign(value.trim().to_owned()) { - // If ORCID is not correctly formatted, display a warning. - // Don't update self.contributor.orcid yet, as user may later - // overwrite a new valid value with an invalid one. - self.orcid_warning.clear(); - match self.orcid.parse::() { - Err(e) => { - match e { - // If no ORCID was provided, no warning is required. - ThothError::OrcidEmptyError => {} - _ => self.orcid_warning = e.to_string(), - } - } - Ok(value) => self.orcid = value.to_string(), - } - true - } else { - false - } - } - Msg::ChangeWebsite(value) => self.contributor.website.neq_assign(value.to_opt_string()), - Msg::ToggleDuplicateTooltip(value) => { - self.show_duplicate_tooltip = value; - true - } - } - } - - fn view(&self, ctx: &Context) -> Html { - let callback = ctx.link().callback(|event: FocusEvent| { - event.prevent_default(); - Msg::CreateContributor - }); - let mut tooltip = String::new(); - if self.show_duplicate_tooltip && !self.contributors.is_empty() { - tooltip = "Existing contributors with similar names:\n\n".to_string(); - for c in &self.contributors { - tooltip = format!("{tooltip}{c}\n"); - } - } - html! { - <> - - -
- - - - - - -
-
- -
-
- - - } - } -} diff --git a/thoth-app/src/component/new_imprint.rs b/thoth-app/src/component/new_imprint.rs deleted file mode 100644 index af7de36c..00000000 --- a/thoth-app/src/component/new_imprint.rs +++ /dev/null @@ -1,293 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::account::model::AccountAccess; -use thoth_api::account::model::AccountDetails; -use thoth_api::model::imprint::Imprint; -use thoth_api::model::publisher::Publisher; -use thoth_api::model::{Doi, DOI_DOMAIN}; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormPublisherSelect; -use crate::component::utils::FormTextInput; -use crate::component::utils::FormTextInputExtended; -use crate::component::utils::FormUrlInput; -use crate::models::imprint::create_imprint_mutation::CreateImprintRequest; -use crate::models::imprint::create_imprint_mutation::CreateImprintRequestBody; -use crate::models::imprint::create_imprint_mutation::PushActionCreateImprint; -use crate::models::imprint::create_imprint_mutation::PushCreateImprint; -use crate::models::imprint::create_imprint_mutation::Variables; -use crate::models::publisher::publishers_query::FetchActionPublishers; -use crate::models::publisher::publishers_query::FetchPublishers; -use crate::models::publisher::publishers_query::PublishersRequest; -use crate::models::publisher::publishers_query::PublishersRequestBody; -use crate::models::publisher::publishers_query::Variables as PublishersVariables; -use crate::models::EditRoute; -use crate::string::SAVE_BUTTON; - -use super::ToElementValue; -use super::ToOption; - -pub struct NewImprintComponent { - imprint: Imprint, - publisher_id: Uuid, - push_imprint: PushCreateImprint, - data: ImprintFormData, - fetch_publishers: FetchPublishers, - notification_bus: NotificationDispatcher, - // Store props value locally in order to test whether it has been updated on props change - resource_access: AccountAccess, - // Track the user-entered DOI string, which may not be validly formatted - crossmark_doi: String, - crossmark_doi_warning: String, -} - -#[derive(Default)] -struct ImprintFormData { - publishers: Vec, -} - -pub enum Msg { - SetPublishersFetchState(FetchActionPublishers), - GetPublishers, - SetImprintPushState(PushActionCreateImprint), - CreateImprint, - ChangePublisher(Uuid), - ChangeImprintName(String), - ChangeImprintUrl(String), - ChangeCrossmarkDoi(String), -} -#[derive(PartialEq, Eq, Properties)] -pub struct Props { - pub current_user: AccountDetails, -} - -impl Component for NewImprintComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let push_imprint = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let imprint: Imprint = Default::default(); - let publisher_id: Uuid = Default::default(); - let data: ImprintFormData = Default::default(); - let fetch_publishers: FetchPublishers = Default::default(); - let resource_access = ctx.props().current_user.resource_access.clone(); - let crossmark_doi = Default::default(); - let crossmark_doi_warning = Default::default(); - - ctx.link().send_message(Msg::GetPublishers); - - NewImprintComponent { - imprint, - publisher_id, - push_imprint, - data, - fetch_publishers, - notification_bus, - resource_access, - crossmark_doi, - crossmark_doi_warning, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetPublishersFetchState(fetch_state) => { - self.fetch_publishers.apply(fetch_state); - self.data.publishers = match self.fetch_publishers.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.publishers.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetPublishers => { - let body = PublishersRequestBody { - variables: PublishersVariables { - limit: Some(100), - publishers: ctx.props().current_user.resource_access.restricted_to(), - ..Default::default() - }, - ..Default::default() - }; - let request = PublishersRequest { body }; - self.fetch_publishers = Fetch::new(request); - - ctx.link() - .send_future(self.fetch_publishers.fetch(Msg::SetPublishersFetchState)); - ctx.link() - .send_message(Msg::SetPublishersFetchState(FetchAction::Fetching)); - false - } - Msg::SetImprintPushState(fetch_state) => { - self.push_imprint.apply(fetch_state); - match self.push_imprint.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_imprint { - Some(i) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Saved {}", i.imprint_name), - NotificationStatus::Success, - ))); - ctx.link().history().unwrap().push(i.edit_route()); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateImprint => { - // Only update the DOI value with the current user-entered string - // if it is validly formatted - otherwise keep the default. - // If no DOI was provided, no format check is required. - if self.crossmark_doi.is_empty() { - self.imprint.crossmark_doi.neq_assign(None); - } else if let Ok(result) = self.crossmark_doi.parse::() { - self.imprint.crossmark_doi.neq_assign(Some(result)); - } - let body = CreateImprintRequestBody { - variables: Variables { - imprint_name: self.imprint.imprint_name.clone(), - imprint_url: self.imprint.imprint_url.clone(), - crossmark_doi: self.imprint.crossmark_doi.clone(), - publisher_id: self.publisher_id, - }, - ..Default::default() - }; - let request = CreateImprintRequest { body }; - self.push_imprint = Fetch::new(request); - ctx.link() - .send_future(self.push_imprint.fetch(Msg::SetImprintPushState)); - ctx.link() - .send_message(Msg::SetImprintPushState(FetchAction::Fetching)); - false - } - Msg::ChangePublisher(publisher_id) => self.publisher_id.neq_assign(publisher_id), - Msg::ChangeImprintName(imprint_name) => self - .imprint - .imprint_name - .neq_assign(imprint_name.trim().to_owned()), - Msg::ChangeImprintUrl(value) => { - self.imprint.imprint_url.neq_assign(value.to_opt_string()) - } - Msg::ChangeCrossmarkDoi(value) => { - if self.crossmark_doi.neq_assign(value.trim().to_owned()) { - // If DOI is not correctly formatted, display a warning. - // Don't update self.imprint.crossmark_doi yet, as user may later - // overwrite a new valid value with an invalid one. - self.crossmark_doi_warning.clear(); - match self.crossmark_doi.parse::() { - Err(e) => { - match e { - // If no DOI was provided, no warning is required. - ThothError::DoiEmptyError => {} - _ => self.crossmark_doi_warning = e.to_string(), - } - } - Ok(value) => self.crossmark_doi = value.to_string(), - } - true - } else { - false - } - } - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - let updated_permissions = self - .resource_access - .neq_assign(ctx.props().current_user.resource_access.clone()); - if updated_permissions { - ctx.link().send_message(Msg::GetPublishers); - } - false - } - - fn view(&self, ctx: &Context) -> Html { - let callback = ctx.link().callback(|event: FocusEvent| { - event.prevent_default(); - Msg::CreateImprint - }); - html! { - <> - - -
- - - - - -
-
- -
-
- - - } - } -} diff --git a/thoth-app/src/component/new_institution.rs b/thoth-app/src/component/new_institution.rs deleted file mode 100644 index 53b47f7f..00000000 --- a/thoth-app/src/component/new_institution.rs +++ /dev/null @@ -1,292 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use std::str::FromStr; -use thoth_api::model::institution::CountryCode; -use thoth_api::model::institution::Institution; -use thoth_api::model::{Doi, Ror, DOI_DOMAIN, ROR_DOMAIN}; -use thoth_errors::ThothError; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormCountryCodeSelect; -use crate::component::utils::FormTextInput; -use crate::component::utils::FormTextInputExtended; -use crate::models::institution::country_codes_query::FetchActionCountryCodes; -use crate::models::institution::country_codes_query::FetchCountryCodes; -use crate::models::institution::create_institution_mutation::CreateInstitutionRequest; -use crate::models::institution::create_institution_mutation::CreateInstitutionRequestBody; -use crate::models::institution::create_institution_mutation::PushActionCreateInstitution; -use crate::models::institution::create_institution_mutation::PushCreateInstitution; -use crate::models::institution::create_institution_mutation::Variables; -use crate::models::institution::CountryCodeValues; -use crate::models::EditRoute; -use crate::string::SAVE_BUTTON; - -use super::ToElementValue; - -pub struct NewInstitutionComponent { - institution: Institution, - fetch_country_codes: FetchCountryCodes, - // Track the user-entered DOI string, which may not be validly formatted - institution_doi: String, - institution_doi_warning: String, - // Track the user-entered ROR string, which may not be validly formatted - ror: String, - ror_warning: String, - push_institution: PushCreateInstitution, - data: InstitutionFormData, - notification_bus: NotificationDispatcher, -} - -#[derive(Default)] -struct InstitutionFormData { - country_codes: Vec, -} - -pub enum Msg { - SetCountryCodesFetchState(FetchActionCountryCodes), - GetCountryCodes, - SetInstitutionPushState(PushActionCreateInstitution), - CreateInstitution, - ChangeInstitutionName(String), - ChangeInstitutionDoi(String), - ChangeRor(String), - ChangeCountryCode(String), -} - -impl Component for NewInstitutionComponent { - type Message = Msg; - type Properties = (); - - fn create(ctx: &Context) -> Self { - let push_institution = Default::default(); - let data: InstitutionFormData = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let institution: Institution = Default::default(); - let fetch_country_codes = Default::default(); - let institution_doi = Default::default(); - let institution_doi_warning = Default::default(); - let ror = Default::default(); - let ror_warning = Default::default(); - - ctx.link().send_message(Msg::GetCountryCodes); - - NewInstitutionComponent { - institution, - fetch_country_codes, - institution_doi, - institution_doi_warning, - ror, - ror_warning, - push_institution, - data, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetCountryCodesFetchState(fetch_state) => { - self.fetch_country_codes.apply(fetch_state); - self.data.country_codes = match self.fetch_country_codes.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.country_codes.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetCountryCodes => { - ctx.link().send_future( - self.fetch_country_codes - .fetch(Msg::SetCountryCodesFetchState), - ); - ctx.link() - .send_message(Msg::SetCountryCodesFetchState(FetchAction::Fetching)); - false - } - Msg::SetInstitutionPushState(fetch_state) => { - self.push_institution.apply(fetch_state); - match self.push_institution.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_institution { - Some(i) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Saved {}", i.institution_name), - NotificationStatus::Success, - ))); - ctx.link().history().unwrap().push(i.edit_route()); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateInstitution => { - // Only update the DOI value with the current user-entered string - // if it is validly formatted - otherwise keep the default. - // If no DOI was provided, no format check is required. - if self.institution_doi.is_empty() { - self.institution.institution_doi.neq_assign(None); - } else if let Ok(result) = self.institution_doi.parse::() { - self.institution.institution_doi.neq_assign(Some(result)); - } - // Only update the ROR value with the current user-entered string - // if it is validly formatted - otherwise keep the database version. - // If no ROR was provided, no format check is required. - if self.ror.is_empty() { - self.institution.ror.neq_assign(None); - } else if let Ok(result) = self.ror.parse::() { - self.institution.ror.neq_assign(Some(result)); - } - let body = CreateInstitutionRequestBody { - variables: Variables { - institution_name: self.institution.institution_name.clone(), - institution_doi: self.institution.institution_doi.clone(), - ror: self.institution.ror.clone(), - country_code: self.institution.country_code, - }, - ..Default::default() - }; - let request = CreateInstitutionRequest { body }; - self.push_institution = Fetch::new(request); - ctx.link() - .send_future(self.push_institution.fetch(Msg::SetInstitutionPushState)); - ctx.link() - .send_message(Msg::SetInstitutionPushState(FetchAction::Fetching)); - false - } - Msg::ChangeInstitutionName(institution_name) => self - .institution - .institution_name - .neq_assign(institution_name.trim().to_owned()), - Msg::ChangeInstitutionDoi(value) => { - if self.institution_doi.neq_assign(value.trim().to_owned()) { - // If DOI is not correctly formatted, display a warning. - // Don't update self.institution.institution_doi yet, as user may later - // overwrite a new valid value with an invalid one. - self.institution_doi_warning.clear(); - match self.institution_doi.parse::() { - Err(e) => { - match e { - // If no DOI was provided, no warning is required. - ThothError::DoiEmptyError => {} - _ => self.institution_doi_warning = e.to_string(), - } - } - Ok(value) => self.institution_doi = value.to_string(), - } - true - } else { - false - } - } - Msg::ChangeRor(value) => { - if self.ror.neq_assign(value.trim().to_owned()) { - // If ROR is not correctly formatted, display a warning. - // Don't update self.institution.ror yet, as user may later - // overwrite a new valid value with an invalid one. - self.ror_warning.clear(); - match self.ror.parse::() { - Err(e) => { - match e { - // If no ROR was provided, no warning is required. - ThothError::RorEmptyError => {} - _ => self.ror_warning = e.to_string(), - } - } - Ok(value) => self.ror = value.to_string(), - } - true - } else { - false - } - } - Msg::ChangeCountryCode(value) => self - .institution - .country_code - .neq_assign(CountryCode::from_str(&value).ok()), - } - } - - fn view(&self, ctx: &Context) -> Html { - let callback = ctx.link().callback(|event: FocusEvent| { - event.prevent_default(); - Msg::CreateInstitution - }); - html! { - <> - - -
- - - - - -
-
- -
-
- - - } - } -} diff --git a/thoth-app/src/component/new_publisher.rs b/thoth-app/src/component/new_publisher.rs deleted file mode 100644 index 79894675..00000000 --- a/thoth-app/src/component/new_publisher.rs +++ /dev/null @@ -1,172 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::model::publisher::Publisher; -use thoth_errors::ThothError; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormTextInput; -use crate::component::utils::FormUrlInput; -use crate::models::publisher::create_publisher_mutation::CreatePublisherRequest; -use crate::models::publisher::create_publisher_mutation::CreatePublisherRequestBody; -use crate::models::publisher::create_publisher_mutation::PushActionCreatePublisher; -use crate::models::publisher::create_publisher_mutation::PushCreatePublisher; -use crate::models::publisher::create_publisher_mutation::Variables; -use crate::models::EditRoute; -use crate::string::SAVE_BUTTON; - -use super::ToElementValue; -use super::ToOption; - -pub struct NewPublisherComponent { - publisher: Publisher, - push_publisher: PushCreatePublisher, - notification_bus: NotificationDispatcher, -} - -pub enum Msg { - SetPublisherPushState(PushActionCreatePublisher), - CreatePublisher, - ChangePublisherName(String), - ChangePublisherShortname(String), - ChangePublisherUrl(String), -} - -impl Component for NewPublisherComponent { - type Message = Msg; - type Properties = (); - - fn create(_ctx: &Context) -> Self { - let push_publisher = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let publisher: Publisher = Default::default(); - - NewPublisherComponent { - publisher, - push_publisher, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetPublisherPushState(fetch_state) => { - self.push_publisher.apply(fetch_state); - match self.push_publisher.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_publisher { - Some(p) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Saved {}", p.publisher_name), - NotificationStatus::Success, - ))); - ctx.link().history().unwrap().push(p.edit_route()); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreatePublisher => { - let body = CreatePublisherRequestBody { - variables: Variables { - publisher_name: self.publisher.publisher_name.clone(), - publisher_shortname: self.publisher.publisher_shortname.clone(), - publisher_url: self.publisher.publisher_url.clone(), - }, - ..Default::default() - }; - let request = CreatePublisherRequest { body }; - self.push_publisher = Fetch::new(request); - ctx.link() - .send_future(self.push_publisher.fetch(Msg::SetPublisherPushState)); - ctx.link() - .send_message(Msg::SetPublisherPushState(FetchAction::Fetching)); - false - } - Msg::ChangePublisherName(publisher_name) => self - .publisher - .publisher_name - .neq_assign(publisher_name.trim().to_owned()), - Msg::ChangePublisherShortname(value) => self - .publisher - .publisher_shortname - .neq_assign(value.to_opt_string()), - Msg::ChangePublisherUrl(value) => self - .publisher - .publisher_url - .neq_assign(value.to_opt_string()), - } - } - - fn view(&self, ctx: &Context) -> Html { - let callback = ctx.link().callback(|event: FocusEvent| { - event.prevent_default(); - Msg::CreatePublisher - }); - html! { - <> - - -
- - - - -
-
- -
-
- - - } - } -} diff --git a/thoth-app/src/component/new_series.rs b/thoth-app/src/component/new_series.rs deleted file mode 100644 index 7095c5ef..00000000 --- a/thoth-app/src/component/new_series.rs +++ /dev/null @@ -1,327 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use std::str::FromStr; -use thoth_api::account::model::AccountAccess; -use thoth_api::account::model::AccountDetails; -use thoth_api::model::imprint::ImprintWithPublisher; -use thoth_api::model::series::Series; -use thoth_api::model::series::SeriesType; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormImprintSelect; -use crate::component::utils::FormSeriesTypeSelect; -use crate::component::utils::FormTextInput; -use crate::component::utils::FormTextarea; -use crate::component::utils::FormUrlInput; -use crate::models::imprint::imprints_query::FetchActionImprints; -use crate::models::imprint::imprints_query::FetchImprints; -use crate::models::imprint::imprints_query::ImprintsRequest; -use crate::models::imprint::imprints_query::ImprintsRequestBody; -use crate::models::imprint::imprints_query::Variables as ImprintsVariables; -use crate::models::series::create_series_mutation::CreateSeriesRequest; -use crate::models::series::create_series_mutation::CreateSeriesRequestBody; -use crate::models::series::create_series_mutation::PushActionCreateSeries; -use crate::models::series::create_series_mutation::PushCreateSeries; -use crate::models::series::create_series_mutation::Variables; -use crate::models::series::series_types_query::FetchActionSeriesTypes; -use crate::models::series::series_types_query::FetchSeriesTypes; -use crate::models::series::SeriesTypeValues; -use crate::models::EditRoute; -use crate::string::SAVE_BUTTON; - -use super::ToElementValue; -use super::ToOption; - -pub struct NewSeriesComponent { - series: Series, - push_series: PushCreateSeries, - data: SeriesFormData, - fetch_imprints: FetchImprints, - fetch_series_types: FetchSeriesTypes, - notification_bus: NotificationDispatcher, - // Store props value locally in order to test whether it has been updated on props change - resource_access: AccountAccess, -} - -#[derive(Default)] -struct SeriesFormData { - imprints: Vec, - series_types: Vec, -} - -pub enum Msg { - SetImprintsFetchState(FetchActionImprints), - GetImprints, - SetSeriesTypesFetchState(FetchActionSeriesTypes), - GetSeriesTypes, - SetSeriesPushState(PushActionCreateSeries), - CreateSeries, - ChangeSeriesType(SeriesType), - ChangeImprint(Uuid), - ChangeSeriesName(String), - ChangeIssnPrint(String), - ChangeIssnDigital(String), - ChangeSeriesUrl(String), - ChangeSeriesDescription(String), - ChangeSeriesCfpUrl(String), -} -#[derive(PartialEq, Eq, Properties)] -pub struct Props { - pub current_user: AccountDetails, -} - -impl Component for NewSeriesComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let push_series = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let series: Series = Default::default(); - let data: SeriesFormData = Default::default(); - let fetch_imprints: FetchImprints = Default::default(); - let fetch_series_types: FetchSeriesTypes = Default::default(); - let resource_access = ctx.props().current_user.resource_access.clone(); - - ctx.link().send_message(Msg::GetImprints); - ctx.link().send_message(Msg::GetSeriesTypes); - - NewSeriesComponent { - series, - push_series, - data, - fetch_imprints, - fetch_series_types, - notification_bus, - resource_access, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetImprintsFetchState(fetch_state) => { - self.fetch_imprints.apply(fetch_state); - self.data.imprints = match self.fetch_imprints.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.imprints.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetImprints => { - let body = ImprintsRequestBody { - variables: ImprintsVariables { - limit: Some(100), - publishers: ctx.props().current_user.resource_access.restricted_to(), - ..Default::default() - }, - ..Default::default() - }; - let request = ImprintsRequest { body }; - self.fetch_imprints = Fetch::new(request); - - ctx.link() - .send_future(self.fetch_imprints.fetch(Msg::SetImprintsFetchState)); - ctx.link() - .send_message(Msg::SetImprintsFetchState(FetchAction::Fetching)); - false - } - Msg::SetSeriesTypesFetchState(fetch_state) => { - self.fetch_series_types.apply(fetch_state); - self.data.series_types = match self.fetch_series_types.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.series_types.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetSeriesTypes => { - ctx.link() - .send_future(self.fetch_series_types.fetch(Msg::SetSeriesTypesFetchState)); - ctx.link() - .send_message(Msg::SetSeriesTypesFetchState(FetchAction::Fetching)); - false - } - Msg::SetSeriesPushState(fetch_state) => { - self.push_series.apply(fetch_state); - match self.push_series.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_series { - Some(s) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Saved {}", s.series_name), - NotificationStatus::Success, - ))); - ctx.link().history().unwrap().push(s.edit_route()); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateSeries => { - let body = CreateSeriesRequestBody { - variables: Variables { - series_type: self.series.series_type, - series_name: self.series.series_name.clone(), - issn_print: self.series.issn_print.clone(), - issn_digital: self.series.issn_digital.clone(), - series_url: self.series.series_url.clone(), - series_description: self.series.series_description.clone(), - series_cfp_url: self.series.series_cfp_url.clone(), - imprint_id: self.series.imprint_id, - }, - ..Default::default() - }; - let request = CreateSeriesRequest { body }; - self.push_series = Fetch::new(request); - ctx.link() - .send_future(self.push_series.fetch(Msg::SetSeriesPushState)); - ctx.link() - .send_message(Msg::SetSeriesPushState(FetchAction::Fetching)); - false - } - Msg::ChangeSeriesType(series_type) => self.series.series_type.neq_assign(series_type), - Msg::ChangeImprint(imprint_id) => self.series.imprint_id.neq_assign(imprint_id), - Msg::ChangeSeriesName(series_name) => self - .series - .series_name - .neq_assign(series_name.trim().to_owned()), - Msg::ChangeIssnPrint(issn_print) => self - .series - .issn_print - .neq_assign(issn_print.to_opt_string()), - Msg::ChangeIssnDigital(issn_digital) => self - .series - .issn_digital - .neq_assign(issn_digital.to_opt_string()), - Msg::ChangeSeriesUrl(value) => self.series.series_url.neq_assign(value.to_opt_string()), - Msg::ChangeSeriesDescription(value) => self - .series - .series_description - .neq_assign(value.to_opt_string()), - Msg::ChangeSeriesCfpUrl(value) => { - self.series.series_cfp_url.neq_assign(value.to_opt_string()) - } - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - let updated_permissions = self - .resource_access - .neq_assign(ctx.props().current_user.resource_access.clone()); - if updated_permissions { - ctx.link().send_message(Msg::GetImprints); - } - false - } - - fn view(&self, ctx: &Context) -> Html { - let callback = ctx.link().callback(|event: FocusEvent| { - event.prevent_default(); - Msg::CreateSeries - }); - html! { - <> - - -
- - - - - - - - - -
-
- -
-
- - - } - } -} diff --git a/thoth-app/src/component/new_work.rs b/thoth-app/src/component/new_work.rs deleted file mode 100644 index 798307c0..00000000 --- a/thoth-app/src/component/new_work.rs +++ /dev/null @@ -1,712 +0,0 @@ -use chrono::NaiveDate; -use std::str::FromStr; -use thoth_api::account::model::AccountAccess; -use thoth_api::account::model::AccountDetails; -use thoth_api::model::imprint::ImprintWithPublisher; -use thoth_api::model::work::WorkProperties; -use thoth_api::model::work::WorkStatus; -use thoth_api::model::work::WorkType; -use thoth_api::model::work::WorkWithRelations; -use thoth_api::model::{Doi, DOI_DOMAIN}; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormDateInput; -use crate::component::utils::FormImprintSelect; -use crate::component::utils::FormNumberInput; -use crate::component::utils::FormTextInput; -use crate::component::utils::FormTextInputExtended; -use crate::component::utils::FormTextarea; -use crate::component::utils::FormUrlInput; -use crate::component::utils::FormWorkStatusSelect; -use crate::component::utils::FormWorkTypeSelect; -use crate::models::imprint::imprints_query::FetchActionImprints; -use crate::models::imprint::imprints_query::FetchImprints; -use crate::models::imprint::imprints_query::ImprintsRequest; -use crate::models::imprint::imprints_query::ImprintsRequestBody; -use crate::models::imprint::imprints_query::Variables as ImprintsVariables; -use crate::models::work::create_work_mutation::CreateWorkRequest; -use crate::models::work::create_work_mutation::CreateWorkRequestBody; -use crate::models::work::create_work_mutation::PushActionCreateWork; -use crate::models::work::create_work_mutation::PushCreateWork; -use crate::models::work::create_work_mutation::Variables; -use crate::models::work::work_statuses_query::FetchActionWorkStatuses; -use crate::models::work::work_statuses_query::FetchWorkStatuses; -use crate::models::work::work_types_query::FetchActionWorkTypes; -use crate::models::work::work_types_query::FetchWorkTypes; -use crate::models::work::WorkStatusValues; -use crate::models::work::WorkTypeValues; -use crate::models::EditRoute; -use crate::route::AdminRoute; -use crate::string::SAVE_BUTTON; - -use super::ToElementValue; -use super::ToOption; - -pub struct NewWorkComponent { - work: WorkWithRelations, - // Track the user-entered DOI string, which may not be validly formatted - doi: String, - doi_warning: String, - // Track imprint stored in database, as distinct from imprint selected in dropdown - imprint_id: Uuid, - push_work: PushCreateWork, - data: WorkFormData, - fetch_imprints: FetchImprints, - fetch_work_types: FetchWorkTypes, - fetch_work_statuses: FetchWorkStatuses, - notification_bus: NotificationDispatcher, - // Store props value locally in order to test whether it has been updated on props change - resource_access: AccountAccess, -} - -#[derive(Default)] -struct WorkFormData { - imprints: Vec, - work_types: Vec, - work_statuses: Vec, -} - -#[allow(clippy::large_enum_variant)] -pub enum Msg { - SetImprintsFetchState(FetchActionImprints), - GetImprints, - SetWorkTypesFetchState(FetchActionWorkTypes), - GetWorkTypes, - SetWorkStatusesFetchState(FetchActionWorkStatuses), - GetWorkStatuses, - SetWorkPushState(PushActionCreateWork), - CreateWork, - ChangeTitle(String), - ChangeSubtitle(String), - ChangeWorkType(WorkType), - ChangeWorkStatus(WorkStatus), - ChangeReference(String), - ChangeImprint(Uuid), - ChangeEdition(String), - ChangeDoi(String), - ChangeDate(String), - ChangeWithdrawnDate(String), - ChangePlace(String), - ChangePageCount(String), - ChangePageBreakdown(String), - ChangeFirstPage(String), - ChangeLastPage(String), - ChangeImageCount(String), - ChangeTableCount(String), - ChangeAudioCount(String), - ChangeVideoCount(String), - ChangeLicense(String), - ChangeCopyright(String), - ChangeLandingPage(String), - ChangeLccn(String), - ChangeOclc(String), - ChangeShortAbstract(String), - ChangeLongAbstract(String), - ChangeNote(String), - ChangeBibliographyNote(String), - ChangeToc(String), - ChangeCoverUrl(String), - ChangeCoverCaption(String), -} -#[derive(PartialEq, Eq, Properties)] -pub struct Props { - pub current_user: AccountDetails, - pub previous_route: AdminRoute, -} - -impl Component for NewWorkComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let push_work = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let work = WorkWithRelations { - work_type: match ctx.props().previous_route { - AdminRoute::Chapters => WorkType::BookChapter, - _ => Default::default(), - }, - edition: match ctx.props().previous_route { - AdminRoute::Chapters => Default::default(), - _ => Some(1), - }, - ..Default::default() - }; - let doi = Default::default(); - let doi_warning = Default::default(); - let imprint_id: Uuid = Default::default(); - let data: WorkFormData = Default::default(); - let fetch_imprints: FetchImprints = Default::default(); - let fetch_work_types: FetchWorkTypes = Default::default(); - let fetch_work_statuses: FetchWorkStatuses = Default::default(); - let resource_access = ctx.props().current_user.resource_access.clone(); - - ctx.link().send_message(Msg::GetImprints); - ctx.link().send_message(Msg::GetWorkTypes); - ctx.link().send_message(Msg::GetWorkStatuses); - - NewWorkComponent { - work, - doi, - doi_warning, - imprint_id, - push_work, - data, - fetch_imprints, - fetch_work_types, - fetch_work_statuses, - notification_bus, - resource_access, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetImprintsFetchState(fetch_state) => { - self.fetch_imprints.apply(fetch_state); - self.data.imprints = match self.fetch_imprints.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.imprints.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetImprints => { - let body = ImprintsRequestBody { - variables: ImprintsVariables { - limit: Some(100), - publishers: ctx.props().current_user.resource_access.restricted_to(), - ..Default::default() - }, - ..Default::default() - }; - let request = ImprintsRequest { body }; - self.fetch_imprints = Fetch::new(request); - - ctx.link() - .send_future(self.fetch_imprints.fetch(Msg::SetImprintsFetchState)); - ctx.link() - .send_message(Msg::SetImprintsFetchState(FetchAction::Fetching)); - false - } - Msg::SetWorkTypesFetchState(fetch_state) => { - self.fetch_work_types.apply(fetch_state); - self.data.work_types = match self.fetch_work_types.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.work_types.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetWorkTypes => { - ctx.link() - .send_future(self.fetch_work_types.fetch(Msg::SetWorkTypesFetchState)); - ctx.link() - .send_message(Msg::SetWorkTypesFetchState(FetchAction::Fetching)); - false - } - Msg::SetWorkStatusesFetchState(fetch_state) => { - self.fetch_work_statuses.apply(fetch_state); - self.data.work_statuses = match self.fetch_work_statuses.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.work_statuses.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetWorkStatuses => { - ctx.link().send_future( - self.fetch_work_statuses - .fetch(Msg::SetWorkStatusesFetchState), - ); - ctx.link() - .send_message(Msg::SetWorkStatusesFetchState(FetchAction::Fetching)); - false - } - Msg::SetWorkPushState(fetch_state) => { - self.push_work.apply(fetch_state); - match self.push_work.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_work { - Some(w) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Saved {}", w.title), - NotificationStatus::Success, - ))); - ctx.link().history().unwrap().push(w.edit_route()); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateWork => { - // Only update the DOI value with the current user-entered string - // if it is validly formatted - otherwise keep the default. - // If no DOI was provided, no format check is required. - if self.doi.is_empty() { - self.work.doi.neq_assign(None); - } else if let Ok(result) = self.doi.parse::() { - self.work.doi.neq_assign(Some(result)); - } - // Clear any fields which are not applicable to the currently selected work type or work status. - // (Do not clear them before the save point as the user may change the type again.) - if self.work.work_type == WorkType::BookChapter { - self.work.edition = None; - self.work.toc = None; - self.work.lccn = None; - self.work.oclc = None; - } else { - self.work.first_page = None; - self.work.last_page = None; - self.work.page_interval = None; - } - if self.work.work_status != WorkStatus::Withdrawn - && self.work.work_status != WorkStatus::Superseded - { - self.work.withdrawn_date = None; - } - let body = CreateWorkRequestBody { - variables: Variables { - work_type: self.work.work_type, - work_status: self.work.work_status, - full_title: self.work.full_title.clone(), - title: self.work.title.clone(), - subtitle: self.work.subtitle.clone(), - reference: self.work.reference.clone(), - edition: self.work.edition, - doi: self.work.doi.clone(), - publication_date: self.work.publication_date, - withdrawn_date: self.work.withdrawn_date, - place: self.work.place.clone(), - page_count: self.work.page_count, - page_breakdown: self.work.page_breakdown.clone(), - image_count: self.work.image_count, - table_count: self.work.table_count, - audio_count: self.work.audio_count, - video_count: self.work.video_count, - license: self.work.license.clone(), - copyright_holder: self.work.copyright_holder.clone(), - landing_page: self.work.landing_page.clone(), - lccn: self.work.lccn.clone(), - oclc: self.work.oclc.clone(), - short_abstract: self.work.short_abstract.clone(), - long_abstract: self.work.long_abstract.clone(), - general_note: self.work.general_note.clone(), - bibliography_note: self.work.bibliography_note.clone(), - toc: self.work.toc.clone(), - cover_url: self.work.cover_url.clone(), - cover_caption: self.work.cover_caption.clone(), - imprint_id: self.imprint_id, - first_page: self.work.first_page.clone(), - last_page: self.work.last_page.clone(), - page_interval: self.work.page_interval.clone(), - }, - ..Default::default() - }; - let request = CreateWorkRequest { body }; - self.push_work = Fetch::new(request); - ctx.link() - .send_future(self.push_work.fetch(Msg::SetWorkPushState)); - ctx.link() - .send_message(Msg::SetWorkPushState(FetchAction::Fetching)); - false - } - Msg::ChangeTitle(title) => { - if self.work.title.neq_assign(title.trim().to_owned()) { - self.work.full_title = self.work.compile_fulltitle(); - true - } else { - false - } - } - Msg::ChangeSubtitle(value) => { - if self.work.subtitle.neq_assign(value.to_opt_string()) { - self.work.full_title = self.work.compile_fulltitle(); - true - } else { - false - } - } - Msg::ChangeWorkType(work_type) => self.work.work_type.neq_assign(work_type), - Msg::ChangeWorkStatus(work_status) => self.work.work_status.neq_assign(work_status), - Msg::ChangeReference(value) => self.work.reference.neq_assign(value.to_opt_string()), - Msg::ChangeImprint(imprint_id) => self.imprint_id.neq_assign(imprint_id), - Msg::ChangeEdition(edition) => self.work.edition.neq_assign(edition.to_opt_int()), - Msg::ChangeDoi(value) => { - if self.doi.neq_assign(value.trim().to_owned()) { - // If DOI is not correctly formatted, display a warning. - // Don't update self.work.doi yet, as user may later - // overwrite a new valid value with an invalid one. - self.doi_warning.clear(); - match self.doi.parse::() { - Err(e) => { - match e { - // If no DOI was provided, no warning is required. - ThothError::DoiEmptyError => {} - _ => self.doi_warning = e.to_string(), - } - } - Ok(value) => self.doi = value.to_string(), - } - true - } else { - false - } - } - Msg::ChangeDate(value) => self - .work - .publication_date - .neq_assign(NaiveDate::parse_from_str(&value, "%Y-%m-%d").ok()), - Msg::ChangeWithdrawnDate(value) => self - .work - .withdrawn_date - .neq_assign(NaiveDate::parse_from_str(&value, "%Y-%m-%d").ok()), - Msg::ChangePlace(value) => self.work.place.neq_assign(value.to_opt_string()), - Msg::ChangePageCount(value) => self.work.page_count.neq_assign(value.to_opt_int()), - Msg::ChangePageBreakdown(value) => { - self.work.page_breakdown.neq_assign(value.to_opt_string()) - } - Msg::ChangeFirstPage(value) => { - if self.work.first_page.neq_assign(value.to_opt_string()) { - self.work.page_interval = self.work.compile_page_interval(); - true - } else { - false - } - } - Msg::ChangeLastPage(value) => { - if self.work.last_page.neq_assign(value.to_opt_string()) { - self.work.page_interval = self.work.compile_page_interval(); - true - } else { - false - } - } - Msg::ChangeImageCount(value) => self.work.image_count.neq_assign(value.to_opt_int()), - Msg::ChangeTableCount(value) => self.work.table_count.neq_assign(value.to_opt_int()), - Msg::ChangeAudioCount(value) => self.work.audio_count.neq_assign(value.to_opt_int()), - Msg::ChangeVideoCount(value) => self.work.video_count.neq_assign(value.to_opt_int()), - Msg::ChangeLicense(value) => self.work.license.neq_assign(value.to_opt_string()), - Msg::ChangeCopyright(copyright) => self - .work - .copyright_holder - .neq_assign(copyright.to_opt_string()), - Msg::ChangeLandingPage(value) => { - self.work.landing_page.neq_assign(value.to_opt_string()) - } - Msg::ChangeLccn(value) => self.work.lccn.neq_assign(value.to_opt_string()), - Msg::ChangeOclc(value) => self.work.oclc.neq_assign(value.to_opt_string()), - Msg::ChangeShortAbstract(value) => { - self.work.short_abstract.neq_assign(value.to_opt_string()) - } - Msg::ChangeLongAbstract(value) => { - self.work.long_abstract.neq_assign(value.to_opt_string()) - } - Msg::ChangeNote(value) => self.work.general_note.neq_assign(value.to_opt_string()), - Msg::ChangeBibliographyNote(value) => self - .work - .bibliography_note - .neq_assign(value.to_opt_string()), - Msg::ChangeToc(value) => self.work.toc.neq_assign(value.to_opt_string()), - Msg::ChangeCoverUrl(value) => self.work.cover_url.neq_assign(value.to_opt_string()), - Msg::ChangeCoverCaption(value) => { - self.work.cover_caption.neq_assign(value.to_opt_string()) - } - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - let updated_permissions = self - .resource_access - .neq_assign(ctx.props().current_user.resource_access.clone()); - if updated_permissions { - ctx.link().send_message(Msg::GetImprints); - } - false - } - - fn view(&self, ctx: &Context) -> Html { - let callback = ctx.link().callback(|event: FocusEvent| { - event.prevent_default(); - Msg::CreateWork - }); - // Grey out chapter-specific or "book"-specific fields - // based on currently selected work type. - let is_chapter = self.work.work_type == WorkType::BookChapter; - let is_not_withdrawn_or_superseded = self.work.work_status != WorkStatus::Withdrawn - && self.work.work_status != WorkStatus::Superseded; - let is_active_withdrawn_or_superseded = self.work.work_status == WorkStatus::Active - || self.work.work_status == WorkStatus::Withdrawn - || self.work.work_status == WorkStatus::Superseded; - html! { - <> - - -
-
-
- - - -
-
- - - - - - -
-
-
-
-
- -
-
-
-
-
- - -
-
-
-
-
-
- - - - -
-
-
-
- - - - -
-
-
-
- - - - -
-
- - - - - - - - - -
-
- -
-
- - - } - } -} diff --git a/thoth-app/src/component/notification.rs b/thoth-app/src/component/notification.rs deleted file mode 100644 index c822fab4..00000000 --- a/thoth-app/src/component/notification.rs +++ /dev/null @@ -1,82 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use yew::html; -use yew::prelude::Context; -use yew::Component; -use yew::Html; -use yew_agent::Bridge; -use yew_agent::Bridged; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationStatus; - -pub enum Msg { - Add((String, NotificationStatus)), - Remove(usize), -} - -struct Notification { - message: String, - status: NotificationStatus, -} - -pub struct NotificationComponent { - notifications: Vec, - _producer: Box>, -} - -impl Component for NotificationComponent { - type Message = Msg; - type Properties = (); - - fn create(ctx: &Context) -> Self { - let callback = ctx.link().callback(Msg::Add); - let _producer = NotificationBus::bridge(callback); - NotificationComponent { - notifications: Vec::new(), - _producer, - } - } - - fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::Add(s) => { - let notification = Notification { - message: s.0, - status: s.1, - }; - self.notifications.push(notification); - } - Msg::Remove(idx) => { - self.notifications.remove(idx); - } - } - true - } - - fn view(&self, ctx: &Context) -> Html { - html! { -
- { for self.notifications.iter().enumerate().map(|n| self.render_notification(ctx, n)) } -
- } - } -} - -impl NotificationComponent { - fn render_notification( - &self, - ctx: &Context, - (idx, notification): (usize, &Notification), - ) -> Html { - html! { -
- - { ¬ification.message } -
- } - } -} diff --git a/thoth-app/src/component/prices_form.rs b/thoth-app/src/component/prices_form.rs deleted file mode 100644 index aad722f2..00000000 --- a/thoth-app/src/component/prices_form.rs +++ /dev/null @@ -1,369 +0,0 @@ -#![allow(clippy::unnecessary_operation)] -use std::str::FromStr; -use thoth_api::model::price::CurrencyCode; -use thoth_api::model::price::Price; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormCurrencyCodeSelect; -use crate::component::utils::FormFloatInput; -use crate::models::price::create_price_mutation::CreatePriceRequest; -use crate::models::price::create_price_mutation::CreatePriceRequestBody; -use crate::models::price::create_price_mutation::PushActionCreatePrice; -use crate::models::price::create_price_mutation::PushCreatePrice; -use crate::models::price::create_price_mutation::Variables; -use crate::models::price::currency_codes_query::FetchActionCurrencyCodes; -use crate::models::price::currency_codes_query::FetchCurrencyCodes; -use crate::models::price::delete_price_mutation::DeletePriceRequest; -use crate::models::price::delete_price_mutation::DeletePriceRequestBody; -use crate::models::price::delete_price_mutation::PushActionDeletePrice; -use crate::models::price::delete_price_mutation::PushDeletePrice; -use crate::models::price::delete_price_mutation::Variables as DeleteVariables; -use crate::models::price::CurrencyCodeValues; -use crate::string::CANCEL_BUTTON; -use crate::string::EMPTY_PRICES; -use crate::string::REMOVE_BUTTON; - -use super::ToElementValue; - -pub struct PricesFormComponent { - data: PricesFormData, - new_price: Price, - show_add_form: bool, - fetch_currency_codes: FetchCurrencyCodes, - push_price: PushCreatePrice, - delete_price: PushDeletePrice, - notification_bus: NotificationDispatcher, -} - -#[derive(Default)] -struct PricesFormData { - currency_codes: Vec, -} - -pub enum Msg { - ToggleAddFormDisplay(bool), - SetCurrencyCodesFetchState(FetchActionCurrencyCodes), - GetCurrencyCodes, - SetPricePushState(PushActionCreatePrice), - CreatePrice, - SetPriceDeleteState(PushActionDeletePrice), - DeletePrice(Uuid), - ChangeCurrencyCode(CurrencyCode), - ChangeUnitPrice(String), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub prices: Option>, - pub publication_id: Uuid, - pub update_prices: Callback>>, -} - -impl Component for PricesFormComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let data: PricesFormData = Default::default(); - let show_add_form = false; - let new_price: Price = Default::default(); - let fetch_currency_codes = Default::default(); - let push_price = Default::default(); - let delete_price = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - - ctx.link().send_message(Msg::GetCurrencyCodes); - - PricesFormComponent { - data, - new_price, - show_add_form, - fetch_currency_codes, - push_price, - delete_price, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleAddFormDisplay(value) => { - self.show_add_form = value; - true - } - Msg::SetCurrencyCodesFetchState(fetch_state) => { - self.fetch_currency_codes.apply(fetch_state); - self.data.currency_codes = match self.fetch_currency_codes.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.currency_codes.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetCurrencyCodes => { - ctx.link().send_future( - self.fetch_currency_codes - .fetch(Msg::SetCurrencyCodesFetchState), - ); - ctx.link() - .send_message(Msg::SetCurrencyCodesFetchState(FetchAction::Fetching)); - false - } - Msg::SetPricePushState(fetch_state) => { - self.push_price.apply(fetch_state); - match self.push_price.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_price { - Some(l) => { - let price = l.clone(); - let mut prices: Vec = - ctx.props().prices.clone().unwrap_or_default(); - prices.push(price); - ctx.props().update_prices.emit(Some(prices)); - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - true - } - None => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreatePrice => { - let body = CreatePriceRequestBody { - variables: Variables { - publication_id: ctx.props().publication_id, - currency_code: self.new_price.currency_code, - unit_price: self.new_price.unit_price, - }, - ..Default::default() - }; - let request = CreatePriceRequest { body }; - self.push_price = Fetch::new(request); - ctx.link() - .send_future(self.push_price.fetch(Msg::SetPricePushState)); - ctx.link() - .send_message(Msg::SetPricePushState(FetchAction::Fetching)); - false - } - Msg::SetPriceDeleteState(fetch_state) => { - self.delete_price.apply(fetch_state); - match self.delete_price.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_price { - Some(price) => { - let to_keep: Vec = ctx - .props() - .prices - .clone() - .unwrap_or_default() - .into_iter() - .filter(|p| p.price_id != price.price_id) - .collect(); - ctx.props().update_prices.emit(Some(to_keep)); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeletePrice(price_id) => { - let body = DeletePriceRequestBody { - variables: DeleteVariables { price_id }, - ..Default::default() - }; - let request = DeletePriceRequest { body }; - self.delete_price = Fetch::new(request); - ctx.link() - .send_future(self.delete_price.fetch(Msg::SetPriceDeleteState)); - ctx.link() - .send_message(Msg::SetPriceDeleteState(FetchAction::Fetching)); - false - } - Msg::ChangeCurrencyCode(code) => self.new_price.currency_code.neq_assign(code), - Msg::ChangeUnitPrice(val) => { - let unit_price: f64 = val.parse().unwrap_or(0.00); - self.new_price.unit_price.neq_assign(unit_price) - } - } - } - - fn view(&self, ctx: &Context) -> Html { - let prices = ctx.props().prices.clone().unwrap_or_default(); - let open_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleAddFormDisplay(true) - }); - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleAddFormDisplay(false) - }); - html! { - - } - } -} - -impl PricesFormComponent { - fn add_form_status(&self) -> String { - match self.show_add_form { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } - - fn render_price(&self, ctx: &Context, p: &Price) -> Html { - let price_id = p.price_id; - html! { -
- - - -
-
- -
- {&p.currency_code} -
-
- -
- -
- {&p.unit_price} -
-
- - -
-
- } - } -} diff --git a/thoth-app/src/component/publication.rs b/thoth-app/src/component/publication.rs deleted file mode 100644 index a1fbeeb5..00000000 --- a/thoth-app/src/component/publication.rs +++ /dev/null @@ -1,440 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::account::model::AccountDetails; -use thoth_api::model::price::Price; -use thoth_api::model::publication::Publication; -use thoth_api::model::publication::PublicationProperties; -use thoth_api::model::publication::PublicationWithRelations; -use thoth_api::model::work::WorkType; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::delete_dialogue::ConfirmDeleteComponent; -use crate::component::locations_form::LocationsFormComponent; -use crate::component::prices_form::PricesFormComponent; -use crate::component::publication_modal::PublicationModalComponent; -use crate::component::utils::Loader; -use crate::models::publication::delete_publication_mutation::DeletePublicationRequest; -use crate::models::publication::delete_publication_mutation::DeletePublicationRequestBody; -use crate::models::publication::delete_publication_mutation::PushActionDeletePublication; -use crate::models::publication::delete_publication_mutation::PushDeletePublication; -use crate::models::publication::delete_publication_mutation::Variables as DeleteVariables; -use crate::models::publication::publication_query::FetchActionPublication; -use crate::models::publication::publication_query::FetchPublication; -use crate::models::publication::publication_query::PublicationRequest; -use crate::models::publication::publication_query::PublicationRequestBody; -use crate::models::publication::publication_query::Variables; -use crate::route::AdminRoute; -use crate::string::EDIT_BUTTON; -use crate::string::RELATIONS_INFO; - -pub struct PublicationComponent { - publication: PublicationWithRelations, - fetch_publication: FetchPublication, - delete_publication: PushDeletePublication, - show_modal_form: bool, - publication_under_edit: Option, - notification_bus: NotificationDispatcher, -} - -#[allow(clippy::large_enum_variant)] -pub enum Msg { - ToggleModalFormDisplay(bool), - AddPublication(Publication), - UpdatePublication(Publication), - SetPublicationFetchState(FetchActionPublication), - GetPublication, - SetPublicationDeleteState(PushActionDeletePublication), - DeletePublication, - UpdateLocations, - UpdatePrices(Option>), -} - -#[derive(PartialEq, Eq, Properties)] -pub struct Props { - pub publication_id: Uuid, - pub current_user: AccountDetails, -} - -impl Component for PublicationComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let fetch_publication: FetchPublication = Default::default(); - let delete_publication = Default::default(); - let show_modal_form = false; - let publication_under_edit = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let publication: PublicationWithRelations = Default::default(); - - ctx.link().send_message(Msg::GetPublication); - - PublicationComponent { - publication, - fetch_publication, - delete_publication, - show_modal_form, - publication_under_edit, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleModalFormDisplay(show_form) => { - self.show_modal_form = show_form; - // Opening the modal form from this form always means - // we are about to edit the current publication - self.publication_under_edit = match self.show_modal_form { - // Child form requires plain Publication, not PublicationWithRelations - true => Some(Publication { - publication_id: self.publication.publication_id, - publication_type: self.publication.publication_type, - work_id: self.publication.work_id, - isbn: self.publication.isbn.clone(), - // Not used by child form - created_at: Default::default(), - updated_at: self.publication.updated_at, - width_mm: self.publication.width_mm, - width_in: self.publication.width_in, - height_mm: self.publication.height_mm, - height_in: self.publication.height_in, - depth_mm: self.publication.depth_mm, - depth_in: self.publication.depth_in, - weight_g: self.publication.weight_g, - weight_oz: self.publication.weight_oz, - }), - false => None, - }; - true - } - Msg::AddPublication(_p) => { - // It should not be possible to call the child form from this component - // in a way which creates a new publication (rather than editing an existing one). - unreachable!() - } - Msg::UpdatePublication(p) => { - if p.publication_id == self.publication.publication_id - && p.work_id == self.publication.work_id - { - self.notification_bus.send(Request::NotificationBusMsg(( - format!( - "Saved {}", - &p.isbn - .as_ref() - .map(|s| s.to_string()) - .unwrap_or_else(|| p.publication_id.to_string()) - ), - NotificationStatus::Success, - ))); - // Child form has updated the current publication - replace its values - // (need to convert from Publication back to PublicationWithRelations) - self.publication.publication_type = p.publication_type; - self.publication.isbn = p.isbn; - self.publication.updated_at = p.updated_at; - self.publication.width_mm = p.width_mm; - self.publication.width_in = p.width_in; - self.publication.height_mm = p.height_mm; - self.publication.height_in = p.height_in; - self.publication.depth_mm = p.depth_mm; - self.publication.depth_in = p.depth_in; - self.publication.weight_g = p.weight_g; - self.publication.weight_oz = p.weight_oz; - } else { - // This should not be possible: the updated publication returned from the - // database does not match the locally-stored publication data. - // Refreshing the page will reload the local data from the database. - self.notification_bus.send(Request::NotificationBusMsg(( - "Changes were saved but display failed to update. Refresh your browser to view current data.".to_string(), - NotificationStatus::Warning, - ))); - } - // Close child form - ctx.link().send_message(Msg::ToggleModalFormDisplay(false)); - true - } - Msg::SetPublicationFetchState(fetch_state) => { - self.fetch_publication.apply(fetch_state); - match self.fetch_publication.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => { - self.publication = match &body.data.publication { - Some(c) => c.to_owned(), - None => Default::default(), - }; - // If user doesn't have permission to edit this object, redirect to dashboard - if let Some(publishers) = - ctx.props().current_user.resource_access.restricted_to() - { - if !publishers.contains( - &self - .publication - .work - .imprint - .publisher - .publisher_id - .to_string(), - ) { - ctx.link().history().unwrap().push(AdminRoute::Dashboard); - } - } - true - } - FetchState::Failed(_, _err) => false, - } - } - Msg::GetPublication => { - let body = PublicationRequestBody { - variables: Variables { - publication_id: Some(ctx.props().publication_id), - }, - ..Default::default() - }; - let request = PublicationRequest { body }; - self.fetch_publication = Fetch::new(request); - - ctx.link() - .send_future(self.fetch_publication.fetch(Msg::SetPublicationFetchState)); - ctx.link() - .send_message(Msg::SetPublicationFetchState(FetchAction::Fetching)); - false - } - Msg::SetPublicationDeleteState(fetch_state) => { - self.delete_publication.apply(fetch_state); - match self.delete_publication.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_publication { - Some(p) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!( - "Deleted {}", - &p.isbn - .as_ref() - .map(|s| s.to_string()) - .unwrap_or_else(|| p.publication_id.to_string()) - ), - NotificationStatus::Success, - ))); - ctx.link().history().unwrap().push(AdminRoute::Publications); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeletePublication => { - let body = DeletePublicationRequestBody { - variables: DeleteVariables { - publication_id: self.publication.publication_id, - }, - ..Default::default() - }; - let request = DeletePublicationRequest { body }; - self.delete_publication = Fetch::new(request); - ctx.link().send_future( - self.delete_publication - .fetch(Msg::SetPublicationDeleteState), - ); - ctx.link() - .send_message(Msg::SetPublicationDeleteState(FetchAction::Fetching)); - false - } - Msg::UpdateLocations => { - ctx.link().send_message(Msg::GetPublication); - true - } - Msg::UpdatePrices(prices) => self.publication.prices.neq_assign(prices), - } - } - - fn view(&self, ctx: &Context) -> Html { - match self.fetch_publication.as_ref().state() { - FetchState::NotFetching(_) => html! {}, - FetchState::Fetching(_) => html! {}, - FetchState::Fetched(_body) => { - html! { - <> - - -
-
- -
- {&self.publication.publication_type} -
-
- -
- -
- {&self.publication.isbn.as_ref().map(|s| s.to_string()).unwrap_or_default()} -
-
-
- - { - // Dimensions are only applicable to physical (Paperback/Hardback) non-Chapter publications. - if self.publication.is_physical() && self.publication.work.work_type != WorkType::BookChapter { - html! { - <> -
-
- -
- {&self.publication.width_mm.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
- -
- -
- {&self.publication.height_mm.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
- -
- -
- {&self.publication.depth_mm.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
- -
- -
- {&self.publication.weight_g.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
-
- -
-
- -
- {&self.publication.width_in.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
- -
- -
- {&self.publication.height_in.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
- -
- -
- {&self.publication.depth_in.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
- -
- -
- {&self.publication.weight_oz.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
-
- - } - } else { - html!{} - } - } - -
- -
-
- { RELATIONS_INFO } -
-
- - - - - - } - } - FetchState::Failed(_, err) => html! { - { ThothError::from(err).to_string() } - }, - } - } -} diff --git a/thoth-app/src/component/publication_modal.rs b/thoth-app/src/component/publication_modal.rs deleted file mode 100644 index 8330d8a6..00000000 --- a/thoth-app/src/component/publication_modal.rs +++ /dev/null @@ -1,648 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use std::str::FromStr; -use thoth_api::model::publication::Publication; -use thoth_api::model::publication::PublicationProperties; -use thoth_api::model::publication::PublicationType; -use thoth_api::model::work::WorkType; -use thoth_api::model::{Convert, Isbn, LengthUnit, WeightUnit}; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormFloatInput; -use crate::component::utils::FormPublicationTypeSelect; -use crate::component::utils::FormTextInputExtended; -use crate::models::publication::create_publication_mutation::CreatePublicationRequest; -use crate::models::publication::create_publication_mutation::CreatePublicationRequestBody; -use crate::models::publication::create_publication_mutation::PushActionCreatePublication; -use crate::models::publication::create_publication_mutation::PushCreatePublication; -use crate::models::publication::create_publication_mutation::Variables; -use crate::models::publication::publication_types_query::FetchActionPublicationTypes; -use crate::models::publication::publication_types_query::FetchPublicationTypes; -use crate::models::publication::update_publication_mutation::PushActionUpdatePublication; -use crate::models::publication::update_publication_mutation::PushUpdatePublication; -use crate::models::publication::update_publication_mutation::UpdatePublicationRequest; -use crate::models::publication::update_publication_mutation::UpdatePublicationRequestBody; -use crate::models::publication::update_publication_mutation::Variables as UpdateVariables; -use crate::models::publication::PublicationTypeValues; -use crate::string::CANCEL_BUTTON; - -use super::ToElementValue; -use super::ToOption; - -pub struct PublicationModalComponent { - data: PublicationModalData, - publication: Publication, - // Track the user-entered ISBN string, which may not be validly formatted - isbn: String, - isbn_warning: String, - in_edit_mode: bool, - convert_dimensions: bool, - fetch_publication_types: FetchPublicationTypes, - create_publication: PushCreatePublication, - update_publication: PushUpdatePublication, - notification_bus: NotificationDispatcher, - // Store props value locally in order to test whether it has been updated on props change - show_modal_form: bool, -} - -#[derive(Default)] -struct PublicationModalData { - publication_types: Vec, -} - -pub enum Msg { - CloseModalForm, - ToggleModalFormDisplay, - ToggleDimensionConversion, - SetPublicationTypesFetchState(FetchActionPublicationTypes), - GetPublicationTypes, - SetPublicationCreateState(PushActionCreatePublication), - CreatePublication, - SetPublicationUpdateState(PushActionUpdatePublication), - UpdatePublication, - ChangePublicationType(PublicationType), - ChangeIsbn(String), - ChangeWidthMm(String), - ChangeWidthIn(String), - ChangeHeightMm(String), - ChangeHeightIn(String), - ChangeDepthMm(String), - ChangeDepthIn(String), - ChangeWeightG(String), - ChangeWeightOz(String), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub publication_under_edit: Option, - pub work_id: Uuid, - pub work_type: WorkType, - pub show_modal_form: bool, - pub add_publication: Callback, - pub update_publication: Callback, - pub close_modal_form: Callback<()>, -} - -impl Component for PublicationModalComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let data: PublicationModalData = Default::default(); - let in_edit_mode = false; - let convert_dimensions = true; - let publication: Publication = Default::default(); - let isbn = Default::default(); - let isbn_warning = Default::default(); - let create_publication = Default::default(); - let update_publication = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let show_modal_form = ctx.props().show_modal_form; - - ctx.link().send_message(Msg::GetPublicationTypes); - - PublicationModalComponent { - data, - publication, - isbn, - isbn_warning, - in_edit_mode, - convert_dimensions, - fetch_publication_types: Default::default(), - create_publication, - update_publication, - notification_bus, - show_modal_form, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::CloseModalForm => { - // Prompt parent form to close this form by updating the props - // (this will eventually cause this form to re-render) - ctx.props().close_modal_form.emit(()); - false - } - Msg::ToggleModalFormDisplay => { - self.in_edit_mode = ctx.props().publication_under_edit.is_some(); - if ctx.props().show_modal_form { - if let Some(publication) = ctx.props().publication_under_edit.clone() { - // Editing existing publication: load its current values. - self.publication = publication; - } else { - // Previous values will be retained if creating new publication, which may be - // useful for batch-adding, but this is less likely for ISBNs (and they also - // cannot be added for publications whose work type is Book Chapter). - self.publication.isbn = None; - } - // Ensure ISBN variable value is kept in sync with publication object. - self.isbn = self - .publication - .isbn - .clone() - .unwrap_or_default() - .to_string(); - // Clear ISBN warning as the variable value is now valid by definition - // (self.publication.isbn can only store valid ISBNs) - self.isbn_warning = Default::default(); - } - true - } - Msg::ToggleDimensionConversion => { - self.convert_dimensions = !self.convert_dimensions; - false - } - Msg::SetPublicationTypesFetchState(fetch_state) => { - self.fetch_publication_types.apply(fetch_state); - self.data.publication_types = match self.fetch_publication_types.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.publication_types.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetPublicationTypes => { - ctx.link().send_future( - self.fetch_publication_types - .fetch(Msg::SetPublicationTypesFetchState), - ); - ctx.link() - .send_message(Msg::SetPublicationTypesFetchState(FetchAction::Fetching)); - false - } - Msg::SetPublicationCreateState(fetch_state) => { - self.create_publication.apply(fetch_state); - match self.create_publication.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_publication { - Some(p) => { - // Send newly-created publication to parent form to process - // (parent form is responsible for closing modal) - ctx.props().add_publication.emit(p.clone()); - true - } - None => { - ctx.link().send_message(Msg::CloseModalForm); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link().send_message(Msg::CloseModalForm); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreatePublication => { - // Update publication object with common field-specific logic before saving - self.prepare_for_submission(ctx); - let body = CreatePublicationRequestBody { - variables: Variables { - work_id: ctx.props().work_id, - publication_type: self.publication.publication_type, - isbn: self.publication.isbn.clone(), - width_mm: self.publication.width_mm, - width_in: self.publication.width_in, - height_mm: self.publication.height_mm, - height_in: self.publication.height_in, - depth_mm: self.publication.depth_mm, - depth_in: self.publication.depth_in, - weight_g: self.publication.weight_g, - weight_oz: self.publication.weight_oz, - }, - ..Default::default() - }; - let request = CreatePublicationRequest { body }; - self.create_publication = Fetch::new(request); - ctx.link().send_future( - self.create_publication - .fetch(Msg::SetPublicationCreateState), - ); - ctx.link() - .send_message(Msg::SetPublicationCreateState(FetchAction::Fetching)); - false - } - Msg::SetPublicationUpdateState(fetch_state) => { - self.update_publication.apply(fetch_state); - match self.update_publication.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.update_publication { - Some(p) => { - // Send newly-created publication to parent form to process - // (parent form is responsible for closing modal) - ctx.props().update_publication.emit(p.clone()); - true - } - None => { - ctx.link().send_message(Msg::CloseModalForm); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link().send_message(Msg::CloseModalForm); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::UpdatePublication => { - // Update publication object with common field-specific logic before saving - self.prepare_for_submission(ctx); - let body = UpdatePublicationRequestBody { - variables: UpdateVariables { - publication_id: self.publication.publication_id, - work_id: ctx.props().work_id, - publication_type: self.publication.publication_type, - isbn: self.publication.isbn.clone(), - width_mm: self.publication.width_mm, - width_in: self.publication.width_in, - height_mm: self.publication.height_mm, - height_in: self.publication.height_in, - depth_mm: self.publication.depth_mm, - depth_in: self.publication.depth_in, - weight_g: self.publication.weight_g, - weight_oz: self.publication.weight_oz, - }, - ..Default::default() - }; - let request = UpdatePublicationRequest { body }; - self.update_publication = Fetch::new(request); - ctx.link().send_future( - self.update_publication - .fetch(Msg::SetPublicationUpdateState), - ); - ctx.link() - .send_message(Msg::SetPublicationUpdateState(FetchAction::Fetching)); - false - } - Msg::ChangePublicationType(val) => self.publication.publication_type.neq_assign(val), - Msg::ChangeIsbn(value) => { - if self.isbn.neq_assign(value.trim().to_owned()) { - // If ISBN is not correctly formatted, display a warning. - // Don't update self.publication.isbn yet, as user may later - // overwrite a new valid value with an invalid one. - self.isbn_warning.clear(); - match self.isbn.parse::() { - Err(e) => { - match e { - // If no ISBN was provided, no warning is required. - ThothError::IsbnEmptyError => {} - _ => self.isbn_warning = e.to_string(), - } - } - Ok(value) => self.isbn = value.to_string(), - } - true - } else { - false - } - } - Msg::ChangeWidthMm(value) => { - let changed_value = self.publication.width_mm.neq_assign(value.to_opt_float()); - if changed_value && self.convert_dimensions { - let mut width_in = None; - // Automatically update paired length field with default conversion. - if let Some(width_mm) = self.publication.width_mm { - width_in = - Some(width_mm.convert_length_from_to(&LengthUnit::Mm, &LengthUnit::In)); - } - self.publication.width_in.neq_assign(width_in); - } - changed_value - } - Msg::ChangeWidthIn(value) => { - let changed_value = self.publication.width_in.neq_assign(value.to_opt_float()); - if changed_value && self.convert_dimensions { - let mut width_mm = None; - // Automatically update paired length field with default conversion. - if let Some(width_in) = self.publication.width_in { - width_mm = - Some(width_in.convert_length_from_to(&LengthUnit::In, &LengthUnit::Mm)); - } - self.publication.width_mm.neq_assign(width_mm); - } - changed_value - } - Msg::ChangeHeightMm(value) => { - let changed_value = self.publication.height_mm.neq_assign(value.to_opt_float()); - if changed_value && self.convert_dimensions { - let mut height_in = None; - // Automatically update paired length field with default conversion. - if let Some(height_mm) = self.publication.height_mm { - height_in = Some( - height_mm.convert_length_from_to(&LengthUnit::Mm, &LengthUnit::In), - ); - } - self.publication.height_in.neq_assign(height_in); - } - changed_value - } - Msg::ChangeHeightIn(value) => { - let changed_value = self.publication.height_in.neq_assign(value.to_opt_float()); - if changed_value && self.convert_dimensions { - let mut height_mm = None; - // Automatically update paired length field with default conversion. - if let Some(height_in) = self.publication.height_in { - height_mm = Some( - height_in.convert_length_from_to(&LengthUnit::In, &LengthUnit::Mm), - ); - } - self.publication.height_mm.neq_assign(height_mm); - } - changed_value - } - Msg::ChangeDepthMm(value) => { - let changed_value = self.publication.depth_mm.neq_assign(value.to_opt_float()); - if changed_value && self.convert_dimensions { - let mut depth_in = None; - // Automatically update paired length field with default conversion. - if let Some(depth_mm) = self.publication.depth_mm { - depth_in = - Some(depth_mm.convert_length_from_to(&LengthUnit::Mm, &LengthUnit::In)); - } - self.publication.depth_in.neq_assign(depth_in); - } - changed_value - } - Msg::ChangeDepthIn(value) => { - let changed_value = self.publication.depth_in.neq_assign(value.to_opt_float()); - if changed_value && self.convert_dimensions { - let mut depth_mm = None; - // Automatically update paired length field with default conversion. - if let Some(depth_in) = self.publication.depth_in { - depth_mm = - Some(depth_in.convert_length_from_to(&LengthUnit::In, &LengthUnit::Mm)); - } - self.publication.depth_mm.neq_assign(depth_mm); - } - changed_value - } - Msg::ChangeWeightG(value) => { - let changed_value = self.publication.weight_g.neq_assign(value.to_opt_float()); - if changed_value && self.convert_dimensions { - let mut weight_oz = None; - // Automatically update paired weight field with default conversion. - if let Some(weight_g) = self.publication.weight_g { - weight_oz = - Some(weight_g.convert_weight_from_to(&WeightUnit::G, &WeightUnit::Oz)); - } - self.publication.weight_oz.neq_assign(weight_oz); - } - changed_value - } - Msg::ChangeWeightOz(value) => { - let changed_value = self.publication.weight_oz.neq_assign(value.to_opt_float()); - if changed_value && self.convert_dimensions { - let mut weight_g = None; - // Automatically update paired weight field with default conversion. - if let Some(weight_oz) = self.publication.weight_oz { - weight_g = - Some(weight_oz.convert_weight_from_to(&WeightUnit::Oz, &WeightUnit::G)); - } - self.publication.weight_g.neq_assign(weight_g); - } - changed_value - } - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - let updated_show_modal_form = self.show_modal_form.neq_assign(ctx.props().show_modal_form); - if updated_show_modal_form { - ctx.link().send_message(Msg::ToggleModalFormDisplay) - } - // Re-render only required if show_modal_form has changed, - // in which case ToggleModalFormDisplay will trigger it - false - } - - fn view(&self, ctx: &Context) -> Html { - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::CloseModalForm - }); - html! { -
- - -
- } - } -} - -impl PublicationModalComponent { - fn modal_form_status(&self, ctx: &Context) -> String { - match ctx.props().show_modal_form { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } - - fn modal_form_title(&self) -> String { - match self.in_edit_mode { - true => "Edit Publication".to_string(), - false => "New Publication".to_string(), - } - } - - fn modal_form_button(&self) -> String { - match self.in_edit_mode { - true => "Save Publication".to_string(), - false => "Add Publication".to_string(), - } - } - - fn modal_form_action(&self, ctx: &Context) -> Callback { - match self.in_edit_mode { - true => ctx.link().callback(|e: FocusEvent| { - e.prevent_default(); - Msg::UpdatePublication - }), - false => ctx.link().callback(|e: FocusEvent| { - e.prevent_default(); - Msg::CreatePublication - }), - } - } - - fn prepare_for_submission(&mut self, ctx: &Context) { - // Only update the ISBN value with the current user-entered string - // if it is validly formatted - otherwise keep the default. - // If no ISBN was provided, no format check is required. - if self.isbn.is_empty() { - self.publication.isbn.neq_assign(None); - } else if let Ok(result) = self.isbn.parse::() { - self.publication.isbn.neq_assign(Some(result)); - } - // Clear any fields which are not applicable to the currently selected work/publication type. - // (Do not clear them before the save point as the user may change the type again.) - if self.publication.is_digital() || ctx.props().work_type == WorkType::BookChapter { - self.publication.width_mm = None; - self.publication.width_in = None; - self.publication.height_mm = None; - self.publication.height_in = None; - self.publication.depth_mm = None; - self.publication.depth_in = None; - self.publication.weight_g = None; - self.publication.weight_oz = None; - } - } -} diff --git a/thoth-app/src/component/publications.rs b/thoth-app/src/component/publications.rs deleted file mode 100644 index dfe6017f..00000000 --- a/thoth-app/src/component/publications.rs +++ /dev/null @@ -1,37 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use crate::models::publication::publications_query::FetchActionPublications; -use crate::models::publication::publications_query::FetchPublications; -use crate::models::publication::publications_query::PublicationsRequest; -use crate::models::publication::publications_query::PublicationsRequestBody; -use crate::models::publication::publications_query::Variables; -use thoth_api::model::publication::PublicationField; -use thoth_api::model::publication::PublicationOrderBy; -use thoth_api::model::publication::PublicationWithRelations; - -use super::ToElementValue; - -pagination_component! { - PublicationsComponent, - PublicationWithRelations, - publications, - publication_count, - PublicationsRequest, - FetchActionPublications, - FetchPublications, - PublicationsRequestBody, - Variables, - SEARCH_PUBLICATIONS, - PAGINATION_COUNT_PUBLICATIONS, - vec![ - PublicationField::PublicationId.to_string(), - "Work Title".to_string(), - "Work DOI".to_string(), - "Publisher".to_string(), - PublicationField::PublicationType.to_string(), - PublicationField::Isbn.to_string(), - PublicationField::UpdatedAt.to_string(), - ], - PublicationOrderBy, - PublicationField, -} diff --git a/thoth-app/src/component/publications_form.rs b/thoth-app/src/component/publications_form.rs deleted file mode 100644 index ce74c96b..00000000 --- a/thoth-app/src/component/publications_form.rs +++ /dev/null @@ -1,347 +0,0 @@ -use thoth_api::model::publication::Publication; -use thoth_api::model::publication::PublicationProperties; -use thoth_api::model::work::WorkType; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::publication_modal::PublicationModalComponent; -use crate::models::publication::delete_publication_mutation::DeletePublicationRequest; -use crate::models::publication::delete_publication_mutation::DeletePublicationRequestBody; -use crate::models::publication::delete_publication_mutation::PushActionDeletePublication; -use crate::models::publication::delete_publication_mutation::PushDeletePublication; -use crate::models::publication::delete_publication_mutation::Variables as DeleteVariables; -use crate::models::EditRoute; -use crate::route::AdminRoute; -use crate::string::EDIT_BUTTON; -use crate::string::EMPTY_PUBLICATIONS; -use crate::string::REMOVE_BUTTON; -use crate::string::VIEW_BUTTON; - -pub struct PublicationsFormComponent { - show_modal_form: bool, - publication_under_edit: Option, - delete_publication: PushDeletePublication, - notification_bus: NotificationDispatcher, -} - -pub enum Msg { - ToggleModalFormDisplay(bool, Option), - AddPublication(Publication), - UpdatePublication(Publication), - SetPublicationDeleteState(PushActionDeletePublication), - DeletePublication(Uuid), - ChangeRoute(AdminRoute), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub publications: Option>, - pub work_id: Uuid, - pub work_type: WorkType, - pub update_publications: Callback>>, -} - -impl Component for PublicationsFormComponent { - type Message = Msg; - type Properties = Props; - - fn create(_ctx: &Context) -> Self { - let show_modal_form = false; - let publication_under_edit = Default::default(); - let delete_publication = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - - PublicationsFormComponent { - show_modal_form, - publication_under_edit, - delete_publication, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleModalFormDisplay(show_form, p) => { - self.show_modal_form = show_form; - self.publication_under_edit = p; - true - } - Msg::AddPublication(p) => { - // Child form has created a new publication - add it to list - let mut publications: Vec = - ctx.props().publications.clone().unwrap_or_default(); - publications.push(p); - ctx.props().update_publications.emit(Some(publications)); - // Close child form - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - true - } - Msg::UpdatePublication(p) => { - // Child form has updated an existing publication - replace it in list - let mut publications: Vec = - ctx.props().publications.clone().unwrap_or_default(); - if let Some(publication) = publications - .iter_mut() - .find(|pb| pb.publication_id == p.publication_id) - { - *publication = p.clone(); - ctx.props().update_publications.emit(Some(publications)); - } else { - // This should not be possible: the updated publication returned from the - // database does not match any of the locally-stored publication data. - // Refreshing the page will reload the local data from the database. - self.notification_bus.send(Request::NotificationBusMsg(( - "Changes were saved but display failed to update. Refresh your browser to view current data.".to_string(), - NotificationStatus::Warning, - ))); - } - // Close child form - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - true - } - Msg::SetPublicationDeleteState(fetch_state) => { - self.delete_publication.apply(fetch_state); - match self.delete_publication.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_publication { - Some(publication) => { - let to_keep: Vec = ctx - .props() - .publications - .clone() - .unwrap_or_default() - .into_iter() - .filter(|p| p.publication_id != publication.publication_id) - .collect(); - ctx.props().update_publications.emit(Some(to_keep)); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeletePublication(publication_id) => { - let body = DeletePublicationRequestBody { - variables: DeleteVariables { publication_id }, - ..Default::default() - }; - let request = DeletePublicationRequest { body }; - self.delete_publication = Fetch::new(request); - ctx.link().send_future( - self.delete_publication - .fetch(Msg::SetPublicationDeleteState), - ); - ctx.link() - .send_message(Msg::SetPublicationDeleteState(FetchAction::Fetching)); - false - } - Msg::ChangeRoute(r) => { - ctx.link().history().unwrap().push(r); - false - } - } - } - - fn view(&self, ctx: &Context) -> Html { - let publications = ctx.props().publications.clone().unwrap_or_default(); - let open_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleModalFormDisplay(true, None) - }); - html! { - - } - } -} - -impl PublicationsFormComponent { - fn render_publication(&self, ctx: &Context, p: &Publication) -> Html { - let publication = p.clone(); - let publication_id = p.publication_id; - let route = p.edit_route(); - html! { -
- - - -
-
- -
- {&p.publication_type} -
-
- -
- -
- {&p.isbn.as_ref().map(|s| s.to_string()).unwrap_or_default()} -
-
- - { - // Dimensions are only applicable to physical (Paperback/Hardback) non-Chapter publications. - if p.is_physical() && ctx.props().work_type != WorkType::BookChapter { - html! { - <> -
-
- -
- {&p.width_mm.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
- -
- -
- {&p.width_in.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
-
- -
-
- -
- {&p.height_mm.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
- -
- -
- {&p.height_in.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
-
- -
-
- -
- {&p.depth_mm.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
- -
- -
- {&p.depth_in.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
-
- -
-
- -
- {&p.weight_g.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
- -
- -
- {&p.weight_oz.as_ref().map(|w| w.to_string()).unwrap_or_default()} -
-
-
- - } - } else { - html!{} - } - } - - -
-
- } - } -} diff --git a/thoth-app/src/component/publisher.rs b/thoth-app/src/component/publisher.rs deleted file mode 100644 index 69ecc9f2..00000000 --- a/thoth-app/src/component/publisher.rs +++ /dev/null @@ -1,305 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::account::model::AccountDetails; -use thoth_api::model::publisher::Publisher; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::delete_dialogue::ConfirmDeleteComponent; -use crate::component::utils::FormTextInput; -use crate::component::utils::FormUrlInput; -use crate::component::utils::Loader; -use crate::models::publisher::delete_publisher_mutation::DeletePublisherRequest; -use crate::models::publisher::delete_publisher_mutation::DeletePublisherRequestBody; -use crate::models::publisher::delete_publisher_mutation::PushActionDeletePublisher; -use crate::models::publisher::delete_publisher_mutation::PushDeletePublisher; -use crate::models::publisher::delete_publisher_mutation::Variables as DeleteVariables; -use crate::models::publisher::publisher_query::FetchActionPublisher; -use crate::models::publisher::publisher_query::FetchPublisher; -use crate::models::publisher::publisher_query::PublisherRequest; -use crate::models::publisher::publisher_query::PublisherRequestBody; -use crate::models::publisher::publisher_query::Variables; -use crate::models::publisher::update_publisher_mutation::PushActionUpdatePublisher; -use crate::models::publisher::update_publisher_mutation::PushUpdatePublisher; -use crate::models::publisher::update_publisher_mutation::UpdatePublisherRequest; -use crate::models::publisher::update_publisher_mutation::UpdatePublisherRequestBody; -use crate::models::publisher::update_publisher_mutation::Variables as UpdateVariables; -use crate::route::AdminRoute; -use crate::string::SAVE_BUTTON; - -use super::ToElementValue; -use super::ToOption; - -pub struct PublisherComponent { - publisher: Publisher, - fetch_publisher: FetchPublisher, - push_publisher: PushUpdatePublisher, - delete_publisher: PushDeletePublisher, - notification_bus: NotificationDispatcher, -} - -pub enum Msg { - SetPublisherFetchState(FetchActionPublisher), - GetPublisher, - SetPublisherPushState(PushActionUpdatePublisher), - UpdatePublisher, - SetPublisherDeleteState(PushActionDeletePublisher), - DeletePublisher, - ChangePublisherName(String), - ChangePublisherShortname(String), - ChangePublisherUrl(String), -} - -#[derive(PartialEq, Eq, Properties)] -pub struct Props { - pub publisher_id: Uuid, - pub current_user: AccountDetails, -} - -impl Component for PublisherComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let fetch_publisher: FetchPublisher = Default::default(); - let push_publisher = Default::default(); - let delete_publisher = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let publisher: Publisher = Default::default(); - - ctx.link().send_message(Msg::GetPublisher); - - PublisherComponent { - publisher, - fetch_publisher, - push_publisher, - delete_publisher, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetPublisherFetchState(fetch_state) => { - self.fetch_publisher.apply(fetch_state); - match self.fetch_publisher.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => { - self.publisher = match &body.data.publisher { - Some(c) => c.to_owned(), - None => Default::default(), - }; - // If user doesn't have permission to edit this object, redirect to dashboard - if let Some(publishers) = - ctx.props().current_user.resource_access.restricted_to() - { - if !publishers.contains(&self.publisher.publisher_id.to_string()) { - ctx.link().history().unwrap().push(AdminRoute::Dashboard); - } - } - true - } - FetchState::Failed(_, _err) => false, - } - } - Msg::GetPublisher => { - let body = PublisherRequestBody { - variables: Variables { - publisher_id: Some(ctx.props().publisher_id), - }, - ..Default::default() - }; - let request = PublisherRequest { body }; - self.fetch_publisher = Fetch::new(request); - - ctx.link() - .send_future(self.fetch_publisher.fetch(Msg::SetPublisherFetchState)); - ctx.link() - .send_message(Msg::SetPublisherFetchState(FetchAction::Fetching)); - false - } - Msg::SetPublisherPushState(fetch_state) => { - self.push_publisher.apply(fetch_state); - match self.push_publisher.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.update_publisher { - Some(p) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Saved {}", p.publisher_name), - NotificationStatus::Success, - ))); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::UpdatePublisher => { - let body = UpdatePublisherRequestBody { - variables: UpdateVariables { - publisher_id: self.publisher.publisher_id, - publisher_name: self.publisher.publisher_name.clone(), - publisher_shortname: self.publisher.publisher_shortname.clone(), - publisher_url: self.publisher.publisher_url.clone(), - }, - ..Default::default() - }; - let request = UpdatePublisherRequest { body }; - self.push_publisher = Fetch::new(request); - ctx.link() - .send_future(self.push_publisher.fetch(Msg::SetPublisherPushState)); - ctx.link() - .send_message(Msg::SetPublisherPushState(FetchAction::Fetching)); - false - } - Msg::SetPublisherDeleteState(fetch_state) => { - self.delete_publisher.apply(fetch_state); - match self.delete_publisher.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_publisher { - Some(f) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Deleted {}", f.publisher_name), - NotificationStatus::Success, - ))); - ctx.link().history().unwrap().push(AdminRoute::Publishers); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeletePublisher => { - let body = DeletePublisherRequestBody { - variables: DeleteVariables { - publisher_id: self.publisher.publisher_id, - }, - ..Default::default() - }; - let request = DeletePublisherRequest { body }; - self.delete_publisher = Fetch::new(request); - ctx.link() - .send_future(self.delete_publisher.fetch(Msg::SetPublisherDeleteState)); - ctx.link() - .send_message(Msg::SetPublisherDeleteState(FetchAction::Fetching)); - false - } - Msg::ChangePublisherName(publisher_name) => self - .publisher - .publisher_name - .neq_assign(publisher_name.trim().to_owned()), - Msg::ChangePublisherShortname(value) => self - .publisher - .publisher_shortname - .neq_assign(value.to_opt_string()), - Msg::ChangePublisherUrl(value) => self - .publisher - .publisher_url - .neq_assign(value.to_opt_string()), - } - } - - fn view(&self, ctx: &Context) -> Html { - match self.fetch_publisher.as_ref().state() { - FetchState::NotFetching(_) => html! {}, - FetchState::Fetching(_) => html! {}, - FetchState::Fetched(_body) => { - let callback = ctx.link().callback(|event: FocusEvent| { - event.prevent_default(); - Msg::UpdatePublisher - }); - html! { - <> - - -
- - - - -
-
- -
-
- - - } - } - FetchState::Failed(_, err) => html! { - { ThothError::from(err).to_string() } - }, - } - } -} diff --git a/thoth-app/src/component/publishers.rs b/thoth-app/src/component/publishers.rs deleted file mode 100644 index 455a3004..00000000 --- a/thoth-app/src/component/publishers.rs +++ /dev/null @@ -1,35 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use crate::models::publisher::publishers_query::FetchActionPublishers; -use crate::models::publisher::publishers_query::FetchPublishers; -use crate::models::publisher::publishers_query::PublishersRequest; -use crate::models::publisher::publishers_query::PublishersRequestBody; -use crate::models::publisher::publishers_query::Variables; -use thoth_api::model::publisher::Publisher; -use thoth_api::model::publisher::PublisherField; -use thoth_api::model::publisher::PublisherOrderBy; - -use super::ToElementValue; - -pagination_component! { - PublishersComponent, - Publisher, - publishers, - publisher_count, - PublishersRequest, - FetchActionPublishers, - FetchPublishers, - PublishersRequestBody, - Variables, - SEARCH_PUBLISHERS, - PAGINATION_COUNT_PUBLISHERS, - vec![ - PublisherField::PublisherId.to_string(), - PublisherField::PublisherName.to_string(), - PublisherField::PublisherShortname.to_string(), - PublisherField::PublisherUrl.to_string(), - PublisherField::UpdatedAt.to_string(), - ], - PublisherOrderBy, - PublisherField, -} diff --git a/thoth-app/src/component/reference_modal.rs b/thoth-app/src/component/reference_modal.rs deleted file mode 100644 index a2a67f69..00000000 --- a/thoth-app/src/component/reference_modal.rs +++ /dev/null @@ -1,699 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::model::reference::Reference; -use thoth_api::model::{Doi, Isbn, DOI_DOMAIN}; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormDateInput; -use crate::component::utils::FormNumberInput; -use crate::component::utils::FormTextInput; -use crate::component::utils::FormTextInputExtended; -use crate::component::utils::FormTextarea; -use crate::component::utils::FormUrlInput; -use crate::models::reference::create_reference_mutation::CreateReferenceRequest; -use crate::models::reference::create_reference_mutation::CreateReferenceRequestBody; -use crate::models::reference::create_reference_mutation::PushActionCreateReference; -use crate::models::reference::create_reference_mutation::PushCreateReference; -use crate::models::reference::create_reference_mutation::Variables; -use crate::models::reference::reference_fields_query::FetchActionReferenceFields; -use crate::models::reference::reference_fields_query::FetchReferenceFields; -use crate::models::reference::update_reference_mutation::PushActionUpdateReference; -use crate::models::reference::update_reference_mutation::PushUpdateReference; -use crate::models::reference::update_reference_mutation::UpdateReferenceRequest; -use crate::models::reference::update_reference_mutation::UpdateReferenceRequestBody; -use crate::models::reference::update_reference_mutation::Variables as UpdateVariables; -use crate::models::GraphqlFieldList; -use crate::string::CANCEL_BUTTON; -use crate::string::REFERENCES_INFO; - -use super::ToElementValue; -use super::ToOption; - -pub struct ReferenceModalComponent { - reference: Reference, - // Track the user-entered DOI string, which may not be validly formatted - doi: String, - doi_warning: String, - // Track the user-entered ISBN string, which may not be validly formatted - isbn: String, - isbn_warning: String, - in_edit_mode: bool, - create_reference: PushCreateReference, - update_reference: PushUpdateReference, - notification_bus: NotificationDispatcher, - // Store props value locally in order to test whether it has been updated on props change - show_modal_form: bool, - fetch_reference_fields: FetchReferenceFields, - reference_fields: GraphqlFieldList, -} - -pub enum Msg { - CloseModalForm, - ToggleModalFormDisplay, - SetReferenceCreateState(PushActionCreateReference), - CreateReference, - SetReferenceUpdateState(PushActionUpdateReference), - UpdateReference, - SetReferenceFieldsFetchState(FetchActionReferenceFields), - GetReferenceFields, - ChangeOrdinal(String), - ChangeDoi(String), - ChangeUnstructuredCitation(String), - ChangeIssn(String), - ChangeIsbn(String), - ChangeJournalTitle(String), - ChangeArticleTitle(String), - ChangeSeriesTitle(String), - ChangeVolumeTitle(String), - ChangeEdition(String), - ChangeAuthor(String), - ChangeVolume(String), - ChangeIssue(String), - ChangeFirstPage(String), - ChangeComponentNumber(String), - ChangeStandardDesignator(String), - ChangeStandardsBodyName(String), - ChangeStandardsBodyAcronym(String), - ChangeUrl(String), - ChangePublicationDate(String), - ChangeRetrievalDate(String), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub reference_under_edit: Option, - pub work_id: Uuid, - pub show_modal_form: bool, - pub add_reference: Callback, - pub update_reference: Callback, - pub close_modal_form: Callback<()>, -} - -impl Component for ReferenceModalComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let in_edit_mode = false; - let reference: Reference = Default::default(); - let doi = Default::default(); - let doi_warning = Default::default(); - let isbn = Default::default(); - let isbn_warning = Default::default(); - let create_reference = Default::default(); - let update_reference = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let show_modal_form = ctx.props().show_modal_form; - let fetch_reference_fields = Default::default(); - let reference_fields = Default::default(); - - ctx.link().send_message(Msg::GetReferenceFields); - - ReferenceModalComponent { - reference, - doi, - doi_warning, - isbn, - isbn_warning, - in_edit_mode, - create_reference, - update_reference, - notification_bus, - show_modal_form, - fetch_reference_fields, - reference_fields, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::CloseModalForm => { - // Prompt parent form to close this form by updating the props - // (this will eventually cause this form to re-render) - ctx.props().close_modal_form.emit(()); - false - } - Msg::ToggleModalFormDisplay => { - self.in_edit_mode = ctx.props().reference_under_edit.is_some(); - if ctx.props().show_modal_form { - if let Some(reference) = ctx.props().reference_under_edit.clone() { - // editing an existing reference - self.reference = reference; - } - // Ensure DOI variable value is kept in sync with reference object. - self.doi = self.reference.doi.clone().unwrap_or_default().to_string(); - // Clear DOI warning as the variable value is now valid by definition - // (self.reference.doi can only store valid DOIs) - self.doi_warning = Default::default(); - // Ditto for ISBN - self.isbn = self.reference.isbn.clone().unwrap_or_default().to_string(); - self.isbn_warning = Default::default(); - } - true - } - Msg::SetReferenceCreateState(fetch_state) => { - self.create_reference.apply(fetch_state); - match self.create_reference.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_reference { - Some(r) => { - // Send newly-created reference to parent form to process - // (parent form is responsible for closing modal) - ctx.props().add_reference.emit(r.clone()); - self.reference = Default::default(); // reset form - true - } - None => { - ctx.link().send_message(Msg::CloseModalForm); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link().send_message(Msg::CloseModalForm); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateReference => { - // Update reference object with common field-specific logic before saving - self.prepare_for_submission(); - let body = CreateReferenceRequestBody { - variables: Variables { - work_id: ctx.props().work_id, - reference_ordinal: self.reference.reference_ordinal, - doi: self.reference.doi.clone(), - unstructured_citation: self.reference.unstructured_citation.clone(), - issn: self.reference.issn.clone(), - isbn: self.reference.isbn.clone(), - journal_title: self.reference.journal_title.clone(), - article_title: self.reference.article_title.clone(), - series_title: self.reference.series_title.clone(), - volume_title: self.reference.volume_title.clone(), - edition: self.reference.edition, - author: self.reference.author.clone(), - volume: self.reference.volume.clone(), - issue: self.reference.issue.clone(), - first_page: self.reference.first_page.clone(), - component_number: self.reference.component_number.clone(), - standard_designator: self.reference.standard_designator.clone(), - standards_body_name: self.reference.standards_body_name.clone(), - standards_body_acronym: self.reference.standards_body_acronym.clone(), - url: self.reference.url.clone(), - publication_date: self.reference.publication_date, - retrieval_date: self.reference.retrieval_date, - }, - ..Default::default() - }; - let request = CreateReferenceRequest { body }; - self.create_reference = Fetch::new(request); - ctx.link() - .send_future(self.create_reference.fetch(Msg::SetReferenceCreateState)); - ctx.link() - .send_message(Msg::SetReferenceCreateState(FetchAction::Fetching)); - false - } - Msg::SetReferenceUpdateState(fetch_state) => { - self.update_reference.apply(fetch_state); - match self.update_reference.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.update_reference { - Some(r) => { - // Send newly-created reference to parent form to process - // (parent form is responsible for closing modal) - ctx.props().update_reference.emit(r.clone()); - self.reference = Default::default(); // reset form - true - } - None => { - ctx.link().send_message(Msg::CloseModalForm); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link().send_message(Msg::CloseModalForm); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::UpdateReference => { - // Update reference object with common field-specific logic before saving - self.prepare_for_submission(); - let body = UpdateReferenceRequestBody { - variables: UpdateVariables { - reference_id: self.reference.reference_id, - work_id: ctx.props().work_id, - reference_ordinal: self.reference.reference_ordinal, - doi: self.reference.doi.clone(), - unstructured_citation: self.reference.unstructured_citation.clone(), - issn: self.reference.issn.clone(), - isbn: self.reference.isbn.clone(), - journal_title: self.reference.journal_title.clone(), - article_title: self.reference.article_title.clone(), - series_title: self.reference.series_title.clone(), - volume_title: self.reference.volume_title.clone(), - edition: self.reference.edition, - author: self.reference.author.clone(), - volume: self.reference.volume.clone(), - issue: self.reference.issue.clone(), - first_page: self.reference.first_page.clone(), - component_number: self.reference.component_number.clone(), - standard_designator: self.reference.standard_designator.clone(), - standards_body_name: self.reference.standards_body_name.clone(), - standards_body_acronym: self.reference.standards_body_acronym.clone(), - url: self.reference.url.clone(), - publication_date: self.reference.publication_date, - retrieval_date: self.reference.retrieval_date, - }, - ..Default::default() - }; - let request = UpdateReferenceRequest { body }; - self.update_reference = Fetch::new(request); - ctx.link() - .send_future(self.update_reference.fetch(Msg::SetReferenceUpdateState)); - ctx.link() - .send_message(Msg::SetReferenceUpdateState(FetchAction::Fetching)); - false - } - Msg::SetReferenceFieldsFetchState(fetch_state) => { - self.fetch_reference_fields.apply(fetch_state); - self.reference_fields = match self.fetch_reference_fields.as_ref().state() { - FetchState::Fetched(body) => body.data.reference_fields.clone(), - _ => GraphqlFieldList::default(), - }; - true - } - Msg::GetReferenceFields => { - ctx.link().send_future( - self.fetch_reference_fields - .fetch(Msg::SetReferenceFieldsFetchState), - ); - ctx.link() - .send_message(Msg::SetReferenceFieldsFetchState(FetchAction::Fetching)); - false - } - Msg::ChangeOrdinal(value) => { - let ordinal = value.parse::().unwrap_or(0); - self.reference.reference_ordinal.neq_assign(ordinal); - false - } - Msg::ChangeDoi(value) => { - if self.doi.neq_assign(value.trim().to_owned()) { - // If DOI is not correctly formatted, display a warning. - // Don't update self.reference.doi yet, as user may later - // overwrite a new valid value with an invalid one. - self.doi_warning.clear(); - match self.doi.parse::() { - Err(e) => { - match e { - // If no DOI was provided, no warning is required. - ThothError::DoiEmptyError => {} - _ => self.doi_warning = e.to_string(), - } - } - Ok(value) => self.doi = value.to_string(), - } - true - } else { - false - } - } - Msg::ChangeUnstructuredCitation(value) => self - .reference - .unstructured_citation - .neq_assign(value.to_opt_string()), - Msg::ChangeIsbn(value) => { - if self.isbn.neq_assign(value.trim().to_owned()) { - // If ISBN is not correctly formatted, display a warning. - // Don't update self.reference.isbn yet, as user may later - // overwrite a new valid value with an invalid one. - self.isbn_warning.clear(); - match self.isbn.parse::() { - Err(e) => { - match e { - // If no ISBN was provided, no warning is required. - ThothError::IsbnEmptyError => {} - _ => self.isbn_warning = e.to_string(), - } - } - Ok(value) => self.isbn = value.to_string(), - } - true - } else { - false - } - } - Msg::ChangeIssn(value) => self.reference.issn.neq_assign(value.to_opt_string()), - Msg::ChangeJournalTitle(value) => self - .reference - .journal_title - .neq_assign(value.to_opt_string()), - Msg::ChangeArticleTitle(value) => self - .reference - .article_title - .neq_assign(value.to_opt_string()), - Msg::ChangeSeriesTitle(value) => self - .reference - .series_title - .neq_assign(value.to_opt_string()), - Msg::ChangeVolumeTitle(value) => self - .reference - .volume_title - .neq_assign(value.to_opt_string()), - Msg::ChangeEdition(value) => self.reference.edition.neq_assign(value.to_opt_int()), - Msg::ChangeAuthor(value) => self.reference.author.neq_assign(value.to_opt_string()), - Msg::ChangeVolume(value) => self.reference.volume.neq_assign(value.to_opt_string()), - Msg::ChangeIssue(value) => self.reference.issue.neq_assign(value.to_opt_string()), - Msg::ChangeFirstPage(value) => { - self.reference.first_page.neq_assign(value.to_opt_string()) - } - Msg::ChangeComponentNumber(value) => self - .reference - .component_number - .neq_assign(value.to_opt_string()), - Msg::ChangeStandardDesignator(value) => self - .reference - .standard_designator - .neq_assign(value.to_opt_string()), - Msg::ChangeStandardsBodyName(value) => self - .reference - .standards_body_name - .neq_assign(value.to_opt_string()), - Msg::ChangeStandardsBodyAcronym(value) => self - .reference - .standards_body_acronym - .neq_assign(value.to_opt_string()), - Msg::ChangeUrl(value) => self.reference.url.neq_assign(value.to_opt_string()), - Msg::ChangePublicationDate(value) => self - .reference - .publication_date - .neq_assign(value.to_opt_date()), - Msg::ChangeRetrievalDate(value) => self - .reference - .retrieval_date - .neq_assign(value.to_opt_date()), - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - let updated_show_modal_form = self.show_modal_form.neq_assign(ctx.props().show_modal_form); - if updated_show_modal_form { - ctx.link().send_message(Msg::ToggleModalFormDisplay) - } - // Re-render only required if show_modal_form has changed, - // in which case ToggleModalFormDisplay will trigger it - false - } - - fn view(&self, ctx: &Context) -> Html { - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::CloseModalForm - }); - html! { -
- - -
- } - } -} - -impl ReferenceModalComponent { - fn modal_form_status(&self, ctx: &Context) -> String { - match ctx.props().show_modal_form { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } - - fn modal_form_title(&self) -> String { - match self.in_edit_mode { - true => "Edit Reference".to_string(), - false => "New Reference".to_string(), - } - } - - fn modal_form_button(&self) -> String { - match self.in_edit_mode { - true => "Save Reference".to_string(), - false => "Add Reference".to_string(), - } - } - - fn modal_form_action(&self, ctx: &Context) -> Callback { - match self.in_edit_mode { - true => ctx.link().callback(|e: FocusEvent| { - e.prevent_default(); - Msg::UpdateReference - }), - false => ctx.link().callback(|e: FocusEvent| { - e.prevent_default(); - Msg::CreateReference - }), - } - } - - fn prepare_for_submission(&mut self) { - // Only update the ISBN value with the current user-entered string - // if it is validly formatted - otherwise keep the default. - // If no ISBN was provided, no format check is required. - if self.isbn.is_empty() { - self.reference.isbn.neq_assign(None); - } else if let Ok(result) = self.isbn.parse::() { - self.reference.isbn.neq_assign(Some(result)); - } - // Same applies to DOI - if self.doi.is_empty() { - self.reference.doi.neq_assign(None); - } else if let Ok(result) = self.doi.parse::() { - self.reference.doi.neq_assign(Some(result)); - } - } -} diff --git a/thoth-app/src/component/references_form.rs b/thoth-app/src/component/references_form.rs deleted file mode 100644 index 532916d2..00000000 --- a/thoth-app/src/component/references_form.rs +++ /dev/null @@ -1,256 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::model::reference::Reference; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::reference_modal::ReferenceModalComponent; -use crate::models::reference::delete_reference_mutation::DeleteReferenceRequest; -use crate::models::reference::delete_reference_mutation::DeleteReferenceRequestBody; -use crate::models::reference::delete_reference_mutation::PushActionDeleteReference; -use crate::models::reference::delete_reference_mutation::PushDeleteReference; -use crate::models::reference::delete_reference_mutation::Variables as DeleteVariables; -use crate::string::EDIT_BUTTON; -use crate::string::EMPTY_REFERENCES; -use crate::string::REMOVE_BUTTON; - -pub struct ReferencesFormComponent { - show_modal_form: bool, - reference_under_edit: Option, - delete_reference: PushDeleteReference, - notification_bus: NotificationDispatcher, -} - -pub enum Msg { - ToggleModalFormDisplay(bool, Option), - AddReference(Reference), - UpdateReference(Reference), - SetReferenceDeleteState(PushActionDeleteReference), - DeleteReference(Uuid), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub references: Option>, - pub work_id: Uuid, - pub update_references: Callback>>, -} - -impl Component for ReferencesFormComponent { - type Message = Msg; - type Properties = Props; - - fn create(_ctx: &Context) -> Self { - let show_modal_form = false; - let reference_under_edit = Default::default(); - let delete_reference = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - - ReferencesFormComponent { - show_modal_form, - reference_under_edit, - delete_reference, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleModalFormDisplay(show_form, reference) => { - self.show_modal_form = show_form; - self.reference_under_edit = reference; - true - } - Msg::AddReference(reference) => { - // Child form has created a new reference - add it to list - let mut references: Vec = - ctx.props().references.clone().unwrap_or_default(); - references.push(reference); - ctx.props().update_references.emit(Some(references)); - // Close child form - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - true - } - Msg::UpdateReference(r) => { - // Child form has updated an existing reference - replace it in list - let mut references: Vec = - ctx.props().references.clone().unwrap_or_default(); - if let Some(reference) = references - .iter_mut() - .find(|re| re.reference_id == r.reference_id) - { - *reference = r.clone(); - ctx.props().update_references.emit(Some(references)); - } else { - // This should not be possible: the updated reference returned from the - // database does not match any of the locally-stored reference data. - // Refreshing the page will reload the local data from the database. - self.notification_bus.send(Request::NotificationBusMsg(( - "Changes were saved but display failed to update. Refresh your browser to view current data.".to_string(), - NotificationStatus::Warning, - ))); - } - // Close child form - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - true - } - Msg::SetReferenceDeleteState(fetch_state) => { - self.delete_reference.apply(fetch_state); - match self.delete_reference.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_reference { - Some(reference) => { - let to_keep: Vec = ctx - .props() - .references - .clone() - .unwrap_or_default() - .into_iter() - .filter(|r| r.reference_id != reference.reference_id) - .collect(); - ctx.props().update_references.emit(Some(to_keep)); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteReference(reference_id) => { - let body = DeleteReferenceRequestBody { - variables: DeleteVariables { reference_id }, - ..Default::default() - }; - let request = DeleteReferenceRequest { body }; - self.delete_reference = Fetch::new(request); - ctx.link() - .send_future(self.delete_reference.fetch(Msg::SetReferenceDeleteState)); - ctx.link() - .send_message(Msg::SetReferenceDeleteState(FetchAction::Fetching)); - false - } - } - } - - fn view(&self, ctx: &Context) -> Html { - let references = ctx.props().references.clone().unwrap_or_default(); - let open_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleModalFormDisplay(true, None) - }); - html! { - - } - } -} - -impl ReferencesFormComponent { - fn render_reference(&self, ctx: &Context, r: &Reference) -> Html { - let reference = r.clone(); - let reference_id = r.reference_id; - html! { -
- - - -
-
- -
- {&r.reference_ordinal} -
-
- -
- -
- {&r.doi.clone().unwrap_or_default()} -
-
- -
- -
- {&r.unstructured_citation.clone().unwrap_or_default()} -
-
- - -
-
- } - } -} diff --git a/thoth-app/src/component/related_works_form.rs b/thoth-app/src/component/related_works_form.rs deleted file mode 100644 index bde4ba1b..00000000 --- a/thoth-app/src/component/related_works_form.rs +++ /dev/null @@ -1,695 +0,0 @@ -use gloo_timers::callback::Timeout; -use std::str::FromStr; -use thoth_api::account::model::AccountAccess; -use thoth_api::account::model::AccountDetails; -use thoth_api::model::work::Work; -use thoth_api::model::work_relation::RelationType; -use thoth_api::model::work_relation::WorkRelationWithRelatedWork; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormNumberInput; -use crate::component::utils::FormRelationTypeSelect; -use crate::models::work::slim_works_query::FetchActionSlimWorks; -use crate::models::work::slim_works_query::FetchSlimWorks; -use crate::models::work::slim_works_query::SlimWorksRequest; -use crate::models::work::slim_works_query::SlimWorksRequestBody; -use crate::models::work::slim_works_query::Variables; -use crate::models::work_relation::create_work_relation_mutation::CreateWorkRelationRequest; -use crate::models::work_relation::create_work_relation_mutation::CreateWorkRelationRequestBody; -use crate::models::work_relation::create_work_relation_mutation::PushActionCreateWorkRelation; -use crate::models::work_relation::create_work_relation_mutation::PushCreateWorkRelation; -use crate::models::work_relation::create_work_relation_mutation::Variables as CreateVariables; -use crate::models::work_relation::delete_work_relation_mutation::DeleteWorkRelationRequest; -use crate::models::work_relation::delete_work_relation_mutation::DeleteWorkRelationRequestBody; -use crate::models::work_relation::delete_work_relation_mutation::PushActionDeleteWorkRelation; -use crate::models::work_relation::delete_work_relation_mutation::PushDeleteWorkRelation; -use crate::models::work_relation::delete_work_relation_mutation::Variables as DeleteVariables; -use crate::models::work_relation::relation_types_query::FetchActionRelationTypes; -use crate::models::work_relation::relation_types_query::FetchRelationTypes; -use crate::models::work_relation::update_work_relation_mutation::PushActionUpdateWorkRelation; -use crate::models::work_relation::update_work_relation_mutation::PushUpdateWorkRelation; -use crate::models::work_relation::update_work_relation_mutation::UpdateWorkRelationRequest; -use crate::models::work_relation::update_work_relation_mutation::UpdateWorkRelationRequestBody; -use crate::models::work_relation::update_work_relation_mutation::Variables as UpdateVariables; -use crate::models::work_relation::RelationTypeValues; -use crate::models::Dropdown; -use crate::models::EditRoute; -use crate::route::AdminRoute; -use crate::string::CANCEL_BUTTON; -use crate::string::EDIT_BUTTON; -use crate::string::EMPTY_RELATIONS; -use crate::string::REMOVE_BUTTON; -use crate::string::VIEW_BUTTON; -use crate::DEFAULT_DEBOUNCING_TIMEOUT; - -use super::ToElementValue; - -pub struct RelatedWorksFormComponent { - data: RelatedWorksFormData, - relation: WorkRelationWithRelatedWork, - show_modal_form: bool, - in_edit_mode: bool, - show_results: bool, - fetch_works: FetchSlimWorks, - fetch_relation_types: FetchRelationTypes, - create_relation: PushCreateWorkRelation, - delete_relation: PushDeleteWorkRelation, - update_relation: PushUpdateWorkRelation, - notification_bus: NotificationDispatcher, - // Store props value locally in order to test whether it has been updated on props change - resource_access: AccountAccess, - search_callback: Callback<()>, - search_query: String, - debounce_timeout: Option, -} - -#[derive(Default)] -struct RelatedWorksFormData { - works: Vec, - relation_types: Vec, -} - -#[allow(clippy::large_enum_variant)] -pub enum Msg { - ToggleModalFormDisplay(bool, Option), - SetWorksFetchState(FetchActionSlimWorks), - GetWorks, - SetRelationTypesFetchState(FetchActionRelationTypes), - GetRelationTypes, - ToggleSearchResultDisplay(bool), - SearchQueryChanged(String), - SearchWork, - SetRelationCreateState(PushActionCreateWorkRelation), - CreateWorkRelation, - SetRelationUpdateState(PushActionUpdateWorkRelation), - UpdateWorkRelation, - SetRelationDeleteState(PushActionDeleteWorkRelation), - DeleteWorkRelation(Uuid), - AddRelation(Work), - ChangeRelationtype(RelationType), - ChangeOrdinal(String), - ChangeRoute(AdminRoute), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub relations: Option>, - pub work_id: Uuid, - pub current_user: AccountDetails, - pub update_relations: Callback>>, -} - -impl Component for RelatedWorksFormComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let data: RelatedWorksFormData = Default::default(); - let relation: WorkRelationWithRelatedWork = Default::default(); - let show_modal_form = false; - let in_edit_mode = false; - let show_results = false; - let body = SlimWorksRequestBody { - variables: Variables { - limit: Some(100), - publishers: ctx.props().current_user.resource_access.restricted_to(), - ..Default::default() - }, - ..Default::default() - }; - let request = SlimWorksRequest { body }; - let fetch_works = Fetch::new(request); - let fetch_relation_types = Default::default(); - let create_relation = Default::default(); - let delete_relation = Default::default(); - let update_relation = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let resource_access = ctx.props().current_user.resource_access.clone(); - let search_callback = ctx.link().callback(|_| Msg::SearchWork); - let search_query: String = Default::default(); - - ctx.link().send_message(Msg::GetWorks); - ctx.link().send_message(Msg::GetRelationTypes); - - RelatedWorksFormComponent { - data, - relation, - show_modal_form, - in_edit_mode, - show_results, - fetch_works, - fetch_relation_types, - create_relation, - delete_relation, - update_relation, - notification_bus, - resource_access, - search_callback, - search_query, - debounce_timeout: None, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleModalFormDisplay(show_form, r) => { - self.show_modal_form = show_form; - self.in_edit_mode = r.is_some(); - if show_form { - if let Some(relation) = r { - // Editing existing relation: load its current values. - self.relation = relation; - } - } - true - } - Msg::SetWorksFetchState(fetch_state) => { - self.fetch_works.apply(fetch_state); - self.data.works = match self.fetch_works.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.works.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetWorks => { - ctx.link() - .send_future(self.fetch_works.fetch(Msg::SetWorksFetchState)); - ctx.link() - .send_message(Msg::SetWorksFetchState(FetchAction::Fetching)); - false - } - Msg::SetRelationTypesFetchState(fetch_state) => { - self.fetch_relation_types.apply(fetch_state); - self.data.relation_types = match self.fetch_relation_types.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.relation_types.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetRelationTypes => { - ctx.link().send_future( - self.fetch_relation_types - .fetch(Msg::SetRelationTypesFetchState), - ); - ctx.link() - .send_message(Msg::SetRelationTypesFetchState(FetchAction::Fetching)); - false - } - Msg::SetRelationCreateState(fetch_state) => { - self.create_relation.apply(fetch_state); - match self.create_relation.clone().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_work_relation { - Some(r) => { - let relation = r.clone(); - let mut relations: Vec = - ctx.props().relations.clone().unwrap_or_default(); - relations.push(relation); - ctx.props().update_relations.emit(Some(relations)); - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - true - } - None => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateWorkRelation => { - let body = CreateWorkRelationRequestBody { - variables: CreateVariables { - relator_work_id: ctx.props().work_id, - related_work_id: self.relation.related_work_id, - relation_type: self.relation.relation_type, - relation_ordinal: self.relation.relation_ordinal, - }, - ..Default::default() - }; - let request = CreateWorkRelationRequest { body }; - self.create_relation = Fetch::new(request); - ctx.link() - .send_future(self.create_relation.fetch(Msg::SetRelationCreateState)); - ctx.link() - .send_message(Msg::SetRelationCreateState(FetchAction::Fetching)); - false - } - Msg::SetRelationUpdateState(fetch_state) => { - self.update_relation.apply(fetch_state); - match self.update_relation.clone().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.update_work_relation { - Some(r) => { - let mut relations: Vec = - ctx.props().relations.clone().unwrap_or_default(); - if let Some(relation) = relations - .iter_mut() - .find(|rn| rn.work_relation_id == r.work_relation_id) - { - *relation = r.clone(); - ctx.props().update_relations.emit(Some(relations)); - } else { - // This should not be possible: the updated relation returned from the - // database does not match any of the locally-stored relation data. - // Refreshing the page will reload the local data from the database. - self.notification_bus.send(Request::NotificationBusMsg(( - "Changes were saved but display failed to update. Refresh your browser to view current data.".to_string(), - NotificationStatus::Warning, - ))); - } - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - true - } - None => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(false, None)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::UpdateWorkRelation => { - let body = UpdateWorkRelationRequestBody { - variables: UpdateVariables { - work_relation_id: self.relation.work_relation_id, - relator_work_id: ctx.props().work_id, - related_work_id: self.relation.related_work_id, - relation_type: self.relation.relation_type, - relation_ordinal: self.relation.relation_ordinal, - }, - ..Default::default() - }; - let request = UpdateWorkRelationRequest { body }; - self.update_relation = Fetch::new(request); - ctx.link() - .send_future(self.update_relation.fetch(Msg::SetRelationUpdateState)); - ctx.link() - .send_message(Msg::SetRelationUpdateState(FetchAction::Fetching)); - false - } - Msg::SetRelationDeleteState(fetch_state) => { - self.delete_relation.apply(fetch_state); - match self.delete_relation.clone().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_work_relation { - Some(relation) => { - let to_keep: Vec = ctx - .props() - .relations - .clone() - .unwrap_or_default() - .into_iter() - .filter(|r| r.work_relation_id != relation.work_relation_id) - .collect(); - ctx.props().update_relations.emit(Some(to_keep)); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteWorkRelation(work_relation_id) => { - let body = DeleteWorkRelationRequestBody { - variables: DeleteVariables { work_relation_id }, - ..Default::default() - }; - let request = DeleteWorkRelationRequest { body }; - self.delete_relation = Fetch::new(request); - ctx.link() - .send_future(self.delete_relation.fetch(Msg::SetRelationDeleteState)); - ctx.link() - .send_message(Msg::SetRelationDeleteState(FetchAction::Fetching)); - false - } - Msg::AddRelation(work) => { - self.relation.related_work_id = work.work_id; - self.relation.related_work = work; - ctx.link() - .send_message(Msg::ToggleModalFormDisplay(true, None)); - true - } - Msg::ToggleSearchResultDisplay(value) => { - self.show_results = value; - true - } - Msg::SearchQueryChanged(value) => { - self.search_query = value; - // cancel previous timeout - self.debounce_timeout = self.debounce_timeout.take().and_then(|timeout| { - timeout.cancel(); - None - }); - // start new timeout - let search_callback = self.search_callback.clone(); - let timeout = Timeout::new(DEFAULT_DEBOUNCING_TIMEOUT, move || { - search_callback.emit(()); - }); - self.debounce_timeout = Some(timeout); - false - } - Msg::SearchWork => { - let body = SlimWorksRequestBody { - variables: Variables { - filter: Some(self.search_query.clone()), - limit: Some(25), - publishers: ctx.props().current_user.resource_access.restricted_to(), - ..Default::default() - }, - ..Default::default() - }; - let request = SlimWorksRequest { body }; - self.fetch_works = Fetch::new(request); - ctx.link().send_message(Msg::GetWorks); - false - } - Msg::ChangeRelationtype(val) => self.relation.relation_type.neq_assign(val), - Msg::ChangeOrdinal(ordinal) => { - let ordinal = ordinal.parse::().unwrap_or(0); - self.relation.relation_ordinal.neq_assign(ordinal); - false // otherwise we re-render the component and reset the value - } - Msg::ChangeRoute(r) => { - ctx.link().history().unwrap().push(r); - false - } - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - let updated_permissions = self - .resource_access - .neq_assign(ctx.props().current_user.resource_access.clone()); - if updated_permissions { - // Reload works list to reflect the user's access rights. - // This will override any search box filtering, but should only occur rarely. - let body = SlimWorksRequestBody { - variables: Variables { - limit: Some(100), - publishers: ctx.props().current_user.resource_access.restricted_to(), - ..Default::default() - }, - ..Default::default() - }; - let request = SlimWorksRequest { body }; - self.fetch_works = Fetch::new(request); - ctx.link().send_message(Msg::GetWorks); - false - } else { - true - } - } - - fn view(&self, ctx: &Context) -> Html { - let relations = ctx.props().relations.clone().unwrap_or_default(); - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleModalFormDisplay(false, None) - }); - html! { - - } - } -} - -impl RelatedWorksFormComponent { - fn modal_form_status(&self) -> String { - match self.show_modal_form { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } - - fn modal_form_title(&self) -> String { - match self.in_edit_mode { - true => "Edit Related Work".to_string(), - false => "New Related Work".to_string(), - } - } - - fn modal_form_button(&self) -> String { - match self.in_edit_mode { - true => "Save Related Work".to_string(), - false => "Add Related Work".to_string(), - } - } - - fn modal_form_action(&self, ctx: &Context) -> Callback { - match self.in_edit_mode { - true => ctx.link().callback(|e: FocusEvent| { - e.prevent_default(); - Msg::UpdateWorkRelation - }), - false => ctx.link().callback(|e: FocusEvent| { - e.prevent_default(); - Msg::CreateWorkRelation - }), - } - } - - fn search_dropdown_status(&self) -> String { - match self.show_results { - true => "dropdown is-active".to_string(), - false => "dropdown".to_string(), - } - } - - fn render_relation(&self, ctx: &Context, r: &WorkRelationWithRelatedWork) -> Html { - let relation = r.clone(); - let relation_id = r.work_relation_id; - let route = r.related_work.edit_route(); - html! { -
- - - -
-
- -
- {&r.relation_type} -
-
-
- -
- {&r.related_work.full_title} -
-
-
- -
- {&r.relation_ordinal.clone()} -
-
- - -
-
- } - } -} diff --git a/thoth-app/src/component/root.rs b/thoth-app/src/component/root.rs deleted file mode 100644 index e3e36dd1..00000000 --- a/thoth-app/src/component/root.rs +++ /dev/null @@ -1,193 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use semver::Version; -use thoth_api::account::model::AccountDetails; -use thoth_errors::ThothError; -use yew::html; -use yew::prelude::*; -use yew::virtual_dom::VNode; -use yew::Callback; -use yew_agent::Dispatched; -use yew_router::prelude::*; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::agent::session_timer::SessionTimerAgent; -use crate::agent::session_timer::SessionTimerDispatcher; -use crate::agent::session_timer::SessionTimerRequest; -use crate::agent::version_timer::VersionTimerAgent; -use crate::agent::version_timer::VersionTimerDispatcher; -use crate::agent::version_timer::VersionTimerRequest; -use crate::component::admin::AdminComponent; -use crate::component::login::LoginComponent; -use crate::component::navbar::NavbarComponent; -use crate::component::notification::NotificationComponent; -use crate::route::AdminRoute; -use crate::route::AppRoute; -use crate::service::account::AccountError; -use crate::service::account::AccountService; -use crate::service::version; -use crate::string::NEW_VERSION_PROMPT; - -pub struct RootComponent { - account_service: AccountService, - current_user: Option, - session_timer_agent: SessionTimerDispatcher, - version_timer_agent: VersionTimerDispatcher, - notification_bus: NotificationDispatcher, -} - -pub enum Msg { - FetchCurrentUser, - CurrentUserResponse(Result), - RenewToken, - RenewTokenResponse(Result), - CheckVersion, - CheckVersionResponse(Result), - UpdateAccount(AccountDetails), - Login(AccountDetails), - Logout, -} - -impl Component for RootComponent { - type Message = Msg; - type Properties = (); - - fn create(_ctx: &Context) -> Self { - let session_timer_agent = SessionTimerAgent::dispatcher(); - let version_timer_agent = VersionTimerAgent::dispatcher(); - let notification_bus = NotificationBus::dispatcher(); - - RootComponent { - account_service: AccountService::new(), - current_user: Default::default(), - session_timer_agent, - version_timer_agent, - notification_bus, - } - } - - fn rendered(&mut self, ctx: &Context, first_render: bool) { - if first_render { - // Start timer to check for updated app version - self.version_timer_agent.send(VersionTimerRequest::Start( - ctx.link().callback(|_| Msg::CheckVersion), - )); - if self.account_service.is_loggedin() { - ctx.link().send_message(Msg::FetchCurrentUser); - } - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::FetchCurrentUser => { - let mut service = self.account_service.clone(); - ctx.link().send_future(async move { - Msg::CurrentUserResponse(service.account_details().await) - }); - } - Msg::RenewToken => { - let mut service = self.account_service.clone(); - ctx.link().send_future(async move { - Msg::RenewTokenResponse(service.renew_token().await) - }); - } - Msg::CheckVersion => { - ctx.link() - .send_future(async { Msg::CheckVersionResponse(version::get_version().await) }); - } - Msg::CurrentUserResponse(Ok(account_details)) => { - ctx.link().send_message(Msg::Login(account_details)); - } - Msg::CurrentUserResponse(Err(_)) => { - ctx.link().send_message(Msg::Logout); - } - Msg::RenewTokenResponse(Ok(account_details)) => { - ctx.link().send_message(Msg::UpdateAccount(account_details)); - } - Msg::RenewTokenResponse(Err(_)) => { - ctx.link().send_message(Msg::Logout); - } - Msg::CheckVersionResponse(Ok(server_version)) => { - if let Ok(app_version) = Version::parse(env!("CARGO_PKG_VERSION")) { - if server_version > app_version { - self.notification_bus.send(Request::NotificationBusMsg(( - NEW_VERSION_PROMPT.into(), - NotificationStatus::Success, - ))); - // Don't send repeated notifications. - self.version_timer_agent.send(VersionTimerRequest::Stop); - } - } - } - Msg::CheckVersionResponse(Err(_)) => { - // Unable to determine if a new app version is available. - // Ignore and move on - not worth alerting the user. - } - Msg::UpdateAccount(account_details) => { - self.current_user = Some(account_details); - } - Msg::Login(account_details) => { - // start session timer - self.session_timer_agent.send(SessionTimerRequest::Start( - ctx.link().callback(|_| Msg::RenewToken), - )); - ctx.link().send_message(Msg::UpdateAccount(account_details)); - } - Msg::Logout => { - self.account_service.logout(); - self.session_timer_agent.send(SessionTimerRequest::Stop); - self.current_user = None; - } - } - true - } - - fn view(&self, ctx: &Context) -> VNode { - let callback_login = ctx.link().callback(Msg::Login); - let callback_logout = ctx.link().callback(|_| Msg::Logout); - let current_user = self.current_user.clone(); - let render = - Switch::render(move |r| switch_app(r, current_user.clone(), callback_login.clone())); - - html! { - -
- -
- -
- { render } /> -
-
- } - } -} - -fn switch_app( - route: &AppRoute, - current_user: Option, - callback_login: Callback, -) -> Html { - match route { - AppRoute::Login => html! { -
- -
- }, - AppRoute::Admin => html! { -
- -
- }, - AppRoute::AdminHome | AppRoute::Home => html! { - to={ AdminRoute::Dashboard }/> - }, - AppRoute::Error => html! { - "Page not found" - }, - } -} diff --git a/thoth-app/src/component/series.rs b/thoth-app/src/component/series.rs deleted file mode 100644 index a3415194..00000000 --- a/thoth-app/src/component/series.rs +++ /dev/null @@ -1,455 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use std::str::FromStr; -use thoth_api::account::model::AccountAccess; -use thoth_api::account::model::AccountDetails; -use thoth_api::model::imprint::ImprintWithPublisher; -use thoth_api::model::series::SeriesType; -use thoth_api::model::series::SeriesWithImprint; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yew_router::history::History; -use yew_router::prelude::RouterScopeExt; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::delete_dialogue::ConfirmDeleteComponent; -use crate::component::utils::FormImprintSelect; -use crate::component::utils::FormSeriesTypeSelect; -use crate::component::utils::FormTextInput; -use crate::component::utils::FormTextarea; -use crate::component::utils::FormUrlInput; -use crate::component::utils::Loader; -use crate::models::imprint::imprints_query::FetchActionImprints; -use crate::models::imprint::imprints_query::FetchImprints; -use crate::models::imprint::imprints_query::ImprintsRequest; -use crate::models::imprint::imprints_query::ImprintsRequestBody; -use crate::models::imprint::imprints_query::Variables as ImprintsVariables; -use crate::models::series::delete_series_mutation::DeleteSeriesRequest; -use crate::models::series::delete_series_mutation::DeleteSeriesRequestBody; -use crate::models::series::delete_series_mutation::PushActionDeleteSeries; -use crate::models::series::delete_series_mutation::PushDeleteSeries; -use crate::models::series::delete_series_mutation::Variables as DeleteVariables; -use crate::models::series::series_query::FetchActionSeries; -use crate::models::series::series_query::FetchSeries; -use crate::models::series::series_query::SeriesRequest; -use crate::models::series::series_query::SeriesRequestBody; -use crate::models::series::series_query::Variables; -use crate::models::series::series_types_query::FetchActionSeriesTypes; -use crate::models::series::series_types_query::FetchSeriesTypes; -use crate::models::series::update_series_mutation::PushActionUpdateSeries; -use crate::models::series::update_series_mutation::PushUpdateSeries; -use crate::models::series::update_series_mutation::UpdateSeriesRequest; -use crate::models::series::update_series_mutation::UpdateSeriesRequestBody; -use crate::models::series::update_series_mutation::Variables as UpdateVariables; -use crate::models::series::SeriesTypeValues; -use crate::route::AdminRoute; -use crate::string::SAVE_BUTTON; - -use super::ToElementValue; -use super::ToOption; - -pub struct SeriesComponent { - series: SeriesWithImprint, - fetch_series: FetchSeries, - push_series: PushUpdateSeries, - data: SeriesFormData, - fetch_imprints: FetchImprints, - fetch_series_types: FetchSeriesTypes, - delete_series: PushDeleteSeries, - notification_bus: NotificationDispatcher, - // Store props value locally in order to test whether it has been updated on props change - resource_access: AccountAccess, -} - -#[derive(Default)] -struct SeriesFormData { - imprints: Vec, - series_types: Vec, -} - -#[allow(clippy::large_enum_variant)] -pub enum Msg { - SetImprintsFetchState(FetchActionImprints), - GetImprints, - SetSeriesTypesFetchState(FetchActionSeriesTypes), - GetSeriesTypes, - SetSeriesFetchState(FetchActionSeries), - GetSeries, - SetSeriesPushState(PushActionUpdateSeries), - UpdateSeries, - SetSeriesDeleteState(PushActionDeleteSeries), - DeleteSeries, - ChangeSeriesType(SeriesType), - ChangeImprint(Uuid), - ChangeSeriesName(String), - ChangeIssnPrint(String), - ChangeIssnDigital(String), - ChangeSeriesUrl(String), - ChangeSeriesDescription(String), - ChangeSeriesCfpUrl(String), -} - -#[derive(PartialEq, Eq, Properties)] -pub struct Props { - pub series_id: Uuid, - pub current_user: AccountDetails, -} - -impl Component for SeriesComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let fetch_series: FetchSeries = Default::default(); - let push_series = Default::default(); - let delete_series = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - let series: SeriesWithImprint = Default::default(); - let data: SeriesFormData = Default::default(); - let fetch_imprints: FetchImprints = Default::default(); - let fetch_series_types: FetchSeriesTypes = Default::default(); - let resource_access = ctx.props().current_user.resource_access.clone(); - - ctx.link().send_message(Msg::GetSeries); - ctx.link().send_message(Msg::GetImprints); - ctx.link().send_message(Msg::GetSeriesTypes); - - SeriesComponent { - series, - fetch_series, - push_series, - data, - fetch_imprints, - fetch_series_types, - delete_series, - notification_bus, - resource_access, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::SetImprintsFetchState(fetch_state) => { - self.fetch_imprints.apply(fetch_state); - self.data.imprints = match self.fetch_imprints.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.imprints.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetImprints => { - let body = ImprintsRequestBody { - variables: ImprintsVariables { - limit: Some(100), - publishers: ctx.props().current_user.resource_access.restricted_to(), - ..Default::default() - }, - ..Default::default() - }; - let request = ImprintsRequest { body }; - self.fetch_imprints = Fetch::new(request); - - ctx.link() - .send_future(self.fetch_imprints.fetch(Msg::SetImprintsFetchState)); - ctx.link() - .send_message(Msg::SetImprintsFetchState(FetchAction::Fetching)); - false - } - Msg::SetSeriesTypesFetchState(fetch_state) => { - self.fetch_series_types.apply(fetch_state); - self.data.series_types = match self.fetch_series_types.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.series_types.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetSeriesTypes => { - ctx.link() - .send_future(self.fetch_series_types.fetch(Msg::SetSeriesTypesFetchState)); - ctx.link() - .send_message(Msg::SetSeriesTypesFetchState(FetchAction::Fetching)); - false - } - Msg::SetSeriesFetchState(fetch_state) => { - self.fetch_series.apply(fetch_state); - match self.fetch_series.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => { - self.series = match &body.data.series { - Some(c) => c.to_owned(), - None => Default::default(), - }; - // If user doesn't have permission to edit this object, redirect to dashboard - if let Some(publishers) = - ctx.props().current_user.resource_access.restricted_to() - { - if !publishers - .contains(&self.series.imprint.publisher.publisher_id.to_string()) - { - ctx.link().history().unwrap().push(AdminRoute::Dashboard); - } - } - true - } - FetchState::Failed(_, _err) => false, - } - } - Msg::GetSeries => { - let body = SeriesRequestBody { - variables: Variables { - series_id: Some(ctx.props().series_id), - }, - ..Default::default() - }; - let request = SeriesRequest { body }; - self.fetch_series = Fetch::new(request); - - ctx.link() - .send_future(self.fetch_series.fetch(Msg::SetSeriesFetchState)); - ctx.link() - .send_message(Msg::SetSeriesFetchState(FetchAction::Fetching)); - false - } - Msg::SetSeriesPushState(fetch_state) => { - self.push_series.apply(fetch_state); - match self.push_series.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.update_series { - Some(s) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Saved {}", s.series_name), - NotificationStatus::Success, - ))); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::UpdateSeries => { - let body = UpdateSeriesRequestBody { - variables: UpdateVariables { - series_id: self.series.series_id, - series_type: self.series.series_type, - series_name: self.series.series_name.clone(), - issn_print: self.series.issn_print.clone(), - issn_digital: self.series.issn_digital.clone(), - series_url: self.series.series_url.clone(), - series_description: self.series.series_description.clone(), - series_cfp_url: self.series.series_cfp_url.clone(), - imprint_id: self.series.imprint.imprint_id, - }, - ..Default::default() - }; - let request = UpdateSeriesRequest { body }; - self.push_series = Fetch::new(request); - ctx.link() - .send_future(self.push_series.fetch(Msg::SetSeriesPushState)); - ctx.link() - .send_message(Msg::SetSeriesPushState(FetchAction::Fetching)); - false - } - Msg::SetSeriesDeleteState(fetch_state) => { - self.delete_series.apply(fetch_state); - match self.delete_series.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_series { - Some(s) => { - self.notification_bus.send(Request::NotificationBusMsg(( - format!("Deleted {}", s.series_name), - NotificationStatus::Success, - ))); - ctx.link().history().unwrap().push(AdminRoute::Serieses); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteSeries => { - let body = DeleteSeriesRequestBody { - variables: DeleteVariables { - series_id: self.series.series_id, - }, - ..Default::default() - }; - let request = DeleteSeriesRequest { body }; - self.delete_series = Fetch::new(request); - ctx.link() - .send_future(self.delete_series.fetch(Msg::SetSeriesDeleteState)); - ctx.link() - .send_message(Msg::SetSeriesDeleteState(FetchAction::Fetching)); - false - } - Msg::ChangeSeriesType(series_type) => self.series.series_type.neq_assign(series_type), - Msg::ChangeImprint(imprint_id) => self.series.imprint.imprint_id.neq_assign(imprint_id), - Msg::ChangeSeriesName(series_name) => self - .series - .series_name - .neq_assign(series_name.trim().to_owned()), - Msg::ChangeIssnPrint(issn_print) => self - .series - .issn_print - .neq_assign(issn_print.to_opt_string()), - Msg::ChangeIssnDigital(issn_digital) => self - .series - .issn_digital - .neq_assign(issn_digital.to_opt_string()), - Msg::ChangeSeriesUrl(value) => self.series.series_url.neq_assign(value.to_opt_string()), - Msg::ChangeSeriesDescription(value) => self - .series - .series_description - .neq_assign(value.to_opt_string()), - Msg::ChangeSeriesCfpUrl(value) => { - self.series.series_cfp_url.neq_assign(value.to_opt_string()) - } - } - } - - fn changed(&mut self, ctx: &Context) -> bool { - let updated_permissions = self - .resource_access - .neq_assign(ctx.props().current_user.resource_access.clone()); - if updated_permissions { - ctx.link().send_message(Msg::GetImprints); - } - false - } - - fn view(&self, ctx: &Context) -> Html { - match self.fetch_series.as_ref().state() { - FetchState::NotFetching(_) => html! {}, - FetchState::Fetching(_) => html! {}, - FetchState::Fetched(_body) => { - let callback = ctx.link().callback(|event: FocusEvent| { - event.prevent_default(); - Msg::UpdateSeries - }); - html! { - <> - -
- - - - - - - - - -
-
- -
-
- - - } - } - FetchState::Failed(_, err) => html! { - { ThothError::from(err).to_string() } - }, - } - } -} diff --git a/thoth-app/src/component/serieses.rs b/thoth-app/src/component/serieses.rs deleted file mode 100644 index 9d470b1e..00000000 --- a/thoth-app/src/component/serieses.rs +++ /dev/null @@ -1,36 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use crate::models::series::serieses_query::FetchActionSerieses; -use crate::models::series::serieses_query::FetchSerieses; -use crate::models::series::serieses_query::SeriesesRequest; -use crate::models::series::serieses_query::SeriesesRequestBody; -use crate::models::series::serieses_query::Variables; -use thoth_api::model::series::SeriesField; -use thoth_api::model::series::SeriesOrderBy; -use thoth_api::model::series::SeriesWithImprint; - -use super::ToElementValue; - -pagination_component! { - SeriesesComponent, - SeriesWithImprint, - serieses, - series_count, - SeriesesRequest, - FetchActionSerieses, - FetchSerieses, - SeriesesRequestBody, - Variables, - SEARCH_SERIESES, - PAGINATION_COUNT_SERIESES, - vec![ - SeriesField::SeriesId.to_string(), - SeriesField::SeriesName.to_string(), - SeriesField::SeriesType.to_string(), - SeriesField::IssnPrint.to_string(), - SeriesField::IssnDigital.to_string(), - SeriesField::UpdatedAt.to_string(), - ], - SeriesOrderBy, - SeriesField, -} diff --git a/thoth-app/src/component/subjects_form.rs b/thoth-app/src/component/subjects_form.rs deleted file mode 100644 index ac0c0464..00000000 --- a/thoth-app/src/component/subjects_form.rs +++ /dev/null @@ -1,396 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use std::str::FromStr; -use thoth_api::model::subject::Subject; -use thoth_api::model::subject::SubjectType; -use thoth_errors::ThothError; -use uuid::Uuid; -use yew::html; -use yew::prelude::*; -use yew_agent::Dispatched; -use yewtil::fetch::Fetch; -use yewtil::fetch::FetchAction; -use yewtil::fetch::FetchState; -use yewtil::NeqAssign; - -use crate::agent::notification_bus::NotificationBus; -use crate::agent::notification_bus::NotificationDispatcher; -use crate::agent::notification_bus::NotificationStatus; -use crate::agent::notification_bus::Request; -use crate::component::utils::FormNumberInput; -use crate::component::utils::FormSubjectTypeSelect; -use crate::component::utils::FormTextInput; -use crate::models::subject::create_subject_mutation::CreateSubjectRequest; -use crate::models::subject::create_subject_mutation::CreateSubjectRequestBody; -use crate::models::subject::create_subject_mutation::PushActionCreateSubject; -use crate::models::subject::create_subject_mutation::PushCreateSubject; -use crate::models::subject::create_subject_mutation::Variables; -use crate::models::subject::delete_subject_mutation::DeleteSubjectRequest; -use crate::models::subject::delete_subject_mutation::DeleteSubjectRequestBody; -use crate::models::subject::delete_subject_mutation::PushActionDeleteSubject; -use crate::models::subject::delete_subject_mutation::PushDeleteSubject; -use crate::models::subject::delete_subject_mutation::Variables as DeleteVariables; -use crate::models::subject::subject_types_query::FetchActionSubjectTypes; -use crate::models::subject::subject_types_query::FetchSubjectTypes; -use crate::models::subject::SubjectTypeValues; -use crate::string::CANCEL_BUTTON; -use crate::string::EMPTY_SUBJECTS; -use crate::string::REMOVE_BUTTON; - -use super::ToElementValue; - -pub struct SubjectsFormComponent { - data: SubjectsFormData, - new_subject: Subject, - show_add_form: bool, - fetch_subject_types: FetchSubjectTypes, - push_subject: PushCreateSubject, - delete_subject: PushDeleteSubject, - notification_bus: NotificationDispatcher, -} - -#[derive(Default)] -struct SubjectsFormData { - subject_types: Vec, -} - -pub enum Msg { - ToggleAddFormDisplay(bool), - SetSubjectTypesFetchState(FetchActionSubjectTypes), - GetSubjectTypes, - SetSubjectPushState(PushActionCreateSubject), - CreateSubject, - SetSubjectDeleteState(PushActionDeleteSubject), - DeleteSubject(Uuid), - ChangeSubjectType(SubjectType), - ChangeCode(String), - ChangeOrdinal(String), -} - -#[derive(Clone, Properties, PartialEq)] -pub struct Props { - pub subjects: Option>, - pub work_id: Uuid, - pub update_subjects: Callback>>, -} - -impl Component for SubjectsFormComponent { - type Message = Msg; - type Properties = Props; - - fn create(ctx: &Context) -> Self { - let data: SubjectsFormData = Default::default(); - let show_add_form = false; - let new_subject: Subject = Default::default(); - let push_subject = Default::default(); - let delete_subject = Default::default(); - let notification_bus = NotificationBus::dispatcher(); - - ctx.link().send_message(Msg::GetSubjectTypes); - - SubjectsFormComponent { - data, - new_subject, - show_add_form, - fetch_subject_types: Default::default(), - push_subject, - delete_subject, - notification_bus, - } - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - Msg::ToggleAddFormDisplay(value) => { - self.show_add_form = value; - true - } - Msg::SetSubjectTypesFetchState(fetch_state) => { - self.fetch_subject_types.apply(fetch_state); - self.data.subject_types = match self.fetch_subject_types.as_ref().state() { - FetchState::NotFetching(_) => vec![], - FetchState::Fetching(_) => vec![], - FetchState::Fetched(body) => body.data.subject_types.enum_values.clone(), - FetchState::Failed(_, _err) => vec![], - }; - true - } - Msg::GetSubjectTypes => { - ctx.link().send_future( - self.fetch_subject_types - .fetch(Msg::SetSubjectTypesFetchState), - ); - ctx.link() - .send_message(Msg::SetSubjectTypesFetchState(FetchAction::Fetching)); - false - } - Msg::SetSubjectPushState(fetch_state) => { - self.push_subject.apply(fetch_state); - match self.push_subject.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.create_subject { - Some(p) => { - let subject = p.clone(); - let mut subjects: Vec = - ctx.props().subjects.clone().unwrap_or_default(); - subjects.push(subject); - ctx.props().update_subjects.emit(Some(subjects)); - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - true - } - None => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - ctx.link().send_message(Msg::ToggleAddFormDisplay(false)); - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::CreateSubject => { - let body = CreateSubjectRequestBody { - variables: Variables { - work_id: ctx.props().work_id, - subject_type: self.new_subject.subject_type, - subject_code: self.new_subject.subject_code.clone(), - subject_ordinal: self.new_subject.subject_ordinal, - }, - ..Default::default() - }; - let request = CreateSubjectRequest { body }; - self.push_subject = Fetch::new(request); - ctx.link() - .send_future(self.push_subject.fetch(Msg::SetSubjectPushState)); - ctx.link() - .send_message(Msg::SetSubjectPushState(FetchAction::Fetching)); - false - } - Msg::SetSubjectDeleteState(fetch_state) => { - self.delete_subject.apply(fetch_state); - match self.delete_subject.as_ref().state() { - FetchState::NotFetching(_) => false, - FetchState::Fetching(_) => false, - FetchState::Fetched(body) => match &body.data.delete_subject { - Some(subject) => { - let to_keep: Vec = ctx - .props() - .subjects - .clone() - .unwrap_or_default() - .into_iter() - .filter(|s| s.subject_id != subject.subject_id) - .collect(); - ctx.props().update_subjects.emit(Some(to_keep)); - true - } - None => { - self.notification_bus.send(Request::NotificationBusMsg(( - "Failed to save".to_string(), - NotificationStatus::Danger, - ))); - false - } - }, - FetchState::Failed(_, err) => { - self.notification_bus.send(Request::NotificationBusMsg(( - ThothError::from(err).to_string(), - NotificationStatus::Danger, - ))); - false - } - } - } - Msg::DeleteSubject(subject_id) => { - let body = DeleteSubjectRequestBody { - variables: DeleteVariables { subject_id }, - ..Default::default() - }; - let request = DeleteSubjectRequest { body }; - self.delete_subject = Fetch::new(request); - ctx.link() - .send_future(self.delete_subject.fetch(Msg::SetSubjectDeleteState)); - ctx.link() - .send_message(Msg::SetSubjectDeleteState(FetchAction::Fetching)); - false - } - Msg::ChangeSubjectType(val) => self.new_subject.subject_type.neq_assign(val), - Msg::ChangeCode(code) => self - .new_subject - .subject_code - .neq_assign(code.trim().to_owned()), - Msg::ChangeOrdinal(ordinal) => { - let ordinal = ordinal.parse::().unwrap_or(0); - self.new_subject.subject_ordinal.neq_assign(ordinal); - false // otherwise we re-render the component and reset the value - } - } - } - - fn view(&self, ctx: &Context) -> Html { - let mut subjects = ctx.props().subjects.clone().unwrap_or_default(); - let open_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleAddFormDisplay(true) - }); - let close_modal = ctx.link().callback(|e: MouseEvent| { - e.prevent_default(); - Msg::ToggleAddFormDisplay(false) - }); - subjects.sort_by(|a, b| { - if a.subject_type == b.subject_type { - a.subject_ordinal.partial_cmp(&b.subject_ordinal).unwrap() - } else { - a.subject_type.partial_cmp(&b.subject_type).unwrap() - } - }); - html! { - - } - } -} - -impl SubjectsFormComponent { - fn add_form_status(&self) -> String { - match self.show_add_form { - true => "modal is-active".to_string(), - false => "modal".to_string(), - } - } - - fn render_subject(&self, ctx: &Context, s: &Subject) -> Html { - let subject_id = s.subject_id; - html! { -
- - - -
-
- -
- {&s.subject_type} -
-
- -
- -
- {&s.subject_code.clone()} -
-
- -
- -
- {&s.subject_ordinal.clone()} -
-
- - -
-
- } - } -} diff --git a/thoth-app/src/component/utils.rs b/thoth-app/src/component/utils.rs deleted file mode 100644 index 04e56430..00000000 --- a/thoth-app/src/component/utils.rs +++ /dev/null @@ -1,1037 +0,0 @@ -#![allow(clippy::unnecessary_operation)] - -use thoth_api::model::contribution::ContributionType; -use thoth_api::model::contributor::Contributor; -use thoth_api::model::imprint::ImprintWithPublisher; -use thoth_api::model::institution::CountryCode; -use thoth_api::model::language::LanguageCode; -use thoth_api::model::language::LanguageRelation; -use thoth_api::model::location::LocationPlatform; -use thoth_api::model::price::CurrencyCode; -use thoth_api::model::publication::PublicationType; -use thoth_api::model::publisher::Publisher; -use thoth_api::model::series::SeriesType; -use thoth_api::model::subject::SubjectType; -use thoth_api::model::work::WorkStatus; -use thoth_api::model::work::WorkType; -use thoth_api::model::work_relation::RelationType; -use uuid::Uuid; -use yew::function_component; -use yew::html; -use yew::virtual_dom::VNode; -use yew::Callback; -use yew::Event; -use yew::FocusEvent; -use yew::InputEvent; -use yew::MouseEvent; -use yew::Properties; - -use crate::models::contribution::ContributionTypeValues; -use crate::models::institution::CountryCodeValues; -use crate::models::language::LanguageCodeValues; -use crate::models::language::LanguageRelationValues; -use crate::models::location::LocationPlatformValues; -use crate::models::price::CurrencyCodeValues; -use crate::models::publication::PublicationTypeValues; -use crate::models::series::SeriesTypeValues; -use crate::models::subject::SubjectTypeValues; -use crate::models::work::WorkStatusValues; -use crate::models::work::WorkTypeValues; -use crate::models::work_relation::RelationTypeValues; -use crate::string::NO; -use crate::string::RELOAD_BUTTON; -use crate::string::YES; - -#[derive(PartialEq, Properties)] -pub struct FormInputProps { - pub label: String, - pub value: String, - pub input_type: String, - #[prop_or_default] - pub oninput: Callback, - #[prop_or_default] - pub onblur: Callback, - #[prop_or(false)] - pub required: bool, - #[prop_or_default] - pub step: Option, - #[prop_or_default] - pub min: Option, - #[prop_or(false)] - pub deactivated: bool, - #[prop_or_default] - pub help_text: String, -} - -#[derive(PartialEq, Properties)] -pub struct FormTextareaProps { - pub label: String, - pub value: Option, - #[prop_or_default] - pub oninput: Callback, - #[prop_or(false)] - pub required: bool, - #[prop_or(false)] - pub deactivated: bool, - #[prop_or_default] - pub help_text: String, -} - -// Variant of FormTextInput which supports tooltips, -// prepended static buttons, or both together. -// Also supports deactivating the input. -#[derive(PartialEq, Properties)] -pub struct FormTextInputExtendedProps { - pub label: String, - pub value: String, - #[prop_or_default] - pub tooltip: String, - #[prop_or_default] - pub statictext: String, - #[prop_or_default] - pub oninput: Callback, - #[prop_or_default] - pub onfocus: Callback, - #[prop_or_default] - pub onblur: Callback, - #[prop_or(false)] - pub required: bool, - #[prop_or(false)] - pub deactivated: bool, - #[prop_or_default] - pub help_text: String, -} - -#[derive(PartialEq, Properties)] -pub struct FormTextInputProps { - pub label: String, - pub value: Option, - #[prop_or_default] - pub oninput: Callback, - #[prop_or_default] - pub onblur: Callback, - #[prop_or(false)] - pub required: bool, - #[prop_or(false)] - pub deactivated: bool, - #[prop_or_default] - pub help_text: String, -} - -#[derive(PartialEq, Properties)] -pub struct FormUrlInputProps { - pub label: String, - pub value: Option, - #[prop_or_default] - pub oninput: Callback, - #[prop_or_default] - pub onblur: Callback, - #[prop_or(false)] - pub required: bool, - #[prop_or_default] - pub help_text: String, -} - -#[derive(PartialEq, Properties)] -pub struct FormDateInputProps { - pub label: String, - pub value: Option, - #[prop_or_default] - pub oninput: Callback, - #[prop_or_default] - pub onblur: Callback, - #[prop_or(false)] - pub required: bool, - #[prop_or(false)] - pub deactivated: bool, - #[prop_or_default] - pub help_text: String, -} - -#[derive(PartialEq, Properties)] -pub struct FormFloatInputProps { - pub label: String, - pub value: Option, - #[prop_or_default] - pub oninput: Callback, - #[prop_or_default] - pub onblur: Callback, - #[prop_or(false)] - pub required: bool, - #[prop_or_default] - pub step: Option, - #[prop_or("0".to_string())] - pub min: String, - #[prop_or(false)] - pub deactivated: bool, - #[prop_or_default] - pub help_text: String, -} - -#[derive(PartialEq, Properties)] -pub struct FormNumberInputProps { - pub label: String, - pub value: Option, - #[prop_or_default] - pub oninput: Callback, - #[prop_or_default] - pub onblur: Callback, - #[prop_or(false)] - pub required: bool, - #[prop_or("0".to_string())] - pub min: String, - #[prop_or(false)] - pub deactivated: bool, - #[prop_or_default] - pub help_text: String, -} - -#[derive(PartialEq, Properties)] -pub struct FormWorkTypeSelectProps { - pub label: String, - pub data: Vec, - // Subset of `data` list which should be deactivated, if any - #[prop_or_default] - pub deactivate: Vec, - pub value: WorkType, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormWorkStatusSelectProps { - pub label: String, - pub data: Vec, - pub value: WorkStatus, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormContributionTypeSelectProps { - pub label: String, - pub data: Vec, - pub value: ContributionType, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormPublicationTypeSelectProps { - pub label: String, - pub data: Vec, - pub value: PublicationType, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormSubjectTypeSelectProps { - pub label: String, - pub data: Vec, - pub value: SubjectType, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormSeriesTypeSelectProps { - pub label: String, - pub data: Vec, - pub value: SeriesType, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormLanguageCodeSelectProps { - pub label: String, - pub data: Vec, - pub value: LanguageCode, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormLanguageRelationSelectProps { - pub label: String, - pub data: Vec, - pub value: LanguageRelation, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormCurrencyCodeSelectProps { - pub label: String, - pub data: Vec, - pub value: CurrencyCode, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormLocationPlatformSelectProps { - pub label: String, - pub data: Vec, - pub value: LocationPlatform, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormCountryCodeSelectProps { - pub label: String, - pub data: Vec, - pub value: Option, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormRelationTypeSelectProps { - pub label: String, - pub data: Vec, - pub value: RelationType, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormBooleanSelectProps { - pub label: String, - pub value: bool, - pub onchange: Callback, - #[prop_or_default] - pub onblur: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormImprintSelectProps { - pub label: String, - pub data: Vec, - pub value: Option, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormPublisherSelectProps { - pub label: String, - pub data: Vec, - pub value: Option, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct FormContributorSelectProps { - pub label: String, - pub data: Vec, - pub value: Uuid, - pub onchange: Callback, - #[prop_or(false)] - pub required: bool, -} - -#[derive(PartialEq, Properties)] -pub struct ReloaderProps { - pub onclick: Callback, -} - -#[function_component(FormInput)] -pub fn form_input(props: &FormInputProps) -> VNode { - html! { -
- -
- -
- { - if !props.help_text.is_empty() { - html! { -

{ props.help_text.clone() }

- } - } else { - html! {} - } - } -
- } -} - -#[function_component(FormTextarea)] -pub fn form_textarea(props: &FormTextareaProps) -> VNode { - html! { -
- -
-