Repository: IgnisDa/ryot (language: rust)

Issue:
[Feature Request] add webhook to send notification whenever a user finishes a workout
For instance, it would be nice to be able to send a message to a discord channel whenever a user in a group finishes a workout. In the message, there should be a (optional) preview of the workout so other people can see what the person did. In addition, it would also be very nice to be able to attach a selfie or some image from the phone.


Discussion / hints:
With ryots current architecture, All user notifications are sent at night. Is this acceptable for your use case?
No it's pretty important that the notification comes as soon as the workout is finished (or not much longer thereafter). My idea is that it replaces the necessity to send a message in something like a WhatsApp group. 
makes sense. will keep that in mind

Fix patch:
diff --git a/.github/renovate.json b/.github/renovate.json
deleted file mode 100644
index 53fac39112..0000000000
--- a/.github/renovate.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
-	"$schema": "https://docs.renovatebot.com/renovate-schema.json",
-	"extends": ["config:base"],
-	"separateMultipleMajor": true,
-	"packageRules": [
-		{
-			"extends": ["monorepo:yarn"],
-			"groupName": "NodeJS dependencies",
-			"schedule": ["every 2 weeks on Monday before 5am"],
-			"packagePatterns": ["*"]
-		},
-		{
-			"managers": ["cargo"],
-			"groupName": "Rust dependencies",
-			"schedule": ["every 2 weeks on Monday before 5am"],
-			"packagePatterns": ["*"]
-		},
-		{
-			"managers": ["poetry"],
-			"groupName": "Python dependencies",
-			"schedule": ["every 2 weeks on Monday before 5am"],
-			"packagePatterns": ["*"]
-		}
-	]
-}
diff --git a/apps/backend/src/job.rs b/apps/backend/src/job.rs
index 0dba84e36f..89e5cd1d24 100644
--- a/apps/backend/src/job.rs
+++ b/apps/backend/src/job.rs
@@ -34,6 +34,11 @@ pub async fn run_frequent_jobs(
         .process_users_scheduled_for_workout_revision()
         .await
         .trace_ok();
+    app_services
+        .miscellaneous_service
+        .send_pending_immediate_notifications()
+        .await
+        .trace_ok();
     Ok(())
 }
 
diff --git a/apps/frontend/app/routes/_dashboard.settings.preferences.tsx b/apps/frontend/app/routes/_dashboard.settings.preferences.tsx
index bd99cdbaef..292a153967 100644
--- a/apps/frontend/app/routes/_dashboard.settings.preferences.tsx
+++ b/apps/frontend/app/routes/_dashboard.settings.preferences.tsx
@@ -29,9 +29,9 @@ import {
 	DashboardElementLot,
 	GridPacking,
 	MediaLot,
-	MediaStateChanged,
 	UpdateUserPreferenceDocument,
 	type UserPreferences,
+	UserNotificationContent,
 	UserReviewScale,
 	UserUnitSystem,
 } from "@ryot/generated/graphql/backend/graphql";
