From bbfde4bb177ab575a3ec3f7e18fbee128f294cae Mon Sep 17 00:00:00 2001 From: Flaminel Date: Wed, 18 Jun 2025 17:48:50 +0300 Subject: [PATCH] added notifications endpoint --- .../Controllers/ConfigurationController.cs | 88 ++++- .../download-cleaner-config.store.ts | 2 +- .../notification-config.store.ts | 85 +++++ .../notification-settings.component.html | 168 +++++++++ .../notification-settings.component.scss | 12 + .../notification-settings.component.ts | 324 ++++++++++++++++++ .../settings-page.component.html | 5 + .../settings-page/settings-page.component.ts | 10 +- .../app/shared/models/apprise-config.model.ts | 6 + .../shared/models/notifiarr-config.model.ts | 6 + .../models/notification-config.model.ts | 9 + .../models/notifications-config.model.ts | 7 + 12 files changed, 714 insertions(+), 8 deletions(-) create mode 100644 code/UI/src/app/settings/notification-settings/notification-config.store.ts create mode 100644 code/UI/src/app/settings/notification-settings/notification-settings.component.html create mode 100644 code/UI/src/app/settings/notification-settings/notification-settings.component.scss create mode 100644 code/UI/src/app/settings/notification-settings/notification-settings.component.ts create mode 100644 code/UI/src/app/shared/models/apprise-config.model.ts create mode 100644 code/UI/src/app/shared/models/notifiarr-config.model.ts create mode 100644 code/UI/src/app/shared/models/notification-config.model.ts create mode 100644 code/UI/src/app/shared/models/notifications-config.model.ts diff --git a/code/Cleanuparr.Api/Controllers/ConfigurationController.cs b/code/Cleanuparr.Api/Controllers/ConfigurationController.cs index f8e4fb8c..fa3c0909 100644 --- a/code/Cleanuparr.Api/Controllers/ConfigurationController.cs +++ b/code/Cleanuparr.Api/Controllers/ConfigurationController.cs @@ -10,6 +10,7 @@ using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Persistence.Models.Configuration.General; +using Cleanuparr.Persistence.Models.Configuration.Notification; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Infrastructure.Services.Interfaces; using Mapster; @@ -285,15 +286,90 @@ public class ConfigurationController : ControllerBase [HttpGet("notifications")] public async Task GetNotificationsConfig() { - // TODO get all notification configs await DataContext.Lock.WaitAsync(); try { - // var config = await _dataContext.NotificationsConfigs - // .AsNoTracking() - // .FirstAsync(); - // return Ok(config); - return null; // Placeholder for future implementation + var notifiarrConfig = await _dataContext.NotifiarrConfigs + .AsNoTracking() + .FirstOrDefaultAsync(); + + var appriseConfig = await _dataContext.AppriseConfigs + .AsNoTracking() + .FirstOrDefaultAsync(); + + // Return in the expected format with wrapper object + var config = new + { + notifiarr = notifiarrConfig, + apprise = appriseConfig + }; + return Ok(config); + } + finally + { + DataContext.Lock.Release(); + } + } + + public class UpdateNotificationConfigDto + { + public NotifiarrConfig? Notifiarr { get; set; } + public AppriseConfig? Apprise { get; set; } + } + + [HttpPut("notifications")] + public async Task UpdateNotificationsConfig([FromBody] UpdateNotificationConfigDto newConfig) + { + await DataContext.Lock.WaitAsync(); + try + { + // Update Notifiarr config if provided + if (newConfig.Notifiarr != null) + { + var existingNotifiarr = await _dataContext.NotifiarrConfigs.FirstOrDefaultAsync(); + if (existingNotifiarr != null) + { + // Apply updates from DTO, excluding the ID property to avoid EF key modification error + var config = new TypeAdapterConfig(); + config.NewConfig() + .Ignore(dest => dest.Id); + + newConfig.Notifiarr.Adapt(existingNotifiarr, config); + } + else + { + _dataContext.NotifiarrConfigs.Add(newConfig.Notifiarr); + } + } + + // Update Apprise config if provided + if (newConfig.Apprise != null) + { + var existingApprise = await _dataContext.AppriseConfigs.FirstOrDefaultAsync(); + if (existingApprise != null) + { + // Apply updates from DTO, excluding the ID property to avoid EF key modification error + var config = new TypeAdapterConfig(); + config.NewConfig() + .Ignore(dest => dest.Id); + + newConfig.Apprise.Adapt(existingApprise, config); + } + else + { + _dataContext.AppriseConfigs.Add(newConfig.Apprise); + } + } + + // Persist the configuration + await _dataContext.SaveChangesAsync(); + + return Ok(new { Message = "Notifications configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save Notifications configuration"); + return StatusCode(500, "Failed to save Notifications configuration"); } finally { diff --git a/code/UI/src/app/settings/download-cleaner/download-cleaner-config.store.ts b/code/UI/src/app/settings/download-cleaner/download-cleaner-config.store.ts index 410f41e5..ef646314 100644 --- a/code/UI/src/app/settings/download-cleaner/download-cleaner-config.store.ts +++ b/code/UI/src/app/settings/download-cleaner/download-cleaner-config.store.ts @@ -24,7 +24,7 @@ export class DownloadCleanerConfigStore { readonly error = this._error.asReadonly(); // API endpoints - private apiUrl = `${environment.apiUrl}/api/Configuration`; + private apiUrl = `${environment.apiUrl}/api/configuration`; constructor() { // Load config on initialization diff --git a/code/UI/src/app/settings/notification-settings/notification-config.store.ts b/code/UI/src/app/settings/notification-settings/notification-config.store.ts new file mode 100644 index 00000000..b97a21a0 --- /dev/null +++ b/code/UI/src/app/settings/notification-settings/notification-config.store.ts @@ -0,0 +1,85 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { NotificationsConfig } from '../../shared/models/notifications-config.model'; +import { catchError, finalize, of, tap } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +@Injectable() +export class NotificationConfigStore { + // API endpoints + private readonly baseUrl = environment.apiUrl; + + // State signals + private _config = signal(null); + private _loading = signal(false); + private _saving = signal(false); + private _error = signal(null); + + // Public selectors + readonly config = this._config.asReadonly(); + readonly loading = this._loading.asReadonly(); + readonly saving = this._saving.asReadonly(); + readonly error = this._error.asReadonly(); + + // Inject HttpClient + private http = inject(HttpClient); + + constructor() { + // Load the configuration when the store is created + this.loadConfig(); + } + + /** + * Load notification configuration from the API + */ + loadConfig(): void { + if (this._loading()) return; + + this._loading.set(true); + this._error.set(null); + + this.http.get(`${this.baseUrl}/api/configuration/notifications`) + .pipe( + tap((config) => { + this._config.set(config); + this._error.set(null); + }), + catchError((error: HttpErrorResponse) => { + console.error('Error loading notification configuration:', error); + this._error.set(error.message || 'Failed to load notification configuration'); + return of(null); + }), + finalize(() => { + this._loading.set(false); + }) + ) + .subscribe(); + } + + /** + * Save notification configuration to the API + */ + saveConfig(config: NotificationsConfig): void { + if (this._saving()) return; + + this._saving.set(true); + this._error.set(null); + + this.http.put(`${this.baseUrl}/api/configuration/notifications`, config) + .pipe( + tap(() => { + // Don't set config - let the form stay as-is with string enum values + this._error.set(null); + }), + catchError((error: HttpErrorResponse) => { + console.error('Error saving notification configuration:', error); + this._error.set(error.message || 'Failed to save notification configuration'); + return of(null); + }), + finalize(() => { + this._saving.set(false); + }) + ) + .subscribe(); + } +} \ No newline at end of file diff --git a/code/UI/src/app/settings/notification-settings/notification-settings.component.html b/code/UI/src/app/settings/notification-settings/notification-settings.component.html new file mode 100644 index 00000000..09db2175 --- /dev/null +++ b/code/UI/src/app/settings/notification-settings/notification-settings.component.html @@ -0,0 +1,168 @@ + + + + +
+
+

Notification Configuration

+ Configure notification settings for Notifiarr and Apprise +
+
+ +
+
+
+ +
+ + + + +
+ + +
+

Notifiarr Configuration

+
+ +
+ +
+ + Your Notifiarr API key for authentication +
+
+ + +
+ +
+ + The channel ID where notifications will be sent +
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Select which events should trigger Notifiarr notifications +
+
+
+
+ + +
+

Apprise Configuration

+
+ +
+ +
+ + The Apprise server URL +
+
+ + +
+ +
+ + The key for authentication +
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Select which events should trigger Apprise notifications +
+
+
+
+ + + +
+
+
\ No newline at end of file diff --git a/code/UI/src/app/settings/notification-settings/notification-settings.component.scss b/code/UI/src/app/settings/notification-settings/notification-settings.component.scss new file mode 100644 index 00000000..937126fd --- /dev/null +++ b/code/UI/src/app/settings/notification-settings/notification-settings.component.scss @@ -0,0 +1,12 @@ +/* Notification Settings Styles */ + +@import '../styles/settings-shared.scss'; + +.section-title { + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--primary-color); + border-bottom: 1px solid var(--surface-border); + padding-bottom: 0.5rem; +} \ No newline at end of file diff --git a/code/UI/src/app/settings/notification-settings/notification-settings.component.ts b/code/UI/src/app/settings/notification-settings/notification-settings.component.ts new file mode 100644 index 00000000..88a3d719 --- /dev/null +++ b/code/UI/src/app/settings/notification-settings/notification-settings.component.ts @@ -0,0 +1,324 @@ +import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { Subject, takeUntil } from "rxjs"; +import { NotificationConfigStore } from "./notification-config.store"; +import { CanComponentDeactivate } from "../../core/guards"; +import { NotificationsConfig } from "../../shared/models/notifications-config.model"; +import { NotifiarrConfig } from "../../shared/models/notifiarr-config.model"; +import { AppriseConfig } from "../../shared/models/apprise-config.model"; + +// PrimeNG Components +import { CardModule } from "primeng/card"; +import { InputTextModule } from "primeng/inputtext"; +import { CheckboxModule } from "primeng/checkbox"; +import { ButtonModule } from "primeng/button"; +import { ToastModule } from "primeng/toast"; +import { NotificationService } from '../../core/services/notification.service'; +import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component"; + +@Component({ + selector: "app-notification-settings", + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + CardModule, + InputTextModule, + CheckboxModule, + ButtonModule, + ToastModule, + LoadingErrorStateComponent, + ], + providers: [NotificationConfigStore], + templateUrl: "./notification-settings.component.html", + styleUrls: ["./notification-settings.component.scss"], +}) +export class NotificationSettingsComponent implements OnDestroy, CanComponentDeactivate { + @Output() saved = new EventEmitter(); + @Output() error = new EventEmitter(); + + // Notification Configuration Form + notificationForm: FormGroup; + + // Original form values for tracking changes + private originalFormValues: any; + + // Track whether the form has actual changes compared to original values + hasActualChanges = false; + + // Inject the necessary services + private formBuilder = inject(FormBuilder); + private notificationService = inject(NotificationService); + private notificationConfigStore = inject(NotificationConfigStore); + + // Signals from the store + readonly notificationConfig = this.notificationConfigStore.config; + readonly notificationLoading = this.notificationConfigStore.loading; + readonly notificationSaving = this.notificationConfigStore.saving; + readonly notificationError = this.notificationConfigStore.error; + + // Subject for unsubscribing from observables when component is destroyed + private destroy$ = new Subject(); + + /** + * Check if component can be deactivated (navigation guard) + */ + canDeactivate(): boolean { + return !this.notificationForm.dirty; + } + + constructor() { + // Initialize the notification settings form + this.notificationForm = this.formBuilder.group({ + // Notifiarr configuration + notifiarr: this.formBuilder.group({ + apiKey: [''], + channelId: [''], + onFailedImportStrike: [false], + onStalledStrike: [false], + onSlowStrike: [false], + onQueueItemDeleted: [false], + onDownloadCleaned: [false], + onCategoryChanged: [false], + }), + // Apprise configuration + apprise: this.formBuilder.group({ + url: [''], + key: [''], + onFailedImportStrike: [false], + onStalledStrike: [false], + onSlowStrike: [false], + onQueueItemDeleted: [false], + onDownloadCleaned: [false], + onCategoryChanged: [false], + }), + }); + + // Setup effect to react to config changes + effect(() => { + const config = this.notificationConfig(); + if (config) { + // Map the server response to form values + const formValue = { + notifiarr: config.notifiarr || { + apiKey: '', + channelId: '', + onFailedImportStrike: false, + onStalledStrike: false, + onSlowStrike: false, + onQueueItemDeleted: false, + onDownloadCleaned: false, + onCategoryChanged: false, + }, + apprise: config.apprise || { + url: '', + key: '', + onFailedImportStrike: false, + onStalledStrike: false, + onSlowStrike: false, + onQueueItemDeleted: false, + onDownloadCleaned: false, + onCategoryChanged: false, + }, + }; + + this.notificationForm.patchValue(formValue); + this.storeOriginalValues(); + this.notificationForm.markAsPristine(); + this.hasActualChanges = false; + } + }); + + // Track form changes for dirty state + this.notificationForm.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.hasActualChanges = this.formValuesChanged(); + }); + + // Setup effect to react to error changes + effect(() => { + const errorMessage = this.notificationError(); + if (errorMessage) { + // Only emit the error for parent components + this.error.emit(errorMessage); + } + }); + } + + /** + * Clean up subscriptions when component is destroyed + */ + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Check if the current form values are different from the original values + */ + private formValuesChanged(): boolean { + return !this.isEqual(this.notificationForm.value, this.originalFormValues); + } + + /** + * Deep compare two objects for equality + */ + private isEqual(obj1: any, obj2: any): boolean { + if (obj1 === obj2) return true; + if (obj1 === null || obj2 === null) return false; + if (obj1 === undefined || obj2 === undefined) return false; + + if (typeof obj1 !== 'object' && typeof obj2 !== 'object') { + return obj1 === obj2; + } + + if (Array.isArray(obj1) && Array.isArray(obj2)) { + if (obj1.length !== obj2.length) return false; + for (let i = 0; i < obj1.length; i++) { + if (!this.isEqual(obj1[i], obj2[i])) return false; + } + return true; + } + + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) return false; + + for (const key of keys1) { + if (!this.isEqual(obj1[key], obj2[key])) return false; + } + + return true; + } + + /** + * Store original form values for dirty checking + */ + private storeOriginalValues(): void { + this.originalFormValues = JSON.parse(JSON.stringify(this.notificationForm.value)); + } + + /** + * Save the notification configuration + */ + saveNotificationConfig(): void { + if (this.notificationForm.invalid) { + this.markFormGroupTouched(this.notificationForm); + this.notificationService.showValidationError(); + return; + } + + if (!this.hasActualChanges) { + this.notificationService.showSuccess('No changes detected'); + return; + } + + const formValues = this.notificationForm.value; + + const config: NotificationsConfig = { + notifiarr: formValues.notifiarr, + apprise: formValues.apprise, + }; + + // Save the configuration + this.notificationConfigStore.saveConfig(config); + + // Setup a one-time check to mark form as pristine after successful save + const checkSaveCompletion = () => { + const loading = this.notificationSaving(); + const error = this.notificationError(); + + if (!loading && !error) { + // Mark form as pristine after successful save + this.notificationForm.markAsPristine(); + this.hasActualChanges = false; + + // Emit saved event + this.saved.emit(); + // Show success message + this.notificationService.showSuccess('Notification configuration saved successfully!'); + } else if (!loading && error) { + // If there's an error, we can stop checking + } else { + // If still loading, check again in a moment + setTimeout(checkSaveCompletion, 100); + } + }; + + // Start checking for save completion + checkSaveCompletion(); + } + + /** + * Reset the notification configuration form to default values + */ + resetNotificationConfig(): void { + this.notificationForm.reset({ + notifiarr: { + apiKey: '', + channelId: '', + onFailedImportStrike: false, + onStalledStrike: false, + onSlowStrike: false, + onQueueItemDeleted: false, + onDownloadCleaned: false, + onCategoryChanged: false, + }, + apprise: { + url: '', + key: '', + onFailedImportStrike: false, + onStalledStrike: false, + onSlowStrike: false, + onQueueItemDeleted: false, + onDownloadCleaned: false, + onCategoryChanged: false, + }, + }); + + // Check if this reset actually changes anything compared to the original state + const hasChangesAfterReset = this.formValuesChanged(); + + if (hasChangesAfterReset) { + // Only mark as dirty if the reset actually changes something + this.notificationForm.markAsDirty(); + this.hasActualChanges = true; + } else { + // If reset brings us back to original state, mark as pristine + this.notificationForm.markAsPristine(); + this.hasActualChanges = false; + } + } + + /** + * Mark all controls in a form group as touched + */ + private markFormGroupTouched(formGroup: FormGroup): void { + Object.values(formGroup.controls).forEach((control) => { + control.markAsTouched(); + + if ((control as any).controls) { + this.markFormGroupTouched(control as FormGroup); + } + }); + } + + /** + * Check if a form control has an error after it's been touched + */ + hasError(controlName: string, errorName: string): boolean { + const control = this.notificationForm.get(controlName); + return control ? control.touched && control.hasError(errorName) : false; + } + + /** + * Check if a nested form control has an error after it's been touched + */ + hasNestedError(groupName: string, controlName: string, errorName: string): boolean { + const control = this.notificationForm.get(`${groupName}.${controlName}`); + return control ? control.touched && control.hasError(errorName) : false; + } +} \ No newline at end of file diff --git a/code/UI/src/app/settings/settings-page/settings-page.component.html b/code/UI/src/app/settings/settings-page/settings-page.component.html index 0eae6c2c..faf1c6c6 100644 --- a/code/UI/src/app/settings/settings-page/settings-page.component.html +++ b/code/UI/src/app/settings/settings-page/settings-page.component.html @@ -20,4 +20,9 @@
+ + +
+ +
diff --git a/code/UI/src/app/settings/settings-page/settings-page.component.ts b/code/UI/src/app/settings/settings-page/settings-page.component.ts index f6e4bb70..72b40feb 100644 --- a/code/UI/src/app/settings/settings-page/settings-page.component.ts +++ b/code/UI/src/app/settings/settings-page/settings-page.component.ts @@ -20,6 +20,7 @@ import { SonarrSettingsComponent } from '../sonarr/sonarr-settings.component'; import { RadarrSettingsComponent } from "../radarr/radarr-settings.component"; import { LidarrSettingsComponent } from "../lidarr/lidarr-settings.component"; import { DownloadClientSettingsComponent } from "../download-client/download-client-settings.component"; +import { NotificationSettingsComponent } from "../notification-settings/notification-settings.component"; // Define interfaces for settings page interface LogLevel { @@ -50,7 +51,8 @@ interface Category { SonarrSettingsComponent, RadarrSettingsComponent, LidarrSettingsComponent, - DownloadClientSettingsComponent + DownloadClientSettingsComponent, + NotificationSettingsComponent ], providers: [MessageService, ConfirmationService], templateUrl: './settings-page.component.html', @@ -89,6 +91,7 @@ export class SettingsPageComponent implements OnInit, CanComponentDeactivate { @ViewChild(GeneralSettingsComponent) generalSettings!: GeneralSettingsComponent; @ViewChild(DownloadCleanerSettingsComponent) downloadCleanerSettings!: DownloadCleanerSettingsComponent; @ViewChild(SonarrSettingsComponent) sonarrSettings!: SonarrSettingsComponent; + @ViewChild(NotificationSettingsComponent) notificationSettings!: NotificationSettingsComponent; ngOnInit(): void { // Future implementation for other settings sections @@ -119,6 +122,11 @@ export class SettingsPageComponent implements OnInit, CanComponentDeactivate { return false; } + // Check if notification settings has unsaved changes + if (this.notificationSettings?.canDeactivate() === false) { + return false; + } + return true; } } diff --git a/code/UI/src/app/shared/models/apprise-config.model.ts b/code/UI/src/app/shared/models/apprise-config.model.ts new file mode 100644 index 00000000..d4901504 --- /dev/null +++ b/code/UI/src/app/shared/models/apprise-config.model.ts @@ -0,0 +1,6 @@ +import { NotificationConfig } from './notification-config.model'; + +export interface AppriseConfig extends NotificationConfig { + url?: string; + key?: string; +} \ No newline at end of file diff --git a/code/UI/src/app/shared/models/notifiarr-config.model.ts b/code/UI/src/app/shared/models/notifiarr-config.model.ts new file mode 100644 index 00000000..5453f0ab --- /dev/null +++ b/code/UI/src/app/shared/models/notifiarr-config.model.ts @@ -0,0 +1,6 @@ +import { NotificationConfig } from './notification-config.model'; + +export interface NotifiarrConfig extends NotificationConfig { + apiKey?: string; + channelId?: string; +} \ No newline at end of file diff --git a/code/UI/src/app/shared/models/notification-config.model.ts b/code/UI/src/app/shared/models/notification-config.model.ts new file mode 100644 index 00000000..0204d01d --- /dev/null +++ b/code/UI/src/app/shared/models/notification-config.model.ts @@ -0,0 +1,9 @@ +export interface NotificationConfig { + id?: string; + onFailedImportStrike: boolean; + onStalledStrike: boolean; + onSlowStrike: boolean; + onQueueItemDeleted: boolean; + onDownloadCleaned: boolean; + onCategoryChanged: boolean; +} \ No newline at end of file diff --git a/code/UI/src/app/shared/models/notifications-config.model.ts b/code/UI/src/app/shared/models/notifications-config.model.ts new file mode 100644 index 00000000..4c0d73dd --- /dev/null +++ b/code/UI/src/app/shared/models/notifications-config.model.ts @@ -0,0 +1,7 @@ +import { NotifiarrConfig } from './notifiarr-config.model'; +import { AppriseConfig } from './apprise-config.model'; + +export interface NotificationsConfig { + notifiarr?: NotifiarrConfig; + apprise?: AppriseConfig; +} \ No newline at end of file