diff --git a/apps/frontend/src/pages/settings.vue b/apps/frontend/src/pages/settings.vue index 9a5f41c8b9..1237409561 100644 --- a/apps/frontend/src/pages/settings.vue +++ b/apps/frontend/src/pages/settings.vue @@ -35,6 +35,13 @@ icon: ShieldIcon, } : null, + auth.user?.email + ? { + link: '/settings/notifications', + label: formatMessage(commonSettingsMessages.notifications), + icon: BellIcon, + } + : null, auth.user ? { link: '/settings/authorizations', @@ -83,6 +90,7 @@ + + + + + diff --git a/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json b/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json deleted file mode 100644 index 921f7f92d9..0000000000 --- a/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "status: PayoutStatus", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false - ] - }, - "hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286" -} diff --git a/apps/labrinth/.sqlx/query-434f55e473f787e2b401195440e03fb132a2c6a37c6d040f51ccdd0feb44ee29.json b/apps/labrinth/.sqlx/query-434f55e473f787e2b401195440e03fb132a2c6a37c6d040f51ccdd0feb44ee29.json new file mode 100644 index 0000000000..1398949cb0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-434f55e473f787e2b401195440e03fb132a2c6a37c6d040f51ccdd0feb44ee29.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users_notifications_preferences (\n user_id, channel, notification_type, enabled\n )\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (COALESCE(user_id, -1), channel, notification_type)\n DO UPDATE SET enabled = $4\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "434f55e473f787e2b401195440e03fb132a2c6a37c6d040f51ccdd0feb44ee29" +} diff --git a/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json b/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json deleted file mode 100644 index 89bd8147dc..0000000000 --- a/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT status AS \"status: PayoutStatus\" FROM payouts WHERE id = 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "status: PayoutStatus", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false - ] - }, - "hash": "b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3" -} diff --git a/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json b/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json deleted file mode 100644 index 469c30168a..0000000000 --- a/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, $3, $4, $5, 10.0, NOW())\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Text", - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02" -} diff --git a/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json b/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json deleted file mode 100644 index 52e020ebf2..0000000000 --- a/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606" -} diff --git a/apps/labrinth/src/database/models/users_notifications_preferences_item.rs b/apps/labrinth/src/database/models/users_notifications_preferences_item.rs index 1ba5e1ce01..259f7ed782 100644 --- a/apps/labrinth/src/database/models/users_notifications_preferences_item.rs +++ b/apps/labrinth/src/database/models/users_notifications_preferences_item.rs @@ -108,4 +108,31 @@ impl UserNotificationPreference { Ok(()) } + + pub async fn upsert( + user_id: DBUserId, + channel: NotificationChannel, + notification_type: NotificationType, + enabled: bool, + exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO users_notifications_preferences ( + user_id, channel, notification_type, enabled + ) + VALUES ($1, $2, $3, $4) + ON CONFLICT (COALESCE(user_id, -1), channel, notification_type) + DO UPDATE SET enabled = $4 + ", + user_id.0, + channel.as_str(), + notification_type.as_str(), + enabled, + ) + .execute(exec) + .await?; + + Ok(()) + } } diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index e213bbf611..c4546958d4 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -10,6 +10,7 @@ pub mod gotenberg; pub mod medal; pub mod moderation; pub mod mural; +pub mod notification_preferences; pub mod pats; pub mod search; pub mod session; @@ -34,6 +35,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .configure(statuses::config) .configure(medal::config) .configure(external_notifications::config) + .configure(notification_preferences::config) .configure(mural::config) .configure(delphi::config), ); diff --git a/apps/labrinth/src/routes/internal/notification_preferences.rs b/apps/labrinth/src/routes/internal/notification_preferences.rs new file mode 100644 index 0000000000..d642fbf170 --- /dev/null +++ b/apps/labrinth/src/routes/internal/notification_preferences.rs @@ -0,0 +1,145 @@ +use crate::auth::get_user_from_headers; +use crate::database::models::notifications_type_item::NotificationTypeItem; +use crate::database::models::users_notifications_preferences_item::UserNotificationPreference; +use crate::database::redis::RedisPool; +use crate::database::PgPool; +use crate::models::pats::Scopes; +use crate::models::v3::notifications::{NotificationChannel, NotificationType}; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use actix_web::web::{self, Data}; +use actix_web::{HttpRequest, HttpResponse, get, patch}; +use serde::{Deserialize, Serialize}; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get_email_preferences); + cfg.service(set_email_preferences); +} + +#[derive(Serialize)] +pub struct NotificationPreferenceEntry { + pub notification_type: NotificationType, + pub channel: NotificationChannel, + pub enabled: bool, +} + +#[derive(Serialize)] +pub struct EmailPreferencesResponse { + pub notification_types: Vec, + pub preferences: Vec, +} + +#[get("email_preferences")] +pub async fn get_email_preferences( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SESSION_ACCESS, + ) + .await? + .1; + + let notification_types = + NotificationTypeItem::list(&**pool, &redis).await?; + + let exposed_types: Vec = notification_types + .iter() + .filter(|t| t.expose_in_user_preferences) + .map(|t| t.name) + .collect(); + + let user_prefs = UserNotificationPreference::get_user_or_default( + user.id.into(), + &**pool, + ) + .await?; + + let preferences: Vec = user_prefs + .into_iter() + .filter(|p| { + exposed_types.contains(&p.notification_type) + }) + .map(|p| NotificationPreferenceEntry { + notification_type: p.notification_type, + channel: p.channel, + enabled: p.enabled, + }) + .collect(); + + Ok(HttpResponse::Ok().json(EmailPreferencesResponse { + notification_types: exposed_types, + preferences, + })) +} + +#[derive(Deserialize)] +pub struct UpdatePreferenceEntry { + pub notification_type: NotificationType, + pub channel: NotificationChannel, + pub enabled: bool, +} + +#[derive(Deserialize)] +pub struct UpdateEmailPreferencesRequest { + pub preferences: Vec, +} + +#[patch("email_preferences")] +pub async fn set_email_preferences( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, + body: web::Json, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SESSION_ACCESS, + ) + .await? + .1; + + let notification_types = + NotificationTypeItem::list(&**pool, &redis).await?; + + let exposed_types: Vec = notification_types + .iter() + .filter(|t| t.expose_in_user_preferences) + .map(|t| t.name) + .collect(); + + let mut transaction = pool.begin().await?; + + for pref in &body.preferences { + if !exposed_types.contains(&pref.notification_type) { + return Err(ApiError::InvalidInput(format!( + "Notification type '{}' is not configurable", + pref.notification_type.as_str() + ))); + } + + UserNotificationPreference::upsert( + user.id.into(), + pref.channel, + pref.notification_type, + pref.enabled, + &mut transaction, + ) + .await?; + } + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().finish()) +} + diff --git a/packages/ui/src/utils/common-messages.ts b/packages/ui/src/utils/common-messages.ts index e2c1db7914..9218b90f86 100644 --- a/packages/ui/src/utils/common-messages.ts +++ b/packages/ui/src/utils/common-messages.ts @@ -646,6 +646,10 @@ export const commonSettingsMessages = defineMessages({ id: 'settings.language.title', defaultMessage: 'Language', }, + notifications: { + id: 'settings.notifications.title', + defaultMessage: 'Email notifications', + }, pats: { id: 'settings.pats.title', defaultMessage: 'Personal access tokens',