diff --git a/backend/api/src/api_doc.rs b/backend/api/src/api_doc.rs index 842065e8..9e9395f7 100644 --- a/backend/api/src/api_doc.rs +++ b/backend/api/src/api_doc.rs @@ -9,11 +9,11 @@ use utoipa::{ Modify, OpenApi, }; -use crate::handlers::search::search_title_group_tags_lite::SearchTitleGroupTagsLiteQuery; -use crate::handlers::{ - search::search_torrent_requests::SearchTorrentRequestsQuery, - user_applications::get_user_applications::GetUserApplicationsQuery, +use crate::handlers::search::{ + search_title_group_tags_lite::SearchTitleGroupTagsLiteQuery, + search_torrent_requests::SearchTorrentRequestsQuery, }; +use crate::handlers::user_applications::get_user_applications::GetUserApplicationsQuery; #[derive(OpenApi)] #[openapi( @@ -52,6 +52,8 @@ use crate::handlers::{ crate::handlers::master_groups::create_master_group::exec, crate::handlers::series::create_series::exec, crate::handlers::series::get_series::exec, + crate::handlers::series::edit_series::exec, + crate::handlers::series::add_title_group::exec, crate::handlers::subscriptions::create_subscription_forum_thread_posts::exec, crate::handlers::subscriptions::remove_subscription_forum_thread_posts::exec, crate::handlers::subscriptions::create_subscription_title_group_torrents::exec, @@ -76,6 +78,7 @@ use crate::handlers::{ crate::handlers::search::search_collages::exec, crate::handlers::search::search_collages_lite::exec, crate::handlers::search::search_series::exec, + crate::handlers::search::search_series_lite::exec, crate::handlers::search::search_forum::exec, crate::handlers::torrent_requests::create_torrent_request::exec, crate::handlers::torrent_requests::get_torrent_request::exec, diff --git a/backend/api/src/handlers/search/mod.rs b/backend/api/src/handlers/search/mod.rs index b190c63e..b059b1fb 100644 --- a/backend/api/src/handlers/search/mod.rs +++ b/backend/api/src/handlers/search/mod.rs @@ -3,6 +3,7 @@ pub mod search_collages; pub mod search_collages_lite; pub mod search_forum; pub mod search_series; +pub mod search_series_lite; pub mod search_title_group_info_lite; pub mod search_title_group_tags; pub mod search_title_group_tags_lite; @@ -33,5 +34,6 @@ pub fn config(cfg: &mut ServiceConfig) { cfg.service(resource("/collages").route(get().to(self::search_collages::exec::))); cfg.service(resource("/collages/lite").route(get().to(self::search_collages_lite::exec::))); cfg.service(resource("/series").route(get().to(self::search_series::exec::))); + cfg.service(resource("/series/lite").route(get().to(self::search_series_lite::exec::))); cfg.service(resource("/forum").route(get().to(self::search_forum::exec::))); } diff --git a/backend/api/src/handlers/search/search_series_lite.rs b/backend/api/src/handlers/search/search_series_lite.rs new file mode 100644 index 00000000..4c4fcc55 --- /dev/null +++ b/backend/api/src/handlers/search/search_series_lite.rs @@ -0,0 +1,34 @@ +use crate::Arcadia; +use actix_web::{ + web::{Data, Query}, + HttpResponse, +}; +use arcadia_common::error::Result; +use arcadia_storage::{models::series::SeriesLite, redis::RedisPoolInterface}; +use serde::Deserialize; +use utoipa::{IntoParams, ToSchema}; + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct SearchSeriesLiteQuery { + pub name: String, +} + +#[utoipa::path( + get, + operation_id = "Search series lite", + tag = "Search", + path = "/api/search/series/lite", + params (SearchSeriesLiteQuery), + description = "Case insensitive", + responses( + (status = 200, description = "Successfully got the series (lite)", body=Vec), + ) +)] +pub async fn exec( + query: Query, + arc: Data>, +) -> Result { + let series = arc.pool.search_series_lite(&query.name, 7).await?; + + Ok(HttpResponse::Ok().json(series)) +} diff --git a/backend/api/src/handlers/series/add_title_group.rs b/backend/api/src/handlers/series/add_title_group.rs new file mode 100644 index 00000000..95fddc31 --- /dev/null +++ b/backend/api/src/handlers/series/add_title_group.rs @@ -0,0 +1,38 @@ +use crate::Arcadia; +use actix_web::{ + web::{Data, Json}, + HttpResponse, +}; +use arcadia_common::error::Result; +use arcadia_storage::{models::title_group::TitleGroup, redis::RedisPoolInterface}; +use serde::Deserialize; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct AddTitleGroupToSeriesRequest { + pub series_id: i64, + pub title_group_id: i32, +} + +#[utoipa::path( + post, + operation_id = "Add title group to series", + tag = "Series", + path = "/api/series/title-group", + security( + ("http" = ["Bearer"]) + ), + responses( + (status = 200, description = "Successfully attached the title group to the series", body=TitleGroup), + ) +)] +pub async fn exec( + form: Json, + arc: Data>, +) -> Result { + arc.pool + .assign_title_group_to_series(form.title_group_id, form.series_id) + .await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({"result": "success"}))) +} diff --git a/backend/api/src/handlers/series/edit_series.rs b/backend/api/src/handlers/series/edit_series.rs new file mode 100644 index 00000000..56f50e29 --- /dev/null +++ b/backend/api/src/handlers/series/edit_series.rs @@ -0,0 +1,41 @@ +use crate::{middlewares::auth_middleware::Authdata, Arcadia}; +use actix_web::{ + web::{Data, Json}, + HttpResponse, +}; +use arcadia_common::error::{Error, Result}; +use arcadia_storage::{ + models::{ + series::{EditedSeries, Series}, + user::UserClass, + }, + redis::RedisPoolInterface, +}; + +#[utoipa::path( + put, + operation_id = "Edit series", + tag = "Series", + path = "/api/series", + security( + ("http" = ["Bearer"]) + ), + responses( + (status = 200, description = "Successfully edited the series", body=Series), + ) +)] +pub async fn exec( + form: Json, + arc: Data>, + user: Authdata, +) -> Result { + let series = arc.pool.find_series(&form.id).await?; + + if user.class != UserClass::Staff && series.created_by_id != user.sub { + return Err(Error::InsufficientPrivileges); + } + + let updated_series = arc.pool.update_series(&form).await?; + + Ok(HttpResponse::Ok().json(updated_series)) +} diff --git a/backend/api/src/handlers/series/mod.rs b/backend/api/src/handlers/series/mod.rs index 5fb38228..2852ed06 100644 --- a/backend/api/src/handlers/series/mod.rs +++ b/backend/api/src/handlers/series/mod.rs @@ -1,13 +1,17 @@ +pub mod add_title_group; pub mod create_series; +pub mod edit_series; pub mod get_series; -use actix_web::web::{get, post, resource, ServiceConfig}; +use actix_web::web::{get, post, put, resource, ServiceConfig}; use arcadia_storage::redis::RedisPoolInterface; pub fn config(cfg: &mut ServiceConfig) { cfg.service( resource("") .route(post().to(self::create_series::exec::)) - .route(get().to(self::get_series::exec::)), + .route(get().to(self::get_series::exec::)) + .route(put().to(self::edit_series::exec::)), ); + cfg.service(resource("/title-group").route(post().to(self::add_title_group::exec::))); } diff --git a/backend/api/tests/fixtures/with_test_series.sql b/backend/api/tests/fixtures/with_test_series.sql new file mode 100644 index 00000000..8cefb5b9 --- /dev/null +++ b/backend/api/tests/fixtures/with_test_series.sql @@ -0,0 +1,15 @@ +INSERT INTO + series (id, name, description, tags, covers, banners, created_by_id, created_at, updated_at) +VALUES + ( + 1, + 'Test Series', + 'A series used for testing', + '{test,series}', + '{https://example.com/cover.jpg}', + '{https://example.com/banner.jpg}', + 1, + NOW(), + NOW() + ); + diff --git a/backend/api/tests/test_series.rs b/backend/api/tests/test_series.rs new file mode 100644 index 00000000..e5b9f430 --- /dev/null +++ b/backend/api/tests/test_series.rs @@ -0,0 +1,88 @@ +pub mod common; +pub mod mocks; + +use std::sync::Arc; + +use actix_web::{ + http::StatusCode, + test::{self, call_service}, +}; +use arcadia_storage::{ + connection_pool::ConnectionPool, + models::{ + series::{EditedSeries, Series}, + title_group::TitleGroupAndAssociatedData, + }, +}; +use mocks::mock_redis::MockRedisPool; +use sqlx::PgPool; + +use crate::common::{ + auth_header, call_and_read_body_json_with_status, create_test_app_and_login, TestUser, +}; + +#[sqlx::test( + fixtures("with_test_user2", "with_test_series"), + migrations = "../storage/migrations" +)] +async fn test_edit_series(pool: PgPool) { + let pool = Arc::new(ConnectionPool::with_pg_pool(pool)); + let (service, user) = + create_test_app_and_login(pool, MockRedisPool::default(), 100, 100, TestUser::Staff).await; + + let payload = EditedSeries { + id: 1, + name: "Updated Series".to_string(), + description: "Updated description".to_string(), + covers: vec!["https://example.com/updated-cover.jpg".to_string()], + banners: vec!["https://example.com/updated-banner.jpg".to_string()], + tags: vec!["updated".to_string()], + }; + + let req = test::TestRequest::put() + .uri("/api/series") + .insert_header(auth_header(&user.token)) + .set_json(&payload) + .to_request(); + + let series: Series = call_and_read_body_json_with_status(&service, req, StatusCode::OK).await; + + assert_eq!(series.name, payload.name); + assert_eq!(series.description, payload.description); + assert_eq!(series.covers, payload.covers); + assert_eq!(series.banners, Some(payload.banners)); + assert_eq!(series.tags, payload.tags); +} + +#[sqlx::test( + fixtures("with_test_user", "with_test_series", "with_test_title_group"), + migrations = "../storage/migrations" +)] +async fn test_add_title_group_to_series(pool: PgPool) { + let pool = Arc::new(ConnectionPool::with_pg_pool(pool)); + let (service, user) = + create_test_app_and_login(pool, MockRedisPool::default(), 100, 100, TestUser::Standard) + .await; + + let req = test::TestRequest::post() + .uri("/api/series/title-group") + .insert_header(auth_header(&user.token)) + .set_json(serde_json::json!({ + "series_id": 1, + "title_group_id": 1 + })) + .to_request(); + + let _ = call_service(&service, req).await; + + let req = test::TestRequest::get() + .uri("/api/title-groups?id=1") + .insert_header(auth_header(&user.token)) + .to_request(); + + let title_group: TitleGroupAndAssociatedData = + call_and_read_body_json_with_status(&service, req, StatusCode::OK).await; + + assert_eq!(title_group.title_group.id, 1); + assert_eq!(title_group.title_group.series_id, Some(1)); +} diff --git a/backend/common/src/error/mod.rs b/backend/common/src/error/mod.rs index ef97927a..60fd3a4c 100644 --- a/backend/common/src/error/mod.rs +++ b/backend/common/src/error/mod.rs @@ -108,6 +108,9 @@ pub enum Error { #[error("could not create series")] CouldNotCreateSeries(#[source] sqlx::Error), + #[error("could not update series")] + CouldNotUpdateSeries(#[source] sqlx::Error), + #[error("could not create api key")] CouldNotCreateAPIKey(#[source] sqlx::Error), diff --git a/backend/storage/.sqlx/query-61a6c11b6da252a26f963509c57cb726d29394f0e77d9d3e65f46b0284c96d78.json b/backend/storage/.sqlx/query-61a6c11b6da252a26f963509c57cb726d29394f0e77d9d3e65f46b0284c96d78.json new file mode 100644 index 00000000..c06df51f --- /dev/null +++ b/backend/storage/.sqlx/query-61a6c11b6da252a26f963509c57cb726d29394f0e77d9d3e65f46b0284c96d78.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE series\n SET\n name = $2,\n description = $3,\n covers = $4,\n banners = $5,\n tags = $6,\n updated_at = NOW()\n WHERE id = $1\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "tags", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "covers", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "banners", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "created_by_id", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Text", + "TextArray", + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "61a6c11b6da252a26f963509c57cb726d29394f0e77d9d3e65f46b0284c96d78" +} diff --git a/backend/storage/.sqlx/query-a47fbecaf179e762203deb5497de7d15ad7caaf68a0269a57b37dafba36121c3.json b/backend/storage/.sqlx/query-a47fbecaf179e762203deb5497de7d15ad7caaf68a0269a57b37dafba36121c3.json new file mode 100644 index 00000000..0262c718 --- /dev/null +++ b/backend/storage/.sqlx/query-a47fbecaf179e762203deb5497de7d15ad7caaf68a0269a57b37dafba36121c3.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n s.id,\n s.name\n FROM series s\n WHERE (s.name ILIKE '%' || $1 || '%')\n LIMIT $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "a47fbecaf179e762203deb5497de7d15ad7caaf68a0269a57b37dafba36121c3" +} diff --git a/backend/storage/.sqlx/query-b70d718632b169f7a13e6c2a34e8aebe703b7b3b3e1d033807228c1f0d3e73de.json b/backend/storage/.sqlx/query-b70d718632b169f7a13e6c2a34e8aebe703b7b3b3e1d033807228c1f0d3e73de.json new file mode 100644 index 00000000..9384e5aa --- /dev/null +++ b/backend/storage/.sqlx/query-b70d718632b169f7a13e6c2a34e8aebe703b7b3b3e1d033807228c1f0d3e73de.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE title_groups\n SET series_id = $2, updated_at = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "b70d718632b169f7a13e6c2a34e8aebe703b7b3b3e1d033807228c1f0d3e73de" +} diff --git a/backend/storage/src/models/series.rs b/backend/storage/src/models/series.rs index 63e6a57f..4c4a8b71 100644 --- a/backend/storage/src/models/series.rs +++ b/backend/storage/src/models/series.rs @@ -29,6 +29,16 @@ pub struct UserCreatedSeries { pub tags: Vec, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct EditedSeries { + pub id: i64, + pub name: String, + pub description: String, + pub covers: Vec, + pub banners: Vec, + pub tags: Vec, +} + #[derive(Debug, Deserialize, Serialize, ToSchema)] pub struct SeriesAndTitleGroupHierarchyLite { pub series: Series, diff --git a/backend/storage/src/repositories/series_repository.rs b/backend/storage/src/repositories/series_repository.rs index c4a0ba75..1da91b5d 100644 --- a/backend/storage/src/repositories/series_repository.rs +++ b/backend/storage/src/repositories/series_repository.rs @@ -1,7 +1,8 @@ use crate::{ connection_pool::ConnectionPool, models::series::{ - SearchSeriesQuery, Series, SeriesSearchResponse, SeriesSearchResult, UserCreatedSeries, + EditedSeries, SearchSeriesQuery, Series, SeriesLite, SeriesSearchResponse, + SeriesSearchResult, UserCreatedSeries, }, }; use arcadia_common::error::{Error, Result}; @@ -99,4 +100,57 @@ impl ConnectionPool { total_items, }) } + + pub async fn search_series_lite( + &self, + name: &str, + results_amount: u8, + ) -> Result> { + let results = sqlx::query_as!( + SeriesLite, + r#" + SELECT + s.id, + s.name + FROM series s + WHERE (s.name ILIKE '%' || $1 || '%') + LIMIT $2 + "#, + name, + results_amount as i64 + ) + .fetch_all(self.borrow()) + .await?; + + Ok(results) + } + + pub async fn update_series(&self, edited_series: &EditedSeries) -> Result { + let series = sqlx::query_as!( + Series, + r#" + UPDATE series + SET + name = $2, + description = $3, + covers = $4, + banners = $5, + tags = $6, + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + edited_series.id, + edited_series.name, + edited_series.description, + &edited_series.covers, + &edited_series.banners, + &edited_series.tags + ) + .fetch_one(self.borrow()) + .await + .map_err(Error::CouldNotUpdateSeries)?; + + Ok(series) + } } diff --git a/backend/storage/src/repositories/title_group_repository.rs b/backend/storage/src/repositories/title_group_repository.rs index 80d2e004..76aa0103 100644 --- a/backend/storage/src/repositories/title_group_repository.rs +++ b/backend/storage/src/repositories/title_group_repository.rs @@ -488,6 +488,27 @@ impl ConnectionPool { Ok(updated_title_group) } + pub async fn assign_title_group_to_series( + &self, + title_group_id: i32, + series_id: i64, + ) -> Result<()> { + let _ = sqlx::query!( + r#" + UPDATE title_groups + SET series_id = $2, updated_at = NOW() + WHERE id = $1 + "#, + title_group_id, + series_id + ) + .fetch_one(self.borrow()) + .await + .map_err(|e| Error::ErrorWhileUpdatingTitleGroup(e.to_string()))?; + + Ok(()) + } + pub async fn does_title_group_with_link_exist( &self, external_link: &str, diff --git a/docs/src/architecture.md b/docs/src/architecture.md index 64ab35e0..39140c83 100644 --- a/docs/src/architecture.md +++ b/docs/src/architecture.md @@ -23,5 +23,5 @@ Arcadia's frontend is a [SPA](https://developer.mozilla.org/en-US/docs/Glossary/ If you make changes to structs that are listed in the swagger or the api routes, you must regenerate the typescript interfaces with this command (from the frontend directory, while the backend is running): ```bash -npx openapi-generator-cli generate -g typescript-axios -i http://127.0.0.1:8080/swagger-json/openapi.json -o ./src/services/api-schema -t .openapi-generator/templates --config .openapi-generator/openapi-generator.config.json --global-property=models,apiDocs=false,modelDocs=false,skipFormModel=false +npx openapi-generator-cli generate -g typescript-axios -i http://127.0.0.1:8080/swagger-json/openapi.json -o ./src/services/api-schema -t .openapi-generator/templates --config .openapi-generator/openapi-generator.config.json --global-property=apiDocs=false,modelDocs=false,skipFormModel=false ``` diff --git a/frontend/src/components/SearchBars.vue b/frontend/src/components/SearchBars.vue index 72c49fd1..4d837e14 100644 --- a/frontend/src/components/SearchBars.vue +++ b/frontend/src/components/SearchBars.vue @@ -12,8 +12,8 @@ } " /> - - + + @@ -22,10 +22,10 @@