@@ -388,7 +388,7 @@ export default function Page() {
 								providers.
 							</Text>
 							<SimpleGrid cols={{ md: 2 }}>
-								{Object.values(MediaStateChanged).map((name) => (
+								{Object.values(UserNotificationContent).map((name) => (
 									<Switch
 										size="xs"
 										key={name}
@@ -412,52 +412,56 @@ export default function Page() {
 										}}
 										label={match(name)
 											.with(
-												MediaStateChanged.MetadataEpisodeNameChanged,
+												UserNotificationContent.MetadataEpisodeNameChanged,
 												() => "Name of an episode changes",
 											)
 											.with(
-												MediaStateChanged.MetadataEpisodeImagesChanged,
+												UserNotificationContent.MetadataEpisodeImagesChanged,
 												() => "Images for an episode changes",
 											)
 											.with(
-												MediaStateChanged.MetadataEpisodeReleased,
+												UserNotificationContent.MetadataEpisodeReleased,
 												() => "Number of episodes changes",
 											)
 											.with(
-												MediaStateChanged.MetadataPublished,
+												UserNotificationContent.MetadataPublished,
 
 												() => "A media is published",
 											)
 											.with(
-												MediaStateChanged.MetadataStatusChanged,
+												UserNotificationContent.MetadataStatusChanged,
 												() => "Status changes",
 											)
 											.with(
-												MediaStateChanged.MetadataReleaseDateChanged,
+												UserNotificationContent.MetadataReleaseDateChanged,
 												() => "Release date changes",
 											)
 											.with(
-												MediaStateChanged.MetadataNumberOfSeasonsChanged,
+												UserNotificationContent.MetadataNumberOfSeasonsChanged,
 												() => "Number of seasons changes",
 											)
 											.with(
-												MediaStateChanged.MetadataChaptersOrEpisodesChanged,
+												UserNotificationContent.MetadataChaptersOrEpisodesChanged,
 												() =>
 													"Number of chapters/episodes changes for manga/anime",
 											)
 											.with(
-												MediaStateChanged.ReviewPosted,
+												UserNotificationContent.ReviewPosted,
 												() =>
 													"A new public review is posted for media/people you monitor",
 											)
 											.with(
-												MediaStateChanged.PersonMetadataAssociated,
+												UserNotificationContent.PersonMetadataAssociated,
 												() => "New media is associated with a person",
 											)
 											.with(
-												MediaStateChanged.PersonMetadataGroupAssociated,
+												UserNotificationContent.PersonMetadataGroupAssociated,
 												() => "New media group is associated with a person",
 											)
+											.with(
+												UserNotificationContent.NewWorkoutCreated,
+												() => "A new workout is created",
+											)
 											.exhaustive()}
 									/>
 								))}
diff --git a/crates/migrations/src/lib.rs b/crates/migrations/src/lib.rs
index a7c1c24a5d..c7283f6ae8 100644
--- a/crates/migrations/src/lib.rs
+++ b/crates/migrations/src/lib.rs
@@ -58,6 +58,7 @@ mod m20241220_changes_for_issue_1141;
 mod m20241220_changes_for_issue_49;
 mod m20241224_changes_for_issue_1139;
 mod m20241227_changes_for_issue_1157;
+mod m20241228_changes_for_issue_1130;
 
 pub use m20230410_create_metadata::Metadata as AliasedMetadata;
 pub use m20230413_create_person::Person as AliasedPerson;
@@ -137,6 +138,7 @@ impl MigratorTrait for Migrator {
             Box::new(m20241220_changes_for_issue_1141::Migration),
             Box::new(m20241224_changes_for_issue_1139::Migration),
             Box::new(m20241227_changes_for_issue_1157::Migration),
+            Box::new(m20241228_changes_for_issue_1130::Migration),
         ]
     }
 }
diff --git a/crates/migrations/src/m20241214_create_user_notification.rs b/crates/migrations/src/m20241214_create_user_notification.rs
index 94825d0e5f..d63f84d9e7 100644
--- a/crates/migrations/src/m20241214_create_user_notification.rs
+++ b/crates/migrations/src/m20241214_create_user_notification.rs
@@ -12,6 +12,7 @@ pub enum UserNotification {
     Table,
     UserId,
     Message,
+    IsAddressed,
 }
 
 #[async_trait::async_trait]
@@ -31,6 +32,7 @@ impl MigrationTrait for Migration {
                     .col(ColumnDef::new(UserNotification::Lot).text().not_null())
                     .col(ColumnDef::new(UserNotification::Message).text().not_null())
                     .col(ColumnDef::new(UserNotification::UserId).text().not_null())
+                    .col(ColumnDef::new(UserNotification::IsAddressed).boolean())
                     .foreign_key(
                         ForeignKey::create()
                             .name("notification_to_user_foreign_key")
diff --git a/crates/migrations/src/m20241228_changes_for_issue_1130.rs b/crates/migrations/src/m20241228_changes_for_issue_1130.rs
new file mode 100644
index 0000000000..67bb7be6e8
--- /dev/null
+++ b/crates/migrations/src/m20241228_changes_for_issue_1130.rs
@@ -0,0 +1,42 @@
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+        let db = manager.get_connection();
+        if !manager
+            .has_column("user_notification", "is_addressed")
+            .await?
+        {
+            db.execute_unprepared(
+                r#"ALTER TABLE "user_notification" ADD COLUMN "is_addressed" boolean"#,
+            )
+            .await?;
+        }
+        db.execute_unprepared(
+            r#"
+UPDATE
+  "user"
+SET
+  preferences = JSONB_SET(
+    preferences,
+    '{notifications,to_send}',
+    (preferences -> 'notifications' -> 'to_send') || '"NewWorkoutCreated"'
+  )
+where
+  NOT (
+    preferences -> 'notifications' -> 'to_send' ? 'NewWorkoutCreated'
+  );
+            "#,
+        )
+        .await?;
+        Ok(())
+    }
+
+    async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
+        Ok(())
+    }
+}
diff --git a/crates/models/common/src/lib.rs b/crates/models/common/src/lib.rs
index fc64edf1b8..4913c5a7e8 100644
--- a/crates/models/common/src/lib.rs
+++ b/crates/models/common/src/lib.rs
@@ -177,9 +177,10 @@ pub struct ChangeCollectionToEntityInput {
 }
 
 #[derive(Enum, Eq, PartialEq, Copy, Clone, Debug, Serialize, Deserialize, Display, EnumIter)]
