diff --git a/Cargo.lock b/Cargo.lock index 359b4ffe..45e5e009 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -591,6 +591,7 @@ dependencies = [ "arcadia-shared", "argon2", "bip_metainfo", + "cargo-husky", "chrono 0.4.41", "deadpool", "deadpool-redis", @@ -825,6 +826,12 @@ dependencies = [ "bytes", ] +[[package]] +name = "cargo-husky" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b02b629252fe8ef6460461409564e2c21d0c8e77e0944f3d189ff06c4e932ad" + [[package]] name = "cc" version = "1.2.34" diff --git a/backend/api/src/api_doc.rs b/backend/api/src/api_doc.rs index 9d80c9ce..98133bb4 100644 --- a/backend/api/src/api_doc.rs +++ b/backend/api/src/api_doc.rs @@ -92,6 +92,7 @@ use crate::handlers::user_applications::get_user_applications::GetUserApplicatio crate::handlers::forum::get_forum_thread::exec, crate::handlers::forum::get_forum_thread_posts::exec, crate::handlers::forum::create_forum_thread::exec, + crate::handlers::forum::edit_forum_thread::exec, crate::handlers::forum::create_forum_post::exec, crate::handlers::forum::edit_forum_post::exec, crate::handlers::wiki::create_wiki_article::exec, diff --git a/backend/api/src/handlers/forum/edit_forum_thread.rs b/backend/api/src/handlers/forum/edit_forum_thread.rs new file mode 100644 index 00000000..f59bd5ed --- /dev/null +++ b/backend/api/src/handlers/forum/edit_forum_thread.rs @@ -0,0 +1,44 @@ +use crate::{middlewares::auth_middleware::Authdata, Arcadia}; +use actix_web::{ + web::{Data, Json}, + HttpResponse, +}; +use arcadia_common::error::{Error, Result}; +use arcadia_storage::{ + models::{ + forum::{EditedForumThread, ForumThreadEnriched}, + user::UserClass, + }, + redis::RedisPoolInterface, +}; + +#[utoipa::path( + put, + operation_id = "Edit forum thread", + tag = "Forum", + path = "/api/forum/thread", + responses( + (status = 200, description = "Edits the thread's information", body=ForumThreadEnriched) + ) +)] +pub async fn exec( + arc: Data>, + user: Authdata, + edited_forum_thread: Json, +) -> Result { + let original_thread = arc + .pool + .find_forum_thread(edited_forum_thread.id, user.sub) + .await?; + + if user.class != UserClass::Staff && original_thread.created_by_id != user.sub { + return Err(Error::InsufficientPrivileges); + } + + let updated_thread = arc + .pool + .update_forum_thread(&edited_forum_thread, user.sub) + .await?; + + Ok(HttpResponse::Ok().json(updated_thread)) +} diff --git a/backend/api/src/handlers/forum/mod.rs b/backend/api/src/handlers/forum/mod.rs index 0f9c8f73..caf33f29 100644 --- a/backend/api/src/handlers/forum/mod.rs +++ b/backend/api/src/handlers/forum/mod.rs @@ -1,6 +1,7 @@ pub mod create_forum_post; pub mod create_forum_thread; pub mod edit_forum_post; +pub mod edit_forum_thread; pub mod get_forum; pub mod get_forum_sub_category_threads; pub mod get_forum_thread; @@ -14,6 +15,7 @@ pub fn config(cfg: &mut ServiceConfig) { cfg.service( resource("/thread") .route(get().to(self::get_forum_thread::exec::)) + .route(put().to(self::edit_forum_thread::exec::)) .route(post().to(self::create_forum_thread::exec::)), ); cfg.service(resource("/thread/posts").route(get().to(self::get_forum_thread_posts::exec::))); diff --git a/backend/common/src/error/mod.rs b/backend/common/src/error/mod.rs index 9c0e1f32..58808b36 100644 --- a/backend/common/src/error/mod.rs +++ b/backend/common/src/error/mod.rs @@ -228,6 +228,9 @@ pub enum Error { #[error("could not update forum post")] CouldNotUpdateForumPost(#[source] sqlx::Error), + #[error("could not update forum thread")] + CouldNotUpdateForumThread(#[source] sqlx::Error), + #[error("could not find forum post")] CouldNotFindForumPost(#[source] sqlx::Error), diff --git a/backend/storage/.sqlx/query-16054d9e2d7ec808fb594591b889eeaa0cc582f99864c27e9963d6236ec94c63.json b/backend/storage/.sqlx/query-16054d9e2d7ec808fb594591b889eeaa0cc582f99864c27e9963d6236ec94c63.json new file mode 100644 index 00000000..1fae610d --- /dev/null +++ b/backend/storage/.sqlx/query-16054d9e2d7ec808fb594591b889eeaa0cc582f99864c27e9963d6236ec94c63.json @@ -0,0 +1,93 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH updated_row AS (\n UPDATE forum_threads\n SET name = $1, sticky = $2, locked = $3, forum_sub_category_id = $4\n WHERE id = $5\n RETURNING *\n )\n SELECT\n ur.id,\n ur.forum_sub_category_id,\n ur.name,\n ur.created_at,\n ur.created_by_id,\n ur.posts_amount,\n ur.sticky,\n ur.locked,\n fsc.name AS forum_sub_category_name,\n fc.name AS forum_category_name,\n fc.id AS forum_category_id,\n (sft.id IS NOT NULL) AS \"is_subscribed!\"\n FROM updated_row ur\n JOIN\n forum_sub_categories AS fsc ON ur.forum_sub_category_id = fsc.id\n JOIN\n forum_categories AS fc ON fsc.forum_category_id = fc.id\n LEFT JOIN\n subscriptions_forum_thread_posts AS sft\n ON sft.forum_thread_id = ur.id AND sft.user_id = $6\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "forum_sub_category_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "created_by_id", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "posts_amount", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "sticky", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "locked", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "forum_sub_category_name", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "forum_category_name", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "forum_category_id", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "is_subscribed!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Bool", + "Bool", + "Int4", + "Int8", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + null + ] + }, + "hash": "16054d9e2d7ec808fb594591b889eeaa0cc582f99864c27e9963d6236ec94c63" +} diff --git a/backend/storage/.sqlx/query-75ee76d1d9f14fcfff97dde35a9778bbd425016b3fb6ff878c21cc65d14ac6cb.json b/backend/storage/.sqlx/query-75ee76d1d9f14fcfff97dde35a9778bbd425016b3fb6ff878c21cc65d14ac6cb.json deleted file mode 100644 index 5d710b0c..00000000 --- a/backend/storage/.sqlx/query-75ee76d1d9f14fcfff97dde35a9778bbd425016b3fb6ff878c21cc65d14ac6cb.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n fp.id,\n fp.content,\n fp.created_at,\n fp.updated_at,\n fp.sticky,\n fp.forum_thread_id,\n u.id AS created_by_user_id,\n u.username AS created_by_user_username,\n u.avatar AS created_by_user_avatar,\n u.banned AS created_by_user_banned,\n u.warned AS created_by_user_warned\n FROM forum_posts fp\n JOIN users u ON fp.created_by_id = u.id\n WHERE fp.forum_thread_id = $1\n ORDER BY fp.created_at ASC\n OFFSET $2\n LIMIT $3\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "content", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "sticky", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "forum_thread_id", - "type_info": "Int8" - }, - { - "ordinal": 6, - "name": "created_by_user_id", - "type_info": "Int4" - }, - { - "ordinal": 7, - "name": "created_by_user_username", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "created_by_user_avatar", - "type_info": "Text" - }, - { - "ordinal": 9, - "name": "created_by_user_banned", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "created_by_user_warned", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - false - ] - }, - "hash": "75ee76d1d9f14fcfff97dde35a9778bbd425016b3fb6ff878c21cc65d14ac6cb" -} diff --git a/backend/storage/src/models/forum.rs b/backend/storage/src/models/forum.rs index bfa86297..6089813b 100644 --- a/backend/storage/src/models/forum.rs +++ b/backend/storage/src/models/forum.rs @@ -47,6 +47,15 @@ pub struct UserCreatedForumThread { pub first_post: UserCreatedForumPost, } +#[derive(Debug, Deserialize, Serialize, FromRow, ToSchema)] +pub struct EditedForumThread { + pub id: i64, + pub forum_sub_category_id: i32, + pub name: String, + pub sticky: bool, + pub locked: bool, +} + #[derive(Debug, Deserialize, Serialize, FromRow, ToSchema)] pub struct ForumPost { pub id: i64, diff --git a/backend/storage/src/repositories/forum_repository.rs b/backend/storage/src/repositories/forum_repository.rs index 410fb935..2ad1dac7 100644 --- a/backend/storage/src/repositories/forum_repository.rs +++ b/backend/storage/src/repositories/forum_repository.rs @@ -3,9 +3,10 @@ use crate::{ models::{ common::PaginatedResults, forum::{ - EditedForumPost, ForumPost, ForumPostAndThreadName, ForumPostHierarchy, - ForumSearchQuery, ForumSearchResult, ForumThread, ForumThreadEnriched, - GetForumThreadPostsQuery, UserCreatedForumPost, UserCreatedForumThread, + EditedForumPost, EditedForumThread, ForumPost, ForumPostAndThreadName, + ForumPostHierarchy, ForumSearchQuery, ForumSearchResult, ForumThread, + ForumThreadEnriched, GetForumThreadPostsQuery, UserCreatedForumPost, + UserCreatedForumThread, }, user::UserLiteAvatar, }, @@ -161,6 +162,56 @@ impl ConnectionPool { Ok(created_forum_thread) } + pub async fn update_forum_thread( + &self, + edited_thread: &EditedForumThread, + user_id: i32, + ) -> Result { + let updated_thread = sqlx::query_as!( + ForumThreadEnriched, + r#" + WITH updated_row AS ( + UPDATE forum_threads + SET name = $1, sticky = $2, locked = $3, forum_sub_category_id = $4 + WHERE id = $5 + RETURNING * + ) + SELECT + ur.id, + ur.forum_sub_category_id, + ur.name, + ur.created_at, + ur.created_by_id, + ur.posts_amount, + ur.sticky, + ur.locked, + fsc.name AS forum_sub_category_name, + fc.name AS forum_category_name, + fc.id AS forum_category_id, + (sft.id IS NOT NULL) AS "is_subscribed!" + FROM updated_row ur + JOIN + forum_sub_categories AS fsc ON ur.forum_sub_category_id = fsc.id + JOIN + forum_categories AS fc ON fsc.forum_category_id = fc.id + LEFT JOIN + subscriptions_forum_thread_posts AS sft + ON sft.forum_thread_id = ur.id AND sft.user_id = $6 + "#, + edited_thread.name, + edited_thread.sticky, + edited_thread.locked, + edited_thread.forum_sub_category_id, + edited_thread.id, + user_id + ) + .fetch_one(self.borrow()) + .await + .map_err(Error::CouldNotUpdateForumThread)?; + + Ok(updated_thread) + } + pub async fn find_forum_cateogries_hierarchy(&self) -> Result { let forum_overview = sqlx::query!( r#" diff --git a/frontend/.gitignore b/frontend/.gitignore index 592b69fd..aaab4b9d 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -39,6 +39,7 @@ vite.config.js vitest.config.js .env +CLAUDE.md # custom frontpage public/home/* diff --git a/frontend/src/components/forum/EditForumThreadDialog.vue b/frontend/src/components/forum/EditForumThreadDialog.vue new file mode 100644 index 00000000..bb94f499 --- /dev/null +++ b/frontend/src/components/forum/EditForumThreadDialog.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index f7bd128a..4322892a 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -65,7 +65,9 @@ "new": "New", "uses": "Uses", "action": "Action | Actions", - "default": "Default" + "default": "Default", + "locked": "Locked", + "sticky": "Sticky" }, "auth": { "remember_me": "Remember me" @@ -121,7 +123,9 @@ "category": "Category", "subcategory": "Subcategory", "search": "Search forum", - "post_edited_success": "Post edited successfully" + "post_edited_success": "Post edited successfully", + "edit_thread": "Edit thread", + "thread_edited_success": "Thread edited successfully" }, "user": { "username": "Username", diff --git a/frontend/src/services/api-schema/api.ts b/frontend/src/services/api-schema/api.ts index 363486b6..be87c798 100644 --- a/frontend/src/services/api-schema/api.ts +++ b/frontend/src/services/api-schema/api.ts @@ -342,6 +342,13 @@ export interface EditedForumPost { 'locked': boolean; 'sticky': boolean; } +export interface EditedForumThread { + 'forum_sub_category_id': number; + 'id': number; + 'locked': boolean; + 'name': string; + 'sticky': boolean; +} export interface EditedSeries { 'banners': Array; 'covers': Array; @@ -4097,6 +4104,41 @@ export const ForumApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {EditedForumThread} editedForumThread + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + editForumThread: async (editedForumThread: EditedForumThread, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'editedForumThread' is not null or undefined + assertParamExists('editForumThread', 'editedForumThread', editedForumThread) + const localVarPath = `/api/forum/thread`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(editedForumThread, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {*} [options] Override http request option. @@ -4296,6 +4338,18 @@ export const ForumApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['ForumApi.editForumPost']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * + * @param {EditedForumThread} editedForumThread + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async editForumThread(editedForumThread: EditedForumThread, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.editForumThread(editedForumThread, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ForumApi.editForumThread']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * * @param {*} [options] Override http request option. @@ -4382,6 +4436,15 @@ export const ForumApiFactory = function (configuration?: Configuration, basePath editForumPost(editedForumPost: EditedForumPost, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.editForumPost(editedForumPost, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {EditedForumThread} editedForumThread + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + editForumThread(editedForumThread: EditedForumThread, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.editForumThread(editedForumThread, options).then((request) => request(axios, basePath)); + }, /** * * @param {*} [options] Override http request option. @@ -4457,6 +4520,16 @@ export class ForumApi extends BaseAPI { return ForumApiFp(this.configuration).editForumPost(editedForumPost, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {EditedForumThread} editedForumThread + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public editForumThread(editedForumThread: EditedForumThread, options?: RawAxiosRequestConfig) { + return ForumApiFp(this.configuration).editForumThread(editedForumThread, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {*} [options] Override http request option. @@ -4523,6 +4596,12 @@ export const editForumPost = async (editedForumPost: EditedForumPost, options?: }; +export const editForumThread = async (editedForumThread: EditedForumThread, options?: RawAxiosRequestConfig): Promise => { + const response = await forumApi.editForumThread(editedForumThread, options); + return response.data; +}; + + export const getForum = async (options?: RawAxiosRequestConfig): Promise => { const response = await forumApi.getForum(options); return response.data; diff --git a/frontend/src/views/forum/ForumThreadView.vue b/frontend/src/views/forum/ForumThreadView.vue index 68e8a111..0b46eba7 100644 --- a/frontend/src/views/forum/ForumThreadView.vue +++ b/frontend/src/views/forum/ForumThreadView.vue @@ -7,6 +7,12 @@ {{ forumThread.name }}
+
+ + +