added notifications endpoint

This commit is contained in:
Flaminel
2025-06-18 17:48:50 +03:00
parent 4d8d3ea732
commit bbfde4bb17
12 changed files with 714 additions and 8 deletions

View File

@@ -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<IActionResult> 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<IActionResult> 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<NotifiarrConfig, NotifiarrConfig>()
.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<AppriseConfig, AppriseConfig>()
.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
{

View File

@@ -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

View File

@@ -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<NotificationsConfig | null>(null);
private _loading = signal<boolean>(false);
private _saving = signal<boolean>(false);
private _error = signal<string | null>(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<NotificationsConfig>(`${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<any>(`${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();
}
}

View File

@@ -0,0 +1,168 @@
<!-- Toast notifications handled by central toast container -->
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Notification Configuration</h2>
<span class="card-subtitle">Configure notification settings for Notifiarr and Apprise</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-bell text-xl"></i>
</div>
</div>
</ng-template>
<div class="card-content">
<!-- Loading/Error Component -->
<app-loading-error-state
*ngIf="notificationLoading() || notificationError()"
[loading]="notificationLoading()"
[error]="notificationError()"
loadingMessage="Loading notification settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
<!-- Settings Form -->
<form *ngIf="!notificationLoading() && !notificationError()" [formGroup]="notificationForm" class="p-fluid">
<!-- Notifiarr Configuration Section -->
<div class="mb-4">
<h3 class="section-title">Notifiarr Configuration</h3>
<div formGroupName="notifiarr">
<!-- API Key -->
<div class="field-row">
<label class="field-label">API Key</label>
<div class="field-input">
<p-inputText formControlName="apiKey" inputId="notifiarrApiKey" placeholder="Enter Notifiarr API key"></p-inputText>
<small class="form-helper-text">Your Notifiarr API key for authentication</small>
</div>
</div>
<!-- Channel ID -->
<div class="field-row">
<label class="field-label">Channel ID</label>
<div class="field-input">
<p-inputText formControlName="channelId" inputId="notifiarrChannelId" placeholder="Enter channel ID"></p-inputText>
<small class="form-helper-text">The channel ID where notifications will be sent</small>
</div>
</div>
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">Event Triggers</label>
<div class="field-input">
<div class="flex flex-column gap-2">
<div class="flex align-items-center">
<p-checkbox formControlName="onFailedImportStrike" [binary]="true" inputId="notifiarrFailedImport"></p-checkbox>
<label for="notifiarrFailedImport" class="ml-2">Failed Import Strike</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onStalledStrike" [binary]="true" inputId="notifiarrStalled"></p-checkbox>
<label for="notifiarrStalled" class="ml-2">Stalled Strike</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onSlowStrike" [binary]="true" inputId="notifiarrSlow"></p-checkbox>
<label for="notifiarrSlow" class="ml-2">Slow Strike</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onQueueItemDeleted" [binary]="true" inputId="notifiarrDeleted"></p-checkbox>
<label for="notifiarrDeleted" class="ml-2">Queue Item Deleted</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onDownloadCleaned" [binary]="true" inputId="notifiarrCleaned"></p-checkbox>
<label for="notifiarrCleaned" class="ml-2">Download Cleaned</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onCategoryChanged" [binary]="true" inputId="notifiarrCategory"></p-checkbox>
<label for="notifiarrCategory" class="ml-2">Category Changed</label>
</div>
</div>
<small class="form-helper-text">Select which events should trigger Notifiarr notifications</small>
</div>
</div>
</div>
</div>
<!-- Apprise Configuration Section -->
<div class="mb-4">
<h3 class="section-title">Apprise Configuration</h3>
<div formGroupName="apprise">
<!-- URL -->
<div class="field-row">
<label class="field-label">URL</label>
<div class="field-input">
<p-inputText formControlName="url" inputId="appriseUrl" placeholder="Enter Apprise URL"></p-inputText>
<small class="form-helper-text">The Apprise server URL</small>
</div>
</div>
<!-- Key -->
<div class="field-row">
<label class="field-label">Key</label>
<div class="field-input">
<p-inputText formControlName="key" inputId="appriseKey" placeholder="Enter key"></p-inputText>
<small class="form-helper-text">The key for authentication</small>
</div>
</div>
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">Event Triggers</label>
<div class="field-input">
<div class="flex flex-column gap-2">
<div class="flex align-items-center">
<p-checkbox formControlName="onFailedImportStrike" [binary]="true" inputId="appriseFailedImport"></p-checkbox>
<label for="appriseFailedImport" class="ml-2">Failed Import Strike</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onStalledStrike" [binary]="true" inputId="appriseStalled"></p-checkbox>
<label for="appriseStalled" class="ml-2">Stalled Strike</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onSlowStrike" [binary]="true" inputId="appriseSlow"></p-checkbox>
<label for="appriseSlow" class="ml-2">Slow Strike</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onQueueItemDeleted" [binary]="true" inputId="appriseDeleted"></p-checkbox>
<label for="appriseDeleted" class="ml-2">Queue Item Deleted</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onDownloadCleaned" [binary]="true" inputId="appriseCleaned"></p-checkbox>
<label for="appriseCleaned" class="ml-2">Download Cleaned</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onCategoryChanged" [binary]="true" inputId="appriseCategory"></p-checkbox>
<label for="appriseCategory" class="ml-2">Category Changed</label>
</div>
</div>
<small class="form-helper-text">Select which events should trigger Apprise notifications</small>
</div>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="(!notificationForm.dirty || !hasActualChanges) || notificationForm.invalid || notificationSaving()"
[loading]="notificationSaving()"
(click)="saveNotificationConfig()"
></button>
<button
pButton
type="button"
label="Reset"
icon="pi pi-refresh"
class="p-button-secondary p-button-outlined ml-2"
(click)="resetNotificationConfig()"
></button>
</div>
</form>
</div>
</p-card>

View File

@@ -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;
}

View File

@@ -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<void>();
@Output() error = new EventEmitter<string>();
// 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<void>();
/**
* 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;
}
}

View File

@@ -20,4 +20,9 @@
<div class="mb-4">
<app-download-cleaner-settings></app-download-cleaner-settings>
</div>
<!-- Notification Settings Component -->
<div class="mb-4">
<app-notification-settings></app-notification-settings>
</div>
</div>

View File

@@ -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;
}
}

View File

@@ -0,0 +1,6 @@
import { NotificationConfig } from './notification-config.model';
export interface AppriseConfig extends NotificationConfig {
url?: string;
key?: string;
}

View File

@@ -0,0 +1,6 @@
import { NotificationConfig } from './notification-config.model';
export interface NotifiarrConfig extends NotificationConfig {
apiKey?: string;
channelId?: string;
}

View File

@@ -0,0 +1,9 @@
export interface NotificationConfig {
id?: string;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
}

View File

@@ -0,0 +1,7 @@
import { NotifiarrConfig } from './notifiarr-config.model';
import { AppriseConfig } from './apprise-config.model';
export interface NotificationsConfig {
notifiarr?: NotifiarrConfig;
apprise?: AppriseConfig;
}