-pub enum MediaStateChanged {
+pub enum UserNotificationContent {
     ReviewPosted,
     MetadataPublished,
+    NewWorkoutCreated,
     MetadataStatusChanged,
     MetadataEpisodeReleased,
     PersonMetadataAssociated,
diff --git a/crates/models/database/src/user_notification.rs b/crates/models/database/src/user_notification.rs
index 55019cea89..d61698f3ec 100644
--- a/crates/models/database/src/user_notification.rs
+++ b/crates/models/database/src/user_notification.rs
@@ -12,6 +12,7 @@ pub struct Model {
     pub message: String,
     pub user_id: String,
     pub lot: UserNotificationLot,
+    pub is_addressed: Option<bool>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
diff --git a/crates/models/enum/src/lib.rs b/crates/models/enum/src/lib.rs
index 9f31f9682e..3930d56f18 100644
--- a/crates/models/enum/src/lib.rs
+++ b/crates/models/enum/src/lib.rs
@@ -103,6 +103,7 @@ pub enum UserLot {
 )]
 pub enum UserNotificationLot {
     Queued,
+    Immediate,
 }
 
 #[derive(
diff --git a/crates/models/user/src/lib.rs b/crates/models/user/src/lib.rs
index e0b562b90b..03104723ed 100644
--- a/crates/models/user/src/lib.rs
+++ b/crates/models/user/src/lib.rs
@@ -1,5 +1,5 @@
 use async_graphql::{Enum, InputObject, SimpleObject};
-use common_models::MediaStateChanged;
+use common_models::UserNotificationContent;
 use educe::Educe;
 use enum_models::{MediaLot, UserLot};
 use fitness_models::{SetRestTimersSettings, UserUnitSystem};
@@ -34,8 +34,8 @@ const MOVIE_WATCH_PROVIDERS: [&str; 8] = [
 #[educe(Default)]
 #[graphql(input_name = "UserNotificationsPreferencesInput")]
 pub struct UserNotificationsPreferences {
-    #[educe(Default(expression = MediaStateChanged::iter().collect()))]
-    pub to_send: Vec<MediaStateChanged>,
+    #[educe(Default(expression = UserNotificationContent::iter().collect()))]
+    pub to_send: Vec<UserNotificationContent>,
     #[educe(Default = true)]
     pub enabled: bool,
 }
diff --git a/crates/services/miscellaneous/src/lib.rs b/crates/services/miscellaneous/src/lib.rs
index 869ea10625..502efd7f91 100644
--- a/crates/services/miscellaneous/src/lib.rs
+++ b/crates/services/miscellaneous/src/lib.rs
@@ -13,9 +13,9 @@ use background_models::{ApplicationJob, HpApplicationJob, MpApplicationJob};
 use chrono::{Days, Duration, NaiveDate, Utc};
 use common_models::{
     ApplicationCacheKey, BackgroundJob, ChangeCollectionToEntityInput, DefaultCollection,
-    IdAndNamedObject, MediaStateChanged, MetadataGroupSearchInput, MetadataSearchInput,
-    PeopleSearchInput, ProgressUpdateCacheInput, SearchDetails, SearchInput, StoredUrl,
-    StringIdObject, UserLevelCacheKey,
+    IdAndNamedObject, MetadataGroupSearchInput, MetadataSearchInput, PeopleSearchInput,
+    ProgressUpdateCacheInput, SearchDetails, SearchInput, StoredUrl, StringIdObject,
+    UserLevelCacheKey, UserNotificationContent,
 };
 use common_utils::{
     get_first_and_last_day_of_month, ryot_log, PAGE_SIZE, SHOW_SPECIAL_SEASON_NAMES,
@@ -47,14 +47,14 @@ use dependent_models::{
 };
 use dependent_utils::{
     add_entity_to_collection, change_metadata_associations, commit_metadata, commit_metadata_group,
-    commit_person, create_partial_metadata, create_user_notification,
+    commit_person, create_notification_for_user, create_partial_metadata, create_user_notification,
     deploy_after_handle_media_seen_tasks, deploy_background_job, deploy_update_metadata_job,
     first_metadata_image_as_url, get_entity_recently_consumed, get_metadata_provider,
     get_openlibrary_service, get_tmdb_non_media_service, get_users_and_cte_monitoring_entity,
     get_users_monitoring_entity, handle_after_media_seen_tasks, is_metadata_finished_by_user,
     metadata_images_as_urls, post_review, progress_update,
-    queue_media_state_changed_notification_for_user, refresh_collection_to_entity_association,
-    remove_entity_from_collection, update_metadata_and_notify_users,
+    refresh_collection_to_entity_association, remove_entity_from_collection,
+    update_metadata_and_notify_users,
 };
 use enum_models::{
     EntityLot, MediaLot, MediaSource, MetadataToMetadataRelation, SeenState, UserNotificationLot,
@@ -2490,7 +2490,7 @@ ORDER BY RANDOM() LIMIT 10;
                 };
                 (
                     meta.id.to_string(),
-                    (notification, MediaStateChanged::MetadataPublished),
+                    (notification, UserNotificationContent::MetadataPublished),
                 )
             })
             .collect_vec();
@@ -2498,14 +2498,22 @@ ORDER BY RANDOM() LIMIT 10;
             let users_to_notify =
                 get_users_monitoring_entity(&metadata_id, EntityLot::Metadata, &self.0.db).await?;
             for user in users_to_notify {
-                queue_media_state_changed_notification_for_user(&user, &notification, &self.0)
-                    .await?;
+                create_notification_for_user(
+                    &user,
+                    &notification,
+                    UserNotificationLot::Queued,
+                    &self.0,
+                )
+                .await?;
             }
         }
         Ok(())
     }
 
-    async fn update_person(&self, person_id: String) -> Result<Vec<(String, MediaStateChanged)>> {
+    async fn update_person(
+        &self,
+        person_id: String,
+    ) -> Result<Vec<(String, UserNotificationContent)>> {
         let mut notifications = vec![];
         let person = Person::find_by_id(person_id.clone())
             .one(&self.0.db)
@@ -2576,7 +2584,7 @@ ORDER BY RANDOM() LIMIT 10;
                         "{} has been associated with {} as {}",
                         person.name, title, data.role
                     ),
-                    MediaStateChanged::PersonMetadataAssociated,
+                    UserNotificationContent::PersonMetadataAssociated,
                 ));
                 current_state_changes.metadata_associated.insert(search_for);
             }
@@ -2637,7 +2645,7 @@ ORDER BY RANDOM() LIMIT 10;
                         "{} has been associated with {} as {}",
                         person.name, data.metadata_group.title, data.role
                     ),
-                    MediaStateChanged::PersonMetadataGroupAssociated,
+                    UserNotificationContent::PersonMetadataGroupAssociated,
                 ));
                 current_state_changes
                     .metadata_groups_associated
@@ -2669,9 +2677,10 @@ ORDER BY RANDOM() LIMIT 10;
                     .await?;
             for notification in notifications {
                 for (user_id, cte_id) in users_to_notify.iter() {
-                    queue_media_state_changed_notification_for_user(
+                    create_notification_for_user(
                         user_id,
                         &notification,
+                        UserNotificationLot::Queued,
                         &self.0,
                     )
                     .await
@@ -2728,7 +2737,7 @@ ORDER BY RANDOM() LIMIT 10;
             .filter(user::Column::Id.is_in(monitored_by))
             .filter(Expr::cust(format!(
                 "(preferences -> 'notifications' -> 'to_send' ? '{}') = true",
-                MediaStateChanged::ReviewPosted
+                UserNotificationContent::ReviewPosted
             )))
             .into_tuple::<String>()
             .all(&self.0.db)
@@ -2879,9 +2888,9 @@ ORDER BY RANDOM() LIMIT 10;
             ryot_log!(debug, "Removing genre id = {:#?}", genre);
             Genre::delete_by_id(genre).exec(&self.0.db).await?;
         }
-        ryot_log!(debug, "Deleting all queued notifications");
+        ryot_log!(debug, "Deleting all addressed user notifications");
         UserNotification::delete_many()
-            .filter(user_notification::Column::Lot.eq(UserNotificationLot::Queued))
+            .filter(user_notification::Column::IsAddressed.eq(true))
             .exec(&self.0.db)
             .await?;
         ryot_log!(debug, "Deleting revoked access tokens");
@@ -2897,7 +2906,7 @@ ORDER BY RANDOM() LIMIT 10;
         Ok(())
     }
 
-    pub async fn put_entities_in_partial_state(&self) -> Result<()> {
+    async fn put_entities_in_partial_state(&self) -> Result<()> {
         async fn update_partial_states<Column1, Column2, Column3, T>(
             ute_filter_column: Column1,
             updater: UpdateMany<T>,
@@ -2956,7 +2965,7 @@ ORDER BY RANDOM() LIMIT 10;
         Ok(())
     }
 
-    pub async fn send_pending_notifications(&self) -> Result<()> {
+    async fn send_pending_queued_notifications(&self) -> Result<()> {
         let users = User::find().all(&self.0.db).await?;
         for user_details in users {
             ryot_log!(debug, "Sending notification to user: {:?}", user_details.id);
@@ -2968,6 +2977,7 @@ ORDER BY RANDOM() LIMIT 10;
             if notifications.is_empty() {
                 continue;
             }
+            let notification_ids = notifications.iter().map(|n| n.id).collect_vec();
             let msg = notifications
                 .into_iter()
                 .map(|n| n.message)
@@ -2975,6 +2985,11 @@ ORDER BY RANDOM() LIMIT 10;
                 .join("\n");
             let platforms = NotificationPlatform::find()
                 .filter(notification_platform::Column::UserId.eq(&user_details.id))
+                .filter(
+                    user_notification::Column::IsAddressed
+                        .eq(false)
+                        .or(user_notification::Column::IsAddressed.is_null()),
+                )
                 .all(&self.0.db)
                 .await?;
             for notification in platforms {
@@ -2993,6 +3008,61 @@ ORDER BY RANDOM() LIMIT 10;
                     ryot_log!(trace, "Error sending notification: {:?}", err);
                 }
             }
+            UserNotification::update_many()
+                .filter(user_notification::Column::Id.is_in(notification_ids))
+                .col_expr(user_notification::Column::IsAddressed, Expr::value(true))
+                .exec(&self.0.db)
+                .await?;
+        }
+        Ok(())
+    }
+
+    pub async fn send_pending_immediate_notifications(&self) -> Result<()> {
+        let users = User::find().all(&self.0.db).await?;
+        for user_details in users {
+            ryot_log!(debug, "Sending notification to user: {:?}", user_details.id);
+            let notifications = UserNotification::find()
+                .filter(user_notification::Column::UserId.eq(&user_details.id))
+                .filter(user_notification::Column::Lot.eq(UserNotificationLot::Immediate))
+                .filter(
+                    user_notification::Column::IsAddressed
+                        .eq(false)
+                        .or(user_notification::Column::IsAddressed.is_null()),
+                )
+                .all(&self.0.db)
+                .await?;
+            let notification_ids = notifications.iter().map(|n| n.id).collect_vec();
+            let platforms = NotificationPlatform::find()
+                .filter(notification_platform::Column::UserId.eq(&user_details.id))
+                .all(&self.0.db)
+                .await?;
+            for notification in notifications {
+                for platform in platforms.iter() {
+                    if platform.is_disabled.unwrap_or_default() {
+                        ryot_log!(
+                        debug,
+                        "Skipping sending notification to user: {} for platform: {} since it is disabled",
+                        user_details.id,
+                        platform.lot
+                    );
+                        continue;
+                    }
+                    if let Err(err) = send_notification(
+                        platform.platform_specifics.to_owned(),
+                        &self.0.config,
+                        &notification.message,
+                    )
+                    .await
+                    {
+                        ryot_log!(trace, "Error sending notification: {:?}", err);
+                    }
+                }
+            }
+            UserNotification::update_many()
+                .filter(user_notification::Column::Id.is_in(notification_ids))
+                .col_expr(user_notification::Column::IsAddressed, Expr::value(true))
+                .exec(&self.0.db)
+                .await?;
         }
         Ok(())
     }
@@ -3030,7 +3100,7 @@ ORDER BY RANDOM() LIMIT 10;
             .await
             .trace_ok();
         ryot_log!(trace, "Sending all pending notifications");
-        self.send_pending_notifications().await.trace_ok();
+        self.send_pending_queued_notifications().await.trace_ok();
         ryot_log!(trace, "Cleaning up user and metadata association");
         self.cleanup_user_and_metadata_association()
             .await
diff --git a/crates/utils/dependent/src/lib.rs b/crates/utils/dependent/src/lib.rs
index 82045d3957..16561eaa14 100644
--- a/crates/utils/dependent/src/lib.rs
+++ b/crates/utils/dependent/src/lib.rs
@@ -6,8 +6,8 @@ use background_models::{ApplicationJob, HpApplicationJob, LpApplicationJob, MpAp
 use chrono::Utc;
 use common_models::{
     ApplicationCacheKey, BackgroundJob, ChangeCollectionToEntityInput, DefaultCollection,
-    MediaStateChanged, MetadataRecentlyConsumedCacheInput, ProgressUpdateCacheInput, StoredUrl,
-    StringIdObject, UserLevelCacheKey,
+    MetadataRecentlyConsumedCacheInput, ProgressUpdateCacheInput, StoredUrl, StringIdObject,
+    UserLevelCacheKey, UserNotificationContent,
 };
 use common_utils::{acquire_lock, ryot_log, EXERCISE_LOT_MAPPINGS, SHOW_SPECIAL_SEASON_NAMES};
 use database_models::{
@@ -355,7 +355,7 @@ pub async fn change_metadata_associations(
 pub async fn update_metadata(
     metadata_id: &String,
     ss: &Arc<SupportingService>,
-) -> Result<Vec<(String, MediaStateChanged)>> {
+) -> Result<Vec<(String, UserNotificationContent)>> {
     let metadata = Metadata::find_by_id(metadata_id)
         .one(&ss.db)
         .await
@@ -382,7 +382,7 @@ pub async fn update_metadata(
                 if p1 != p2 {
                     notifications.push((
                         format!("Status changed from {:#?} to {:#?}", p1, p2),
-                        MediaStateChanged::MetadataStatusChanged,
+                        UserNotificationContent::MetadataStatusChanged,
                     ));
                 }
             }
@@ -390,7 +390,7 @@ pub async fn update_metadata(
                 if p1 != p2 {
                     notifications.push((
                         format!("Publish year from {:#?} to {:#?}", p1, p2),
-                        MediaStateChanged::MetadataReleaseDateChanged,
+                        UserNotificationContent::MetadataReleaseDateChanged,
                     ));
                 }
             }
@@ -402,7 +402,7 @@ pub async fn update_metadata(
                             s1.seasons.len(),
                             s2.seasons.len()
                         ),
-                        MediaStateChanged::MetadataNumberOfSeasonsChanged,
+                        UserNotificationContent::MetadataNumberOfSeasonsChanged,
                     ));
                 } else {
                     for (s1, s2) in zip(s1.seasons.iter(), s2.seasons.iter()) {
@@ -419,7 +419,7 @@ pub async fn update_metadata(
                                     s2.episodes.len(),
                                     s1.season_number
                                 ),
-                                MediaStateChanged::MetadataEpisodeReleased,
+                                UserNotificationContent::MetadataEpisodeReleased,
                             ));
                         } else {
                             for (before_episode, after_episode) in
@@ -434,7 +434,7 @@ pub async fn update_metadata(
                                             s1.season_number,
                                             before_episode.episode_number
                                         ),
-                                        MediaStateChanged::MetadataEpisodeNameChanged,
+                                        UserNotificationContent::MetadataEpisodeNameChanged,
                                     ));
                                 }
                                 if before_episode.poster_images != after_episode.poster_images {
@@ -443,7 +443,7 @@ pub async fn update_metadata(
                                             "Episode image changed for S{}E{}",
                                             s1.season_number, before_episode.episode_number
                                         ),
-                                        MediaStateChanged::MetadataEpisodeImagesChanged,
+                                        UserNotificationContent::MetadataEpisodeImagesChanged,
                                     ));
                                 }
                                 if let (Some(pd1), Some(pd2)) =
@@ -458,7 +458,7 @@ pub async fn update_metadata(
                                                 s1.season_number,
                                                 before_episode.episode_number
                                             ),
-                                            MediaStateChanged::MetadataReleaseDateChanged,
+                                            UserNotificationContent::MetadataReleaseDateChanged,
                                         ));
                                     }
                                 }
@@ -472,7 +472,7 @@ pub async fn update_metadata(
                     if e1 != e2 {
                         notifications.push((
                             format!("Number of episodes changed from {:#?} to {:#?}", e1, e2),
-                            MediaStateChanged::MetadataChaptersOrEpisodesChanged,
+                            UserNotificationContent::MetadataChaptersOrEpisodesChanged,
                         ));
                     }
                 }
@@ -482,7 +482,7 @@ pub async fn update_metadata(
                     if c1 != c2 {
                         notifications.push((
                             format!("Number of chapters changed from {:#?} to {:#?}", c1, c2),
-                            MediaStateChanged::MetadataChaptersOrEpisodesChanged,
+                            UserNotificationContent::MetadataChaptersOrEpisodesChanged,
                         ));
                     }
                 }
@@ -495,7 +495,7 @@ pub async fn update_metadata(
                             p1.episodes.len(),
                             p2.episodes.len()
                         ),
-                        MediaStateChanged::MetadataEpisodeReleased,
+                        UserNotificationContent::MetadataEpisodeReleased,
                     ));
                 } else {
                     for (before_episode, after_episode) in
@@ -509,13 +509,13 @@ pub async fn update_metadata(
                                     after_episode.title,
                                     before_episode.number
                                 ),
-                                MediaStateChanged::MetadataEpisodeNameChanged,
+                                UserNotificationContent::MetadataEpisodeNameChanged,
                             ));
                         }
                         if before_episode.thumbnail != after_episode.thumbnail {
                             notifications.push((
                                 format!("Episode image changed for EP{}", before_episode.number),
-                                MediaStateChanged::MetadataEpisodeImagesChanged,
+                                UserNotificationContent::MetadataEpisodeImagesChanged,
                             ));
                         }
                     }
@@ -646,19 +646,24 @@ pub async fn create_user_notification(
         ..Default::default()
     };
     let notification = insert_data.insert(db).await?;
-    ryot_log!(debug, "Queued notification with id = {}", notification.id);
+    ryot_log!(
+        debug,
+        "Created user notification with id = {}",
+        notification.id
+    );
     Ok(true)
 }
 
-pub async fn queue_media_state_changed_notification_for_user(
+pub async fn create_notification_for_user(
     user_id: &String,
-    notification: &(String, MediaStateChanged),
+    notification: &(String, UserNotificationContent),
+    lot: UserNotificationLot,
     ss: &Arc<SupportingService>,
 ) -> Result<()> {
     let (msg, change) = notification;
     let notification_preferences = user_by_id(user_id, ss).await?.preferences.notifications;
     if notification_preferences.enabled && notification_preferences.to_send.contains(change) {
-        create_user_notification(msg, user_id, &ss.db, UserNotificationLot::Queued)
+        create_user_notification(msg, user_id, &ss.db, lot)
             .await
             .trace_ok();
     } else {
@@ -699,9 +704,14 @@ pub async fn update_metadata_and_notify_users(
             get_users_and_cte_monitoring_entity(metadata_id, EntityLot::Metadata, &ss.db).await?;
         for notification in notifications {
             for (user_id, cte_id) in users_to_notify.iter() {
-                queue_media_state_changed_notification_for_user(user_id, &notification, ss)
-                    .await
-                    .trace_ok();
+                create_notification_for_user(
+                    user_id,
+                    &notification,
+                    UserNotificationLot::Queued,
+                    ss,
+                )
+                .await
+                .trace_ok();
                 refresh_collection_to_entity_association(cte_id, &ss.db)
                     .await
                     .trace_ok();
@@ -1697,14 +1707,14 @@ pub async fn create_or_update_user_workout(
                     "The workout was never resumed after being paused",
                 ));
             }
-            let mut total = 0;
-            for duration in durations.iter() {
-                total += duration
-                    .to
-                    .unwrap_or(end_time)
-                    .signed_duration_since(duration.from)
-                    .num_seconds();
-            }
+            let total = durations
+                .iter()
+                .map(|d| {
+                    d.to.unwrap_or(end_time)
+                        .signed_duration_since(d.from)
+                        .num_seconds()
+                })
+                .sum();
             (total, durations)
         }
         None => (
@@ -1979,9 +1989,21 @@ pub async fn create_or_update_user_workout(
         old_workout.delete(&ss.db).await?;
     }
     let data = insert.insert(&ss.db).await?;
-    if to_update_workout.is_some() {
-        schedule_user_for_workout_revision(user_id, ss).await?;
-    }
+    match to_update_workout {
+        Some(_) => schedule_user_for_workout_revision(user_id, ss).await?,
+        None => {
+            create_notification_for_user(
+                user_id,
+                &(
+                    format!("New workout created - {}", data.name),
+                    UserNotificationContent::NewWorkoutCreated,
+                ),
+                UserNotificationLot::Immediate,
+                ss,
+            )
+            .await?
+        }
+    };
     Ok(data.id)
 }
 
diff --git a/libs/generated/src/graphql/backend/graphql.ts b/libs/generated/src/graphql/backend/graphql.ts
index b5b28c9d48..6eb28655e9 100644
--- a/libs/generated/src/graphql/backend/graphql.ts
+++ b/libs/generated/src/graphql/backend/graphql.ts
@@ -1037,20 +1037,6 @@ export enum MediaSource {
   YoutubeMusic = 'YOUTUBE_MUSIC'
 }
 
-export enum MediaStateChanged {
-  MetadataChaptersOrEpisodesChanged = 'METADATA_CHAPTERS_OR_EPISODES_CHANGED',
-  MetadataEpisodeImagesChanged = 'METADATA_EPISODE_IMAGES_CHANGED',
-  MetadataEpisodeNameChanged = 'METADATA_EPISODE_NAME_CHANGED',
-  MetadataEpisodeReleased = 'METADATA_EPISODE_RELEASED',
-  MetadataNumberOfSeasonsChanged = 'METADATA_NUMBER_OF_SEASONS_CHANGED',
-  MetadataPublished = 'METADATA_PUBLISHED',
-  MetadataReleaseDateChanged = 'METADATA_RELEASE_DATE_CHANGED',
-  MetadataStatusChanged = 'METADATA_STATUS_CHANGED',
-  PersonMetadataAssociated = 'PERSON_METADATA_ASSOCIATED',
-  PersonMetadataGroupAssociated = 'PERSON_METADATA_GROUP_ASSOCIATED',
-  ReviewPosted = 'REVIEW_POSTED'
-}
-
 export type MetadataCreator = {
   character?: Maybe<Scalars['String']['output']>;
   id?: Maybe<Scalars['String']['output']>;
@@ -2690,14 +2676,29 @@ export type UserMetadataGroupDetails = {
   reviews: Array<ReviewItem>;
 };
 
+export enum UserNotificationContent {
+  MetadataChaptersOrEpisodesChanged = 'METADATA_CHAPTERS_OR_EPISODES_CHANGED',
+  MetadataEpisodeImagesChanged = 'METADATA_EPISODE_IMAGES_CHANGED',
+  MetadataEpisodeNameChanged = 'METADATA_EPISODE_NAME_CHANGED',
+  MetadataEpisodeReleased = 'METADATA_EPISODE_RELEASED',
+  MetadataNumberOfSeasonsChanged = 'METADATA_NUMBER_OF_SEASONS_CHANGED',
+  MetadataPublished = 'METADATA_PUBLISHED',
+  MetadataReleaseDateChanged = 'METADATA_RELEASE_DATE_CHANGED',
+  MetadataStatusChanged = 'METADATA_STATUS_CHANGED',
+  NewWorkoutCreated = 'NEW_WORKOUT_CREATED',
+  PersonMetadataAssociated = 'PERSON_METADATA_ASSOCIATED',
+  PersonMetadataGroupAssociated = 'PERSON_METADATA_GROUP_ASSOCIATED',
+  ReviewPosted = 'REVIEW_POSTED'
+}
+
 export type UserNotificationsPreferences = {
   enabled: Scalars['Boolean']['output'];
-  toSend: Array<MediaStateChanged>;
+  toSend: Array<UserNotificationContent>;
 };
 
 export type UserNotificationsPreferencesInput = {
   enabled: Scalars['Boolean']['input'];
-  toSend: Array<MediaStateChanged>;
+  toSend: Array<UserNotificationContent>;
 };
 
 export type UserOthersFeaturesEnabledPreferences = {
@@ -3406,7 +3407,7 @@ export type UserAnalyticsQuery = { userAnalytics: { hours: Array<{ hour: number,
 export type UserDetailsQueryVariables = Exact<{ [key: string]: never; }>;
 
 
-export type UserDetailsQuery = { userDetails: { __typename: 'User', id: string, lot: UserLot, name: string, isDisabled?: boolean | null, oidcIssuerId?: string | null, preferences: { general: { reviewScale: UserReviewScale, gridPacking: GridPacking, displayNsfw: boolean, disableVideos: boolean, persistQueries: boolean, disableReviews: boolean, disableIntegrations: boolean, disableWatchProviders: boolean, disableNavigationAnimation: boolean, dashboard: Array<{ hidden: boolean, section: DashboardElementLot, numElements?: number | null, deduplicateMedia?: boolean | null }>, watchProviders: Array<{ lot: MediaLot, values: Array<string> }> }, fitness: { logging: { muteSounds: boolean, promptForRestTimer: boolean, showDetailsWhileEditing: boolean }, exercises: { unitSystem: UserUnitSystem, setRestTimers: { drop?: number | null, warmup?: number | null, normal?: number | null, failure?: number | null } }, measurements: { custom: Array<{ name: string, dataType: UserCustomMeasurementDataType }>, inbuilt: { weight: boolean, bodyMassIndex: boolean, totalBodyWater: boolean, muscle: boolean, leanBodyMass: boolean, bodyFat: boolean, boneMass: boolean, visceralFat: boolean, waistCircumference: boolean, waistToHeightRatio: boolean, hipCircumference: boolean, waistToHipRatio: boolean, chestCircumference: boolean, thighCircumference: boolean, bicepsCircumference: boolean, neckCircumference: boolean, bodyFatCaliper: boolean, chestSkinfold: boolean, abdominalSkinfold: boolean, thighSkinfold: boolean, basalMetabolicRate: boolean, totalDailyEnergyExpenditure: boolean, calories: boolean } } }, notifications: { toSend: Array<MediaStateChanged>, enabled: boolean }, featuresEnabled: { analytics: { enabled: boolean }, others: { calendar: boolean, collections: boolean }, fitness: { enabled: boolean, workouts: boolean, templates: boolean, measurements: boolean }, media: { enabled: boolean, show: boolean, book: boolean, anime: boolean, manga: boolean, music: boolean, movie: boolean, groups: boolean, people: boolean, genres: boolean, podcast: boolean, audioBook: boolean, videoGame: boolean, visualNovel: boolean } } } } | { __typename: 'UserDetailsError' } };
+export type UserDetailsQuery = { userDetails: { __typename: 'User', id: string, lot: UserLot, name: string, isDisabled?: boolean | null, oidcIssuerId?: string | null, preferences: { general: { reviewScale: UserReviewScale, gridPacking: GridPacking, displayNsfw: boolean, disableVideos: boolean, persistQueries: boolean, disableReviews: boolean, disableIntegrations: boolean, disableWatchProviders: boolean, disableNavigationAnimation: boolean, dashboard: Array<{ hidden: boolean, section: DashboardElementLot, numElements?: number | null, deduplicateMedia?: boolean | null }>, watchProviders: Array<{ lot: MediaLot, values: Array<string> }> }, fitness: { logging: { muteSounds: boolean, promptForRestTimer: boolean, showDetailsWhileEditing: boolean }, exercises: { unitSystem: UserUnitSystem, setRestTimers: { drop?: number | null, warmup?: number | null, normal?: number | null, failure?: number | null } }, measurements: { custom: Array<{ name: string, dataType: UserCustomMeasurementDataType }>, inbuilt: { weight: boolean, bodyMassIndex: boolean, totalBodyWater: boolean, muscle: boolean, leanBodyMass: boolean, bodyFat: boolean, boneMass: boolean, visceralFat: boolean, waistCircumference: boolean, waistToHeightRatio: boolean, hipCircumference: boolean, waistToHipRatio: boolean, chestCircumference: boolean, thighCircumference: boolean, bicepsCircumference: boolean, neckCircumference: boolean, bodyFatCaliper: boolean, chestSkinfold: boolean, abdominalSkinfold: boolean, thighSkinfold: boolean, basalMetabolicRate: boolean, totalDailyEnergyExpenditure: boolean, calories: boolean } } }, notifications: { toSend: Array<UserNotificationContent>, enabled: boolean }, featuresEnabled: { analytics: { enabled: boolean }, others: { calendar: boolean, collections: boolean }, fitness: { enabled: boolean, workouts: boolean, templates: boolean, measurements: boolean }, media: { enabled: boolean, show: boolean, book: boolean, anime: boolean, manga: boolean, music: boolean, movie: boolean, groups: boolean, people: boolean, genres: boolean, podcast: boolean, audioBook: boolean, videoGame: boolean, visualNovel: boolean } } } } | { __typename: 'UserDetailsError' } };
 
 export type UserExerciseDetailsQueryVariables = Exact<{
   exerciseId: Scalars['String']['input'];
diff --git a/libs/generated/src/graphql/backend/types.generated.ts b/libs/generated/src/graphql/backend/types.generated.ts
index 1f4ff5eeb0..782e98ea81 100644
--- a/libs/generated/src/graphql/backend/types.generated.ts
+++ b/libs/generated/src/graphql/backend/types.generated.ts
@@ -1065,20 +1065,6 @@ export enum MediaSource {
   YoutubeMusic = 'YOUTUBE_MUSIC'
 }
 
-export enum MediaStateChanged {
-  MetadataChaptersOrEpisodesChanged = 'METADATA_CHAPTERS_OR_EPISODES_CHANGED',
-  MetadataEpisodeImagesChanged = 'METADATA_EPISODE_IMAGES_CHANGED',
-  MetadataEpisodeNameChanged = 'METADATA_EPISODE_NAME_CHANGED',
-  MetadataEpisodeReleased = 'METADATA_EPISODE_RELEASED',
-  MetadataNumberOfSeasonsChanged = 'METADATA_NUMBER_OF_SEASONS_CHANGED',
-  MetadataPublished = 'METADATA_PUBLISHED',
-  MetadataReleaseDateChanged = 'METADATA_RELEASE_DATE_CHANGED',
-  MetadataStatusChanged = 'METADATA_STATUS_CHANGED',
-  PersonMetadataAssociated = 'PERSON_METADATA_ASSOCIATED',
-  PersonMetadataGroupAssociated = 'PERSON_METADATA_GROUP_ASSOCIATED',
-  ReviewPosted = 'REVIEW_POSTED'
-}
-
 export type MetadataCreator = {
   __typename?: 'MetadataCreator';
   character?: Maybe<Scalars['String']['output']>;
@@ -2786,15 +2772,30 @@ export type UserMetadataGroupDetails = {
   reviews: Array<ReviewItem>;
 };
 
+export enum UserNotificationContent {
+  MetadataChaptersOrEpisodesChanged = 'METADATA_CHAPTERS_OR_EPISODES_CHANGED',
+  MetadataEpisodeImagesChanged = 'METADATA_EPISODE_IMAGES_CHANGED',
+  MetadataEpisodeNameChanged = 'METADATA_EPISODE_NAME_CHANGED',
+  MetadataEpisodeReleased = 'METADATA_EPISODE_RELEASED',
+  MetadataNumberOfSeasonsChanged = 'METADATA_NUMBER_OF_SEASONS_CHANGED',
+  MetadataPublished = 'METADATA_PUBLISHED',
+  MetadataReleaseDateChanged = 'METADATA_RELEASE_DATE_CHANGED',
+  MetadataStatusChanged = 'METADATA_STATUS_CHANGED',
+  NewWorkoutCreated = 'NEW_WORKOUT_CREATED',
+  PersonMetadataAssociated = 'PERSON_METADATA_ASSOCIATED',
+  PersonMetadataGroupAssociated = 'PERSON_METADATA_GROUP_ASSOCIATED',
+  ReviewPosted = 'REVIEW_POSTED'
+}
+
 export type UserNotificationsPreferences = {
   __typename?: 'UserNotificationsPreferences';
   enabled: Scalars['Boolean']['output'];
-  toSend: Array<MediaStateChanged>;
+  toSend: Array<UserNotificationContent>;
 };
 
 export type UserNotificationsPreferencesInput = {
   enabled: Scalars['Boolean']['input'];
-  toSend: Array<MediaStateChanged>;
+  toSend: Array<UserNotificationContent>;
 };
 
 export type UserOthersFeaturesEnabledPreferences = {


Test patch: