diff --git a/i18n/en.json b/i18n/en.json index eafb3415d58..8404d6d1d0f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -857,6 +857,7 @@ "failed_to_remove_product_key": "Failed to remove product key", "failed_to_stack_assets": "Failed to stack assets", "failed_to_unstack_assets": "Failed to un-stack assets", + "failed_to_update_notification_status": "Failed to update notification status", "import_path_already_exists": "This import path already exists.", "incorrect_email_or_password": "Incorrect email or password", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", @@ -1199,6 +1200,9 @@ "map_settings_only_show_favorites": "Show Favorite Only", "map_settings_theme_settings": "Map Theme", "map_zoom_to_see_photos": "Zoom out to see photos", + "mark_as_read": "Mark as read", + "mark_all_as_read": "Mark all as read", + "marked_all_as_read": "Marked all as read", "matches": "Matches", "media_type": "Media type", "memories": "Memories", @@ -1260,6 +1264,7 @@ "no_places": "No places", "no_results": "No results", "no_results_description": "Try a synonym or more general keyword", + "no_notifications": "No notifications", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", "not_in_any_album": "Not in any album", "not_selected": "Not selected", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5a7a42cce59..b8ea4b924c1 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -145,8 +145,15 @@ Class | Method | HTTP request | Description *MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | *MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | *MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | -*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /notifications/admin/templates/{name} | -*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /notifications/admin/test-email | +*NotificationsApi* | [**deleteNotification**](doc//NotificationsApi.md#deletenotification) | **DELETE** /notifications/{id} | +*NotificationsApi* | [**deleteNotifications**](doc//NotificationsApi.md#deletenotifications) | **DELETE** /notifications | +*NotificationsApi* | [**getNotification**](doc//NotificationsApi.md#getnotification) | **GET** /notifications/{id} | +*NotificationsApi* | [**getNotifications**](doc//NotificationsApi.md#getnotifications) | **GET** /notifications | +*NotificationsApi* | [**updateNotification**](doc//NotificationsApi.md#updatenotification) | **PUT** /notifications/{id} | +*NotificationsApi* | [**updateNotifications**](doc//NotificationsApi.md#updatenotifications) | **PUT** /notifications | +*NotificationsAdminApi* | [**createNotification**](doc//NotificationsAdminApi.md#createnotification) | **POST** /admin/notifications | +*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /admin/notifications/templates/{name} | +*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /admin/notifications/test-email | *OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback | *OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link | *OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect | @@ -360,6 +367,13 @@ Class | Method | HTTP request | Description - [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MergePersonDto](doc//MergePersonDto.md) - [MetadataSearchDto](doc//MetadataSearchDto.md) + - [NotificationCreateDto](doc//NotificationCreateDto.md) + - [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md) + - [NotificationDto](doc//NotificationDto.md) + - [NotificationLevel](doc//NotificationLevel.md) + - [NotificationType](doc//NotificationType.md) + - [NotificationUpdateAllDto](doc//NotificationUpdateAllDto.md) + - [NotificationUpdateDto](doc//NotificationUpdateDto.md) - [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md) - [OAuthCallbackDto](doc//OAuthCallbackDto.md) - [OAuthConfigDto](doc//OAuthConfigDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d08f9fda38c..e845099bd2a 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -44,6 +44,7 @@ part 'api/jobs_api.dart'; part 'api/libraries_api.dart'; part 'api/map_api.dart'; part 'api/memories_api.dart'; +part 'api/notifications_api.dart'; part 'api/notifications_admin_api.dart'; part 'api/o_auth_api.dart'; part 'api/partners_api.dart'; @@ -167,6 +168,13 @@ part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; part 'model/merge_person_dto.dart'; part 'model/metadata_search_dto.dart'; +part 'model/notification_create_dto.dart'; +part 'model/notification_delete_all_dto.dart'; +part 'model/notification_dto.dart'; +part 'model/notification_level.dart'; +part 'model/notification_type.dart'; +part 'model/notification_update_all_dto.dart'; +part 'model/notification_update_dto.dart'; part 'model/o_auth_authorize_response_dto.dart'; part 'model/o_auth_callback_dto.dart'; part 'model/o_auth_config_dto.dart'; diff --git a/mobile/openapi/lib/api/notifications_admin_api.dart b/mobile/openapi/lib/api/notifications_admin_api.dart index c58bf8978db..409683a950a 100644 --- a/mobile/openapi/lib/api/notifications_admin_api.dart +++ b/mobile/openapi/lib/api/notifications_admin_api.dart @@ -16,7 +16,54 @@ class NotificationsAdminApi { final ApiClient apiClient; - /// Performs an HTTP 'POST /notifications/admin/templates/{name}' operation and returns the [Response]. + /// Performs an HTTP 'POST /admin/notifications' operation and returns the [Response]. + /// Parameters: + /// + /// * [NotificationCreateDto] notificationCreateDto (required): + Future createNotificationWithHttpInfo(NotificationCreateDto notificationCreateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/notifications'; + + // ignore: prefer_final_locals + Object? postBody = notificationCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [NotificationCreateDto] notificationCreateDto (required): + Future createNotification(NotificationCreateDto notificationCreateDto,) async { + final response = await createNotificationWithHttpInfo(notificationCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto; + + } + return null; + } + + /// Performs an HTTP 'POST /admin/notifications/templates/{name}' operation and returns the [Response]. /// Parameters: /// /// * [String] name (required): @@ -24,7 +71,7 @@ class NotificationsAdminApi { /// * [TemplateDto] templateDto (required): Future getNotificationTemplateAdminWithHttpInfo(String name, TemplateDto templateDto,) async { // ignore: prefer_const_declarations - final apiPath = r'/notifications/admin/templates/{name}' + final apiPath = r'/admin/notifications/templates/{name}' .replaceAll('{name}', name); // ignore: prefer_final_locals @@ -68,13 +115,13 @@ class NotificationsAdminApi { return null; } - /// Performs an HTTP 'POST /notifications/admin/test-email' operation and returns the [Response]. + /// Performs an HTTP 'POST /admin/notifications/test-email' operation and returns the [Response]. /// Parameters: /// /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): Future sendTestEmailAdminWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async { // ignore: prefer_const_declarations - final apiPath = r'/notifications/admin/test-email'; + final apiPath = r'/admin/notifications/test-email'; // ignore: prefer_final_locals Object? postBody = systemConfigSmtpDto; diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart new file mode 100644 index 00000000000..501cc70a295 --- /dev/null +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -0,0 +1,311 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class NotificationsApi { + NotificationsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'DELETE /notifications/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future deleteNotificationWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future deleteNotification(String id,) async { + final response = await deleteNotificationWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'DELETE /notifications' operation and returns the [Response]. + /// Parameters: + /// + /// * [NotificationDeleteAllDto] notificationDeleteAllDto (required): + Future deleteNotificationsWithHttpInfo(NotificationDeleteAllDto notificationDeleteAllDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications'; + + // ignore: prefer_final_locals + Object? postBody = notificationDeleteAllDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [NotificationDeleteAllDto] notificationDeleteAllDto (required): + Future deleteNotifications(NotificationDeleteAllDto notificationDeleteAllDto,) async { + final response = await deleteNotificationsWithHttpInfo(notificationDeleteAllDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'GET /notifications/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future getNotificationWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future getNotification(String id,) async { + final response = await getNotificationWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto; + + } + return null; + } + + /// Performs an HTTP 'GET /notifications' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id: + /// + /// * [NotificationLevel] level: + /// + /// * [NotificationType] type: + /// + /// * [bool] unread: + Future getNotificationsWithHttpInfo({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (id != null) { + queryParams.addAll(_queryParams('', 'id', id)); + } + if (level != null) { + queryParams.addAll(_queryParams('', 'level', level)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + if (unread != null) { + queryParams.addAll(_queryParams('', 'unread', unread)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id: + /// + /// * [NotificationLevel] level: + /// + /// * [NotificationType] type: + /// + /// * [bool] unread: + Future?> getNotifications({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async { + final response = await getNotificationsWithHttpInfo( id: id, level: level, type: type, unread: unread, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Performs an HTTP 'PUT /notifications/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [NotificationUpdateDto] notificationUpdateDto (required): + Future updateNotificationWithHttpInfo(String id, NotificationUpdateDto notificationUpdateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = notificationUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [NotificationUpdateDto] notificationUpdateDto (required): + Future updateNotification(String id, NotificationUpdateDto notificationUpdateDto,) async { + final response = await updateNotificationWithHttpInfo(id, notificationUpdateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto; + + } + return null; + } + + /// Performs an HTTP 'PUT /notifications' operation and returns the [Response]. + /// Parameters: + /// + /// * [NotificationUpdateAllDto] notificationUpdateAllDto (required): + Future updateNotificationsWithHttpInfo(NotificationUpdateAllDto notificationUpdateAllDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications'; + + // ignore: prefer_final_locals + Object? postBody = notificationUpdateAllDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [NotificationUpdateAllDto] notificationUpdateAllDto (required): + Future updateNotifications(NotificationUpdateAllDto notificationUpdateAllDto,) async { + final response = await updateNotificationsWithHttpInfo(notificationUpdateAllDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 0d8e4c6ba97..7586cc1ae2a 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -390,6 +390,20 @@ class ApiClient { return MergePersonDto.fromJson(value); case 'MetadataSearchDto': return MetadataSearchDto.fromJson(value); + case 'NotificationCreateDto': + return NotificationCreateDto.fromJson(value); + case 'NotificationDeleteAllDto': + return NotificationDeleteAllDto.fromJson(value); + case 'NotificationDto': + return NotificationDto.fromJson(value); + case 'NotificationLevel': + return NotificationLevelTypeTransformer().decode(value); + case 'NotificationType': + return NotificationTypeTypeTransformer().decode(value); + case 'NotificationUpdateAllDto': + return NotificationUpdateAllDto.fromJson(value); + case 'NotificationUpdateDto': + return NotificationUpdateDto.fromJson(value); case 'OAuthAuthorizeResponseDto': return OAuthAuthorizeResponseDto.fromJson(value); case 'OAuthCallbackDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 1ebf8314ad4..cc517d48ab5 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -100,6 +100,12 @@ String parameterToString(dynamic value) { if (value is MemoryType) { return MemoryTypeTypeTransformer().encode(value).toString(); } + if (value is NotificationLevel) { + return NotificationLevelTypeTransformer().encode(value).toString(); + } + if (value is NotificationType) { + return NotificationTypeTypeTransformer().encode(value).toString(); + } if (value is PartnerDirection) { return PartnerDirectionTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/notification_create_dto.dart b/mobile/openapi/lib/model/notification_create_dto.dart new file mode 100644 index 00000000000..07985353b2f --- /dev/null +++ b/mobile/openapi/lib/model/notification_create_dto.dart @@ -0,0 +1,180 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class NotificationCreateDto { + /// Returns a new [NotificationCreateDto] instance. + NotificationCreateDto({ + this.data, + this.description, + this.level, + this.readAt, + required this.title, + this.type, + required this.userId, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Object? data; + + String? description; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + NotificationLevel? level; + + DateTime? readAt; + + String title; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + NotificationType? type; + + String userId; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationCreateDto && + other.data == data && + other.description == description && + other.level == level && + other.readAt == readAt && + other.title == title && + other.type == type && + other.userId == userId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (data == null ? 0 : data!.hashCode) + + (description == null ? 0 : description!.hashCode) + + (level == null ? 0 : level!.hashCode) + + (readAt == null ? 0 : readAt!.hashCode) + + (title.hashCode) + + (type == null ? 0 : type!.hashCode) + + (userId.hashCode); + + @override + String toString() => 'NotificationCreateDto[data=$data, description=$description, level=$level, readAt=$readAt, title=$title, type=$type, userId=$userId]'; + + Map toJson() { + final json = {}; + if (this.data != null) { + json[r'data'] = this.data; + } else { + // json[r'data'] = null; + } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + if (this.level != null) { + json[r'level'] = this.level; + } else { + // json[r'level'] = null; + } + if (this.readAt != null) { + json[r'readAt'] = this.readAt!.toUtc().toIso8601String(); + } else { + // json[r'readAt'] = null; + } + json[r'title'] = this.title; + if (this.type != null) { + json[r'type'] = this.type; + } else { + // json[r'type'] = null; + } + json[r'userId'] = this.userId; + return json; + } + + /// Returns a new [NotificationCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationCreateDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationCreateDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationCreateDto( + data: mapValueOfType(json, r'data'), + description: mapValueOfType(json, r'description'), + level: NotificationLevel.fromJson(json[r'level']), + readAt: mapDateTime(json, r'readAt', r''), + title: mapValueOfType(json, r'title')!, + type: NotificationType.fromJson(json[r'type']), + userId: mapValueOfType(json, r'userId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = NotificationCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = NotificationCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'title', + 'userId', + }; +} + diff --git a/mobile/openapi/lib/model/notification_delete_all_dto.dart b/mobile/openapi/lib/model/notification_delete_all_dto.dart new file mode 100644 index 00000000000..4be1b89e920 --- /dev/null +++ b/mobile/openapi/lib/model/notification_delete_all_dto.dart @@ -0,0 +1,101 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class NotificationDeleteAllDto { + /// Returns a new [NotificationDeleteAllDto] instance. + NotificationDeleteAllDto({ + this.ids = const [], + }); + + List ids; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationDeleteAllDto && + _deepEquality.equals(other.ids, ids); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (ids.hashCode); + + @override + String toString() => 'NotificationDeleteAllDto[ids=$ids]'; + + Map toJson() { + final json = {}; + json[r'ids'] = this.ids; + return json; + } + + /// Returns a new [NotificationDeleteAllDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationDeleteAllDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationDeleteAllDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationDeleteAllDto( + ids: json[r'ids'] is Iterable + ? (json[r'ids'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationDeleteAllDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = NotificationDeleteAllDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationDeleteAllDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = NotificationDeleteAllDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'ids', + }; +} + diff --git a/mobile/openapi/lib/model/notification_dto.dart b/mobile/openapi/lib/model/notification_dto.dart new file mode 100644 index 00000000000..4f730b4e50d --- /dev/null +++ b/mobile/openapi/lib/model/notification_dto.dart @@ -0,0 +1,182 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class NotificationDto { + /// Returns a new [NotificationDto] instance. + NotificationDto({ + required this.createdAt, + this.data, + this.description, + required this.id, + required this.level, + this.readAt, + required this.title, + required this.type, + }); + + DateTime createdAt; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Object? data; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? description; + + String id; + + NotificationLevel level; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? readAt; + + String title; + + NotificationType type; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationDto && + other.createdAt == createdAt && + other.data == data && + other.description == description && + other.id == id && + other.level == level && + other.readAt == readAt && + other.title == title && + other.type == type; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (createdAt.hashCode) + + (data == null ? 0 : data!.hashCode) + + (description == null ? 0 : description!.hashCode) + + (id.hashCode) + + (level.hashCode) + + (readAt == null ? 0 : readAt!.hashCode) + + (title.hashCode) + + (type.hashCode); + + @override + String toString() => 'NotificationDto[createdAt=$createdAt, data=$data, description=$description, id=$id, level=$level, readAt=$readAt, title=$title, type=$type]'; + + Map toJson() { + final json = {}; + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + if (this.data != null) { + json[r'data'] = this.data; + } else { + // json[r'data'] = null; + } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + json[r'id'] = this.id; + json[r'level'] = this.level; + if (this.readAt != null) { + json[r'readAt'] = this.readAt!.toUtc().toIso8601String(); + } else { + // json[r'readAt'] = null; + } + json[r'title'] = this.title; + json[r'type'] = this.type; + return json; + } + + /// Returns a new [NotificationDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationDto( + createdAt: mapDateTime(json, r'createdAt', r'')!, + data: mapValueOfType(json, r'data'), + description: mapValueOfType(json, r'description'), + id: mapValueOfType(json, r'id')!, + level: NotificationLevel.fromJson(json[r'level'])!, + readAt: mapDateTime(json, r'readAt', r''), + title: mapValueOfType(json, r'title')!, + type: NotificationType.fromJson(json[r'type'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = NotificationDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = NotificationDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'createdAt', + 'id', + 'level', + 'title', + 'type', + }; +} + diff --git a/mobile/openapi/lib/model/notification_level.dart b/mobile/openapi/lib/model/notification_level.dart new file mode 100644 index 00000000000..554863ae4fb --- /dev/null +++ b/mobile/openapi/lib/model/notification_level.dart @@ -0,0 +1,91 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class NotificationLevel { + /// Instantiate a new enum with the provided [value]. + const NotificationLevel._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const success = NotificationLevel._(r'success'); + static const error = NotificationLevel._(r'error'); + static const warning = NotificationLevel._(r'warning'); + static const info = NotificationLevel._(r'info'); + + /// List of all possible values in this [enum][NotificationLevel]. + static const values = [ + success, + error, + warning, + info, + ]; + + static NotificationLevel? fromJson(dynamic value) => NotificationLevelTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationLevel.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [NotificationLevel] to String, +/// and [decode] dynamic data back to [NotificationLevel]. +class NotificationLevelTypeTransformer { + factory NotificationLevelTypeTransformer() => _instance ??= const NotificationLevelTypeTransformer._(); + + const NotificationLevelTypeTransformer._(); + + String encode(NotificationLevel data) => data.value; + + /// Decodes a [dynamic value][data] to a NotificationLevel. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + NotificationLevel? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'success': return NotificationLevel.success; + case r'error': return NotificationLevel.error; + case r'warning': return NotificationLevel.warning; + case r'info': return NotificationLevel.info; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [NotificationLevelTypeTransformer] instance. + static NotificationLevelTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/notification_type.dart b/mobile/openapi/lib/model/notification_type.dart new file mode 100644 index 00000000000..436d2d190fd --- /dev/null +++ b/mobile/openapi/lib/model/notification_type.dart @@ -0,0 +1,91 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class NotificationType { + /// Instantiate a new enum with the provided [value]. + const NotificationType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const jobFailed = NotificationType._(r'JobFailed'); + static const backupFailed = NotificationType._(r'BackupFailed'); + static const systemMessage = NotificationType._(r'SystemMessage'); + static const custom = NotificationType._(r'Custom'); + + /// List of all possible values in this [enum][NotificationType]. + static const values = [ + jobFailed, + backupFailed, + systemMessage, + custom, + ]; + + static NotificationType? fromJson(dynamic value) => NotificationTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [NotificationType] to String, +/// and [decode] dynamic data back to [NotificationType]. +class NotificationTypeTypeTransformer { + factory NotificationTypeTypeTransformer() => _instance ??= const NotificationTypeTypeTransformer._(); + + const NotificationTypeTypeTransformer._(); + + String encode(NotificationType data) => data.value; + + /// Decodes a [dynamic value][data] to a NotificationType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + NotificationType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'JobFailed': return NotificationType.jobFailed; + case r'BackupFailed': return NotificationType.backupFailed; + case r'SystemMessage': return NotificationType.systemMessage; + case r'Custom': return NotificationType.custom; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [NotificationTypeTypeTransformer] instance. + static NotificationTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/notification_update_all_dto.dart b/mobile/openapi/lib/model/notification_update_all_dto.dart new file mode 100644 index 00000000000..a6393b275a5 --- /dev/null +++ b/mobile/openapi/lib/model/notification_update_all_dto.dart @@ -0,0 +1,112 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class NotificationUpdateAllDto { + /// Returns a new [NotificationUpdateAllDto] instance. + NotificationUpdateAllDto({ + this.ids = const [], + this.readAt, + }); + + List ids; + + DateTime? readAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationUpdateAllDto && + _deepEquality.equals(other.ids, ids) && + other.readAt == readAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (ids.hashCode) + + (readAt == null ? 0 : readAt!.hashCode); + + @override + String toString() => 'NotificationUpdateAllDto[ids=$ids, readAt=$readAt]'; + + Map toJson() { + final json = {}; + json[r'ids'] = this.ids; + if (this.readAt != null) { + json[r'readAt'] = this.readAt!.toUtc().toIso8601String(); + } else { + // json[r'readAt'] = null; + } + return json; + } + + /// Returns a new [NotificationUpdateAllDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationUpdateAllDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationUpdateAllDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationUpdateAllDto( + ids: json[r'ids'] is Iterable + ? (json[r'ids'] as Iterable).cast().toList(growable: false) + : const [], + readAt: mapDateTime(json, r'readAt', r''), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationUpdateAllDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = NotificationUpdateAllDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationUpdateAllDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = NotificationUpdateAllDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'ids', + }; +} + diff --git a/mobile/openapi/lib/model/notification_update_dto.dart b/mobile/openapi/lib/model/notification_update_dto.dart new file mode 100644 index 00000000000..e76496eb975 --- /dev/null +++ b/mobile/openapi/lib/model/notification_update_dto.dart @@ -0,0 +1,102 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class NotificationUpdateDto { + /// Returns a new [NotificationUpdateDto] instance. + NotificationUpdateDto({ + this.readAt, + }); + + DateTime? readAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationUpdateDto && + other.readAt == readAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (readAt == null ? 0 : readAt!.hashCode); + + @override + String toString() => 'NotificationUpdateDto[readAt=$readAt]'; + + Map toJson() { + final json = {}; + if (this.readAt != null) { + json[r'readAt'] = this.readAt!.toUtc().toIso8601String(); + } else { + // json[r'readAt'] = null; + } + return json; + } + + /// Returns a new [NotificationUpdateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationUpdateDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationUpdateDto( + readAt: mapDateTime(json, r'readAt', r''), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationUpdateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = NotificationUpdateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationUpdateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = NotificationUpdateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 1244a434b6e..1735bc2eb5c 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -66,6 +66,10 @@ class Permission { static const memoryPeriodRead = Permission._(r'memory.read'); static const memoryPeriodUpdate = Permission._(r'memory.update'); static const memoryPeriodDelete = Permission._(r'memory.delete'); + static const notificationPeriodCreate = Permission._(r'notification.create'); + static const notificationPeriodRead = Permission._(r'notification.read'); + static const notificationPeriodUpdate = Permission._(r'notification.update'); + static const notificationPeriodDelete = Permission._(r'notification.delete'); static const partnerPeriodCreate = Permission._(r'partner.create'); static const partnerPeriodRead = Permission._(r'partner.read'); static const partnerPeriodUpdate = Permission._(r'partner.update'); @@ -147,6 +151,10 @@ class Permission { memoryPeriodRead, memoryPeriodUpdate, memoryPeriodDelete, + notificationPeriodCreate, + notificationPeriodRead, + notificationPeriodUpdate, + notificationPeriodDelete, partnerPeriodCreate, partnerPeriodRead, partnerPeriodUpdate, @@ -263,6 +271,10 @@ class PermissionTypeTransformer { case r'memory.read': return Permission.memoryPeriodRead; case r'memory.update': return Permission.memoryPeriodUpdate; case r'memory.delete': return Permission.memoryPeriodDelete; + case r'notification.create': return Permission.notificationPeriodCreate; + case r'notification.read': return Permission.notificationPeriodRead; + case r'notification.update': return Permission.notificationPeriodUpdate; + case r'notification.delete': return Permission.notificationPeriodDelete; case r'partner.create': return Permission.partnerPeriodCreate; case r'partner.read': return Permission.partnerPeriodRead; case r'partner.update': return Permission.partnerPeriodUpdate; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1471020cd4d..f4ec9293736 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -206,6 +206,141 @@ ] } }, + "/admin/notifications": { + "post": { + "operationId": "createNotification", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications (Admin)" + ] + } + }, + "/admin/notifications/templates/{name}": { + "post": { + "operationId": "getNotificationTemplateAdmin", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications (Admin)" + ] + } + }, + "/admin/notifications/test-email": { + "post": { + "operationId": "sendTestEmailAdmin", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemConfigSmtpDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestEmailResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications (Admin)" + ] + } + }, "/admin/users": { "get": { "operationId": "searchUsersAdmin", @@ -3485,15 +3620,224 @@ ] } }, - "/notifications/admin/templates/{name}": { - "post": { - "operationId": "getNotificationTemplateAdmin", + "/notifications": { + "delete": { + "operationId": "deleteNotifications", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDeleteAllDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "get": { + "operationId": "getNotifications", "parameters": [ { - "name": "name", + "name": "id", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "level", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/NotificationLevel" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/NotificationType" + } + }, + { + "name": "unread", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/NotificationDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "put": { + "operationId": "updateNotifications", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationUpdateAllDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + } + }, + "/notifications/{id}": { + "delete": { + "operationId": "deleteNotification", + "parameters": [ + { + "name": "id", "required": true, "in": "path", "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "get": { + "operationId": "getNotification", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "put": { + "operationId": "updateNotification", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", "type": "string" } } @@ -3502,7 +3846,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateDto" + "$ref": "#/components/schemas/NotificationUpdateDto" } } }, @@ -3513,7 +3857,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateResponseDto" + "$ref": "#/components/schemas/NotificationDto" } } }, @@ -3532,49 +3876,7 @@ } ], "tags": [ - "Notifications (Admin)" - ] - } - }, - "/notifications/admin/test-email": { - "post": { - "operationId": "sendTestEmailAdmin", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SystemConfigSmtpDto" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TestEmailResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Notifications (Admin)" + "Notifications" ] } }, @@ -10326,6 +10628,157 @@ }, "type": "object" }, + "NotificationCreateDto": { + "properties": { + "data": { + "type": "object" + }, + "description": { + "nullable": true, + "type": "string" + }, + "level": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationLevel" + } + ] + }, + "readAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationType" + } + ] + }, + "userId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "title", + "userId" + ], + "type": "object" + }, + "NotificationDeleteAllDto": { + "properties": { + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, + "NotificationDto": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "data": { + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "level": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationLevel" + } + ] + }, + "readAt": { + "format": "date-time", + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationType" + } + ] + } + }, + "required": [ + "createdAt", + "id", + "level", + "title", + "type" + ], + "type": "object" + }, + "NotificationLevel": { + "enum": [ + "success", + "error", + "warning", + "info" + ], + "type": "string" + }, + "NotificationType": { + "enum": [ + "JobFailed", + "BackupFailed", + "SystemMessage", + "Custom" + ], + "type": "string" + }, + "NotificationUpdateAllDto": { + "properties": { + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "readAt": { + "format": "date-time", + "nullable": true, + "type": "string" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, + "NotificationUpdateDto": { + "properties": { + "readAt": { + "format": "date-time", + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, "OAuthAuthorizeResponseDto": { "properties": { "url": { @@ -10600,6 +11053,10 @@ "memory.read", "memory.update", "memory.delete", + "notification.create", + "notification.read", + "notification.update", + "notification.delete", "partner.create", "partner.read", "partner.update", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 1ba4d3e231a..647c5c4ada8 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -39,6 +39,48 @@ export type ActivityCreateDto = { export type ActivityStatisticsResponseDto = { comments: number; }; +export type NotificationCreateDto = { + data?: object; + description?: string | null; + level?: NotificationLevel; + readAt?: string | null; + title: string; + "type"?: NotificationType; + userId: string; +}; +export type NotificationDto = { + createdAt: string; + data?: object; + description?: string; + id: string; + level: NotificationLevel; + readAt?: string; + title: string; + "type": NotificationType; +}; +export type TemplateDto = { + template: string; +}; +export type TemplateResponseDto = { + html: string; + name: string; +}; +export type SystemConfigSmtpTransportDto = { + host: string; + ignoreCert: boolean; + password: string; + port: number; + username: string; +}; +export type SystemConfigSmtpDto = { + enabled: boolean; + "from": string; + replyTo: string; + transport: SystemConfigSmtpTransportDto; +}; +export type TestEmailResponseDto = { + messageId: string; +}; export type UserLicense = { activatedAt: string; activationKey: string; @@ -661,28 +703,15 @@ export type MemoryUpdateDto = { memoryAt?: string; seenAt?: string; }; -export type TemplateDto = { - template: string; +export type NotificationDeleteAllDto = { + ids: string[]; }; -export type TemplateResponseDto = { - html: string; - name: string; +export type NotificationUpdateAllDto = { + ids: string[]; + readAt?: string | null; }; -export type SystemConfigSmtpTransportDto = { - host: string; - ignoreCert: boolean; - password: string; - port: number; - username: string; -}; -export type SystemConfigSmtpDto = { - enabled: boolean; - "from": string; - replyTo: string; - transport: SystemConfigSmtpTransportDto; -}; -export type TestEmailResponseDto = { - messageId: string; +export type NotificationUpdateDto = { + readAt?: string | null; }; export type OAuthConfigDto = { codeChallenge?: string; @@ -1453,6 +1482,43 @@ export function deleteActivity({ id }: { method: "DELETE" })); } +export function createNotification({ notificationCreateDto }: { + notificationCreateDto: NotificationCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: NotificationDto; + }>("/admin/notifications", oazapfts.json({ + ...opts, + method: "POST", + body: notificationCreateDto + }))); +} +export function getNotificationTemplateAdmin({ name, templateDto }: { + name: string; + templateDto: TemplateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TemplateResponseDto; + }>(`/admin/notifications/templates/${encodeURIComponent(name)}`, oazapfts.json({ + ...opts, + method: "POST", + body: templateDto + }))); +} +export function sendTestEmailAdmin({ systemConfigSmtpDto }: { + systemConfigSmtpDto: SystemConfigSmtpDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TestEmailResponseDto; + }>("/admin/notifications/test-email", oazapfts.json({ + ...opts, + method: "POST", + body: systemConfigSmtpDto + }))); +} export function searchUsersAdmin({ withDeleted }: { withDeleted?: boolean; }, opts?: Oazapfts.RequestOpts) { @@ -2321,29 +2387,71 @@ export function addMemoryAssets({ id, bulkIdsDto }: { body: bulkIdsDto }))); } -export function getNotificationTemplateAdmin({ name, templateDto }: { - name: string; - templateDto: TemplateDto; +export function deleteNotifications({ notificationDeleteAllDto }: { + notificationDeleteAllDto: NotificationDeleteAllDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: TemplateResponseDto; - }>(`/notifications/admin/templates/${encodeURIComponent(name)}`, oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({ ...opts, - method: "POST", - body: templateDto + method: "DELETE", + body: notificationDeleteAllDto }))); } -export function sendTestEmailAdmin({ systemConfigSmtpDto }: { - systemConfigSmtpDto: SystemConfigSmtpDto; +export function getNotifications({ id, level, $type, unread }: { + id?: string; + level?: NotificationLevel; + $type?: NotificationType; + unread?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: TestEmailResponseDto; - }>("/notifications/admin/test-email", oazapfts.json({ + data: NotificationDto[]; + }>(`/notifications${QS.query(QS.explode({ + id, + level, + "type": $type, + unread + }))}`, { + ...opts + })); +} +export function updateNotifications({ notificationUpdateAllDto }: { + notificationUpdateAllDto: NotificationUpdateAllDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({ ...opts, - method: "POST", - body: systemConfigSmtpDto + method: "PUT", + body: notificationUpdateAllDto + }))); +} +export function deleteNotification({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/notifications/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +export function getNotification({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: NotificationDto; + }>(`/notifications/${encodeURIComponent(id)}`, { + ...opts + })); +} +export function updateNotification({ id, notificationUpdateDto }: { + id: string; + notificationUpdateDto: NotificationUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: NotificationDto; + }>(`/notifications/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: notificationUpdateDto }))); } export function startOAuth({ oAuthConfigDto }: { @@ -3452,6 +3560,18 @@ export enum UserAvatarColor { Gray = "gray", Amber = "amber" } +export enum NotificationLevel { + Success = "success", + Error = "error", + Warning = "warning", + Info = "info" +} +export enum NotificationType { + JobFailed = "JobFailed", + BackupFailed = "BackupFailed", + SystemMessage = "SystemMessage", + Custom = "Custom" +} export enum UserStatus { Active = "active", Removing = "removing", @@ -3526,6 +3646,10 @@ export enum Permission { MemoryRead = "memory.read", MemoryUpdate = "memory.update", MemoryDelete = "memory.delete", + NotificationCreate = "notification.create", + NotificationRead = "notification.read", + NotificationUpdate = "notification.update", + NotificationDelete = "notification.delete", PartnerCreate = "partner.create", PartnerRead = "partner.read", PartnerUpdate = "partner.update", diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 0da0aac8b1d..e36793b3d73 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -14,6 +14,7 @@ import { LibraryController } from 'src/controllers/library.controller'; import { MapController } from 'src/controllers/map.controller'; import { MemoryController } from 'src/controllers/memory.controller'; import { NotificationAdminController } from 'src/controllers/notification-admin.controller'; +import { NotificationController } from 'src/controllers/notification.controller'; import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; @@ -47,6 +48,7 @@ export const controllers = [ LibraryController, MapController, MemoryController, + NotificationController, NotificationAdminController, OAuthController, PartnerController, diff --git a/server/src/controllers/notification-admin.controller.ts b/server/src/controllers/notification-admin.controller.ts index 937244fc564..9bac865bdf4 100644 --- a/server/src/controllers/notification-admin.controller.ts +++ b/server/src/controllers/notification-admin.controller.ts @@ -1,16 +1,28 @@ import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto'; +import { + NotificationCreateDto, + NotificationDto, + TemplateDto, + TemplateResponseDto, + TestEmailResponseDto, +} from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { EmailTemplate } from 'src/repositories/email.repository'; -import { NotificationService } from 'src/services/notification.service'; +import { NotificationAdminService } from 'src/services/notification-admin.service'; @ApiTags('Notifications (Admin)') -@Controller('notifications/admin') +@Controller('admin/notifications') export class NotificationAdminController { - constructor(private service: NotificationService) {} + constructor(private service: NotificationAdminService) {} + + @Post() + @Authenticated({ admin: true }) + createNotification(@Auth() auth: AuthDto, @Body() dto: NotificationCreateDto): Promise { + return this.service.create(auth, dto); + } @Post('test-email') @HttpCode(HttpStatus.OK) diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts new file mode 100644 index 00000000000..c64f7868502 --- /dev/null +++ b/server/src/controllers/notification.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + NotificationDeleteAllDto, + NotificationDto, + NotificationSearchDto, + NotificationUpdateAllDto, + NotificationUpdateDto, +} from 'src/dtos/notification.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { NotificationService } from 'src/services/notification.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Notifications') +@Controller('notifications') +export class NotificationController { + constructor(private service: NotificationService) {} + + @Get() + @Authenticated({ permission: Permission.NOTIFICATION_READ }) + getNotifications(@Auth() auth: AuthDto, @Query() dto: NotificationSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Put() + @Authenticated({ permission: Permission.NOTIFICATION_UPDATE }) + updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise { + return this.service.updateAll(auth, dto); + } + + @Delete() + @Authenticated({ permission: Permission.NOTIFICATION_DELETE }) + deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise { + return this.service.deleteAll(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.NOTIFICATION_READ }) + getNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ permission: Permission.NOTIFICATION_UPDATE }) + updateNotification( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: NotificationUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @Authenticated({ permission: Permission.NOTIFICATION_DELETE }) + deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } +} diff --git a/server/src/database.ts b/server/src/database.ts index 0dab61cbe04..a93873ef425 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -333,6 +333,7 @@ export const columns = { ], tag: ['tags.id', 'tags.value', 'tags.createdAt', 'tags.updatedAt', 'tags.color', 'tags.parentId'], apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], + notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'], syncAsset: [ 'id', 'ownerId', diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 4e9738ecec3..85be9d52084 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -11,6 +11,8 @@ import { AssetStatus, AssetType, MemoryType, + NotificationLevel, + NotificationType, Permission, SharedLinkType, SourceType, @@ -263,6 +265,21 @@ export interface Memories { updateId: Generated; } +export interface Notifications { + id: Generated; + createdAt: Generated; + updatedAt: Generated; + deletedAt: Timestamp | null; + updateId: Generated; + userId: string; + level: Generated; + type: NotificationType; + title: string; + description: string | null; + data: any | null; + readAt: Timestamp | null; +} + export interface MemoriesAssetsAssets { assetsId: string; memoriesId: string; @@ -463,6 +480,7 @@ export interface DB { memories: Memories; memories_assets_assets: MemoriesAssetsAssets; migrations: Migrations; + notifications: Notifications; move_history: MoveHistory; naturalearth_countries: NaturalearthCountries; partners_audit: PartnersAudit; diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts index c1a09c801c8..d9847cda172 100644 --- a/server/src/dtos/notification.dto.ts +++ b/server/src/dtos/notification.dto.ts @@ -1,4 +1,7 @@ -import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsString } from 'class-validator'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; export class TestEmailResponseDto { messageId!: string; @@ -11,3 +14,106 @@ export class TemplateDto { @IsString() template!: string; } + +export class NotificationDto { + id!: string; + @ValidateDate() + createdAt!: Date; + @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' }) + level!: NotificationLevel; + @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' }) + type!: NotificationType; + title!: string; + description?: string; + data?: any; + readAt?: Date; +} + +export class NotificationSearchDto { + @Optional() + @ValidateUUID({ optional: true }) + id?: string; + + @IsEnum(NotificationLevel) + @Optional() + @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' }) + level?: NotificationLevel; + + @IsEnum(NotificationType) + @Optional() + @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' }) + type?: NotificationType; + + @ValidateBoolean({ optional: true }) + unread?: boolean; +} + +export class NotificationCreateDto { + @Optional() + @IsEnum(NotificationLevel) + @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' }) + level?: NotificationLevel; + + @IsEnum(NotificationType) + @Optional() + @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' }) + type?: NotificationType; + + @IsString() + title!: string; + + @IsString() + @Optional({ nullable: true }) + description?: string | null; + + @Optional({ nullable: true }) + data?: any; + + @ValidateDate({ optional: true, nullable: true }) + readAt?: Date | null; + + @ValidateUUID() + userId!: string; +} + +export class NotificationUpdateDto { + @ValidateDate({ optional: true, nullable: true }) + readAt?: Date | null; +} + +export class NotificationUpdateAllDto { + @ValidateUUID({ each: true, optional: true }) + ids!: string[]; + + @ValidateDate({ optional: true, nullable: true }) + readAt?: Date | null; +} + +export class NotificationDeleteAllDto { + @ValidateUUID({ each: true }) + ids!: string[]; +} + +export type MapNotification = { + id: string; + createdAt: Date; + updateId?: string; + level: NotificationLevel; + type: NotificationType; + data: any | null; + title: string; + description: string | null; + readAt: Date | null; +}; +export const mapNotification = (notification: MapNotification): NotificationDto => { + return { + id: notification.id, + createdAt: notification.createdAt, + level: notification.level, + type: notification.type, + title: notification.title, + description: notification.description ?? undefined, + data: notification.data ?? undefined, + readAt: notification.readAt ?? undefined, + }; +}; diff --git a/server/src/enum.ts b/server/src/enum.ts index b9a914671ac..9fb6168b1a0 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -126,6 +126,11 @@ export enum Permission { MEMORY_UPDATE = 'memory.update', MEMORY_DELETE = 'memory.delete', + NOTIFICATION_CREATE = 'notification.create', + NOTIFICATION_READ = 'notification.read', + NOTIFICATION_UPDATE = 'notification.update', + NOTIFICATION_DELETE = 'notification.delete', + PARTNER_CREATE = 'partner.create', PARTNER_READ = 'partner.read', PARTNER_UPDATE = 'partner.update', @@ -515,6 +520,7 @@ export enum JobName { NOTIFY_SIGNUP = 'notify-signup', NOTIFY_ALBUM_INVITE = 'notify-album-invite', NOTIFY_ALBUM_UPDATE = 'notify-album-update', + NOTIFICATIONS_CLEANUP = 'notifications-cleanup', SEND_EMAIL = 'notification-send-email', // Version check @@ -580,3 +586,17 @@ export enum SyncEntityType { PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', PartnerAssetExifV1 = 'PartnerAssetExifV1', } + +export enum NotificationLevel { + Success = 'success', + Error = 'error', + Warning = 'warning', + Info = 'info', +} + +export enum NotificationType { + JobFailed = 'JobFailed', + BackupFailed = 'BackupFailed', + SystemMessage = 'SystemMessage', + Custom = 'Custom', +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index dd58aebcb20..03f1af3b281 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -157,6 +157,15 @@ where and "memories"."ownerId" = $2 and "memories"."deletedAt" is null +-- AccessRepository.notification.checkOwnerAccess +select + "notifications"."id" +from + "notifications" +where + "notifications"."id" in ($1) + and "notifications"."userId" = $2 + -- AccessRepository.person.checkOwnerAccess select "person"."id" diff --git a/server/src/queries/notification.repository.sql b/server/src/queries/notification.repository.sql new file mode 100644 index 00000000000..c55e00d2265 --- /dev/null +++ b/server/src/queries/notification.repository.sql @@ -0,0 +1,58 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- NotificationRepository.cleanup +delete from "notifications" +where + ( + ( + "deletedAt" is not null + and "deletedAt" < $1 + ) + or ( + "readAt" > $2 + and "createdAt" < $3 + ) + or ( + "readAt" = $4 + and "createdAt" < $5 + ) + ) + +-- NotificationRepository.search +select + "id", + "createdAt", + "level", + "type", + "title", + "description", + "data", + "readAt" +from + "notifications" +where + "userId" = $1 + and "deletedAt" is null +order by + "createdAt" desc + +-- NotificationRepository.search (unread) +select + "id", + "createdAt", + "level", + "type", + "title", + "description", + "data", + "readAt" +from + "notifications" +where + ( + "userId" = $1 + and "readAt" is null + ) + and "deletedAt" is null +order by + "createdAt" desc diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 961cccbf3ea..c24209e4828 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -279,6 +279,26 @@ class AuthDeviceAccess { } } +class NotificationAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, notificationIds: Set) { + if (notificationIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('notifications') + .select('notifications.id') + .where('notifications.id', 'in', [...notificationIds]) + .where('notifications.userId', '=', userId) + .execute() + .then((stacks) => new Set(stacks.map((stack) => stack.id))); + } +} + class StackAccess { constructor(private db: Kysely) {} @@ -426,6 +446,7 @@ export class AccessRepository { asset: AssetAccess; authDevice: AuthDeviceAccess; memory: MemoryAccess; + notification: NotificationAccess; person: PersonAccess; partner: PartnerAccess; stack: StackAccess; @@ -438,6 +459,7 @@ export class AccessRepository { this.asset = new AssetAccess(db); this.authDevice = new AuthDeviceAccess(db); this.memory = new MemoryAccess(db); + this.notification = new NotificationAccess(db); this.person = new PersonAccess(db); this.partner = new PartnerAccess(db); this.stack = new StackAccess(db); diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 3156804d090..b41c007ef5c 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -14,6 +14,7 @@ import { SystemConfig } from 'src/config'; import { EventConfig } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { NotificationDto } from 'src/dtos/notification.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ImmichWorker, MetadataKey, QueueName } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -64,6 +65,7 @@ type EventMap = { 'assets.restore': [{ assetIds: string[]; userId: string }]; 'job.start': [QueueName, JobItem]; + 'job.failed': [{ job: JobItem; error: Error | any }]; // session events 'session.delete': [{ sessionId: string }]; @@ -104,6 +106,7 @@ export interface ClientEventMap { on_server_version: [ServerVersionResponseDto]; on_config_update: []; on_new_release: [ReleaseNotification]; + on_notification: [NotificationDto]; on_session_delete: [string]; } diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index bd2e5c6774c..453e515fe05 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -22,6 +22,7 @@ import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -55,6 +56,7 @@ export const repositories = [ CryptoRepository, DatabaseRepository, DownloadRepository, + EmailRepository, EventRepository, JobRepository, LibraryRepository, @@ -65,7 +67,7 @@ export const repositories = [ MemoryRepository, MetadataRepository, MoveRepository, - EmailRepository, + NotificationRepository, OAuthRepository, PartnerRepository, PersonRepository, diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts new file mode 100644 index 00000000000..112bb97e60c --- /dev/null +++ b/server/src/repositories/notification.repository.ts @@ -0,0 +1,103 @@ +import { Insertable, Kysely, Updateable } from 'kysely'; +import { DateTime } from 'luxon'; +import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; +import { DB, Notifications } from 'src/db'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { NotificationSearchDto } from 'src/dtos/notification.dto'; + +export class NotificationRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID] }) + cleanup() { + return this.db + .deleteFrom('notifications') + .where((eb) => + eb.or([ + // remove soft-deleted notifications + eb.and([eb('deletedAt', 'is not', null), eb('deletedAt', '<', DateTime.now().minus({ days: 3 }).toJSDate())]), + + // remove old, read notifications + eb.and([ + // keep recently read messages around for a few days + eb('readAt', '>', DateTime.now().minus({ days: 2 }).toJSDate()), + eb('createdAt', '<', DateTime.now().minus({ days: 15 }).toJSDate()), + ]), + + eb.and([ + // remove super old, unread notifications + eb('readAt', '=', null), + eb('createdAt', '<', DateTime.now().minus({ days: 30 }).toJSDate()), + ]), + ]), + ) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, {}] }, { name: 'unread', params: [DummyValue.UUID, { unread: true }] }) + search(userId: string, dto: NotificationSearchDto) { + return this.db + .selectFrom('notifications') + .select(columns.notification) + .where((qb) => + qb.and({ + userId, + id: dto.id, + level: dto.level, + type: dto.type, + readAt: dto.unread ? null : undefined, + }), + ) + .where('deletedAt', 'is', null) + .orderBy('createdAt', 'desc') + .execute(); + } + + create(notification: Insertable) { + return this.db + .insertInto('notifications') + .values(notification) + .returning(columns.notification) + .executeTakeFirstOrThrow(); + } + + get(id: string) { + return this.db + .selectFrom('notifications') + .select(columns.notification) + .where('id', '=', id) + .where('deletedAt', 'is not', null) + .executeTakeFirst(); + } + + update(id: string, notification: Updateable) { + return this.db + .updateTable('notifications') + .set(notification) + .where('deletedAt', 'is', null) + .where('id', '=', id) + .returning(columns.notification) + .executeTakeFirstOrThrow(); + } + + async updateAll(ids: string[], notification: Updateable) { + await this.db.updateTable('notifications').set(notification).where('id', 'in', ids).execute(); + } + + async delete(id: string) { + await this.db + .updateTable('notifications') + .set({ deletedAt: DateTime.now().toJSDate() }) + .where('id', '=', id) + .execute(); + } + + async deleteAll(ids: string[]) { + await this.db + .updateTable('notifications') + .set({ deletedAt: DateTime.now().toJSDate() }) + .where('id', 'in', ids) + .execute(); + } +} diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index fe4b86d65c5..d297b2217de 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -28,6 +28,7 @@ import { MemoryTable } from 'src/schema/tables/memory.table'; import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table'; import { MoveTable } from 'src/schema/tables/move.table'; import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table'; +import { NotificationTable } from 'src/schema/tables/notification.table'; import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonTable } from 'src/schema/tables/person.table'; @@ -76,6 +77,7 @@ export class ImmichDatabase { MemoryTable, MoveTable, NaturalEarthCountriesTable, + NotificationTable, PartnerAuditTable, PartnerTable, PersonTable, diff --git a/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts b/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts new file mode 100644 index 00000000000..28dca6658c5 --- /dev/null +++ b/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts @@ -0,0 +1,22 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "deletedAt" timestamp with time zone, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), "userId" uuid, "level" character varying NOT NULL DEFAULT 'info', "type" character varying NOT NULL DEFAULT 'info', "data" jsonb, "title" character varying NOT NULL, "description" text, "readAt" timestamp with time zone);`.execute(db); + await sql`ALTER TABLE "notifications" ADD CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id");`.execute(db); + await sql`ALTER TABLE "notifications" ADD CONSTRAINT "FK_692a909ee0fa9383e7859f9b406" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); + await sql`CREATE INDEX "IDX_notifications_update_id" ON "notifications" ("updateId")`.execute(db); + await sql`CREATE INDEX "IDX_692a909ee0fa9383e7859f9b40" ON "notifications" ("userId")`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "notifications_updated_at" + BEFORE UPDATE ON "notifications" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "notifications_updated_at" ON "notifications";`.execute(db); + await sql`DROP INDEX "IDX_notifications_update_id";`.execute(db); + await sql`DROP INDEX "IDX_692a909ee0fa9383e7859f9b40";`.execute(db); + await sql`ALTER TABLE "notifications" DROP CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a";`.execute(db); + await sql`ALTER TABLE "notifications" DROP CONSTRAINT "FK_692a909ee0fa9383e7859f9b406";`.execute(db); + await sql`DROP TABLE "notifications";`.execute(db); +} diff --git a/server/src/schema/tables/notification.table.ts b/server/src/schema/tables/notification.table.ts new file mode 100644 index 00000000000..bf9b8bdf3b3 --- /dev/null +++ b/server/src/schema/tables/notification.table.ts @@ -0,0 +1,52 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { UserTable } from 'src/schema/tables/user.table'; +import { + Column, + CreateDateColumn, + DeleteDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, +} from 'src/sql-tools'; + +@Table('notifications') +@UpdatedAtTrigger('notifications_updated_at') +export class NotificationTable { + @PrimaryGeneratedColumn() + id!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @DeleteDateColumn() + deletedAt?: Date; + + @UpdateIdColumn({ indexName: 'IDX_notifications_update_id' }) + updateId?: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) + userId!: string; + + @Column({ default: NotificationLevel.Info }) + level!: NotificationLevel; + + @Column({ default: NotificationLevel.Info }) + type!: NotificationType; + + @Column({ type: 'jsonb', nullable: true }) + data!: any | null; + + @Column() + title!: string; + + @Column({ type: 'text', nullable: true }) + description!: string; + + @Column({ type: 'timestamp with time zone', nullable: true }) + readAt?: Date | null; +} diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index 704087ab053..aa72fd588a5 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -142,52 +142,55 @@ describe(BackupService.name, () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); mocks.storage.createWriteStream.mockReturnValue(new PassThrough()); }); + it('should run a database backup successfully', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); expect(mocks.storage.createWriteStream).toHaveBeenCalled(); }); + it('should rename file on success', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); expect(mocks.storage.rename).toHaveBeenCalled(); }); + it('should fail if pg_dumpall fails', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1'); }); + it('should not rename file if pgdump fails and gzip succeeds', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1'); expect(mocks.storage.rename).not.toHaveBeenCalled(); }); + it('should fail if gzip fails', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Gzip failed with code 1'); }); + it('should fail if write stream fails', async () => { mocks.storage.createWriteStream.mockImplementation(() => { throw new Error('error'); }); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('error'); }); + it('should fail if rename fails', async () => { mocks.storage.rename.mockRejectedValue(new Error('error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('error'); }); + it('should ignore unlink failing and still return failed job status', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); mocks.storage.unlink.mockRejectedValue(new Error('error')); - const result = await sut.handleBackupDatabase(); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1'); expect(mocks.storage.unlink).toHaveBeenCalled(); - expect(result).toBe(JobStatus.FAILED); }); + it.each` postgresVersion | expectedVersion ${'14.10'} | ${14} diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index 409d34ab734..10f7becc7dc 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -174,7 +174,7 @@ export class BackupService extends BaseService { await this.storageRepository .unlink(backupFilePath) .catch((error) => this.logger.error('Failed to delete failed backup file', error)); - return JobStatus.FAILED; + throw error; } this.logger.log(`Database Backup Success`); diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 23ddb1b63eb..3381ad72226 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -29,6 +29,7 @@ import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -80,6 +81,7 @@ export class BaseService { protected memoryRepository: MemoryRepository, protected metadataRepository: MetadataRepository, protected moveRepository: MoveRepository, + protected notificationRepository: NotificationRepository, protected oauthRepository: OAuthRepository, protected partnerRepository: PartnerRepository, protected personRepository: PersonRepository, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index b214dd14f67..88b68d2c130 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -17,6 +17,7 @@ import { MapService } from 'src/services/map.service'; import { MediaService } from 'src/services/media.service'; import { MemoryService } from 'src/services/memory.service'; import { MetadataService } from 'src/services/metadata.service'; +import { NotificationAdminService } from 'src/services/notification-admin.service'; import { NotificationService } from 'src/services/notification.service'; import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; @@ -60,6 +61,7 @@ export const services = [ MemoryService, MetadataService, NotificationService, + NotificationAdminService, PartnerService, PersonService, SearchService, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index b81256de815..a387e6e0994 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -215,11 +215,7 @@ export class JobService extends BaseService { await this.onDone(job); } } catch (error: Error | any) { - this.logger.error( - `Unable to run job handler (${queueName}/${job.name}): ${error}`, - error?.stack, - JSON.stringify(job.data), - ); + await this.eventRepository.emit('job.failed', { job, error }); } finally { this.telemetryRepository.jobs.addToGauge(queueMetric, -1); } diff --git a/server/src/services/notification-admin.service.spec.ts b/server/src/services/notification-admin.service.spec.ts new file mode 100644 index 00000000000..4a747d41a35 --- /dev/null +++ b/server/src/services/notification-admin.service.spec.ts @@ -0,0 +1,111 @@ +import { defaults, SystemConfig } from 'src/config'; +import { EmailTemplate } from 'src/repositories/email.repository'; +import { NotificationService } from 'src/services/notification.service'; +import { userStub } from 'test/fixtures/user.stub'; +import { newTestService, ServiceMocks } from 'test/utils'; + +const smtpTransport = Object.freeze({ + ...defaults, + notifications: { + smtp: { + ...defaults.notifications.smtp, + enabled: true, + transport: { + ignoreCert: false, + host: 'localhost', + port: 587, + username: 'test', + password: 'test', + }, + }, + }, +}); + +describe(NotificationService.name, () => { + let sut: NotificationService; + let mocks: ServiceMocks; + + beforeEach(() => { + ({ sut, mocks } = newTestService(NotificationService)); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('sendTestEmail', () => { + it('should throw error if user could not be found', async () => { + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).rejects.toThrow('User not found'); + }); + + it('should throw error if smtp validation fails', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockRejectedValue(''); + + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).rejects.toThrow( + 'Failed to verify SMTP configuration', + ); + }); + + it('should send email to default domain', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockResolvedValue(true); + mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); + + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).resolves.not.toThrow(); + expect(mocks.email.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, + }); + expect(mocks.email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: smtpTransport.notifications.smtp.transport, + }), + ); + }); + + it('should send email to external domain', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockResolvedValue(true); + mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); + mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); + + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).resolves.not.toThrow(); + expect(mocks.email.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, + }); + expect(mocks.email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: smtpTransport.notifications.smtp.transport, + }), + ); + }); + + it('should send email with replyTo', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockResolvedValue(true); + mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); + + await expect( + sut.sendTestEmail('', { ...smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), + ).resolves.not.toThrow(); + expect(mocks.email.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, + }); + expect(mocks.email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: smtpTransport.notifications.smtp.transport, + replyTo: 'demo@immich.app', + }), + ); + }); + }); +}); diff --git a/server/src/services/notification-admin.service.ts b/server/src/services/notification-admin.service.ts new file mode 100644 index 00000000000..bf0d2bba41e --- /dev/null +++ b/server/src/services/notification-admin.service.ts @@ -0,0 +1,120 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto'; +import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { EmailTemplate } from 'src/repositories/email.repository'; +import { BaseService } from 'src/services/base.service'; +import { getExternalDomain } from 'src/utils/misc'; + +@Injectable() +export class NotificationAdminService extends BaseService { + async create(auth: AuthDto, dto: NotificationCreateDto) { + const item = await this.notificationRepository.create({ + userId: dto.userId, + type: dto.type ?? NotificationType.Custom, + level: dto.level ?? NotificationLevel.Info, + title: dto.title, + description: dto.description, + data: dto.data, + }); + + return mapNotification(item); + } + + async sendTestEmail(id: string, dto: SystemConfigSmtpDto, tempTemplate?: string) { + const user = await this.userRepository.get(id, { withDeleted: false }); + if (!user) { + throw new Error('User not found'); + } + + try { + await this.emailRepository.verifySmtp(dto.transport); + } catch (error) { + throw new BadRequestException('Failed to verify SMTP configuration', { cause: error }); + } + + const { server } = await this.getConfig({ withCache: false }); + const { html, text } = await this.emailRepository.renderEmail({ + template: EmailTemplate.TEST_EMAIL, + data: { + baseUrl: getExternalDomain(server), + displayName: user.name, + }, + customTemplate: tempTemplate!, + }); + const { messageId } = await this.emailRepository.sendEmail({ + to: user.email, + subject: 'Test email from Immich', + html, + text, + from: dto.from, + replyTo: dto.replyTo || dto.from, + smtp: dto.transport, + }); + + return { messageId }; + } + + async getTemplate(name: EmailTemplate, customTemplate: string) { + const { server, templates } = await this.getConfig({ withCache: false }); + + let templateResponse = ''; + + switch (name) { + case EmailTemplate.WELCOME: { + const { html: _welcomeHtml } = await this.emailRepository.renderEmail({ + template: EmailTemplate.WELCOME, + data: { + baseUrl: getExternalDomain(server), + displayName: 'John Doe', + username: 'john@doe.com', + password: 'thisIsAPassword123', + }, + customTemplate: customTemplate || templates.email.welcomeTemplate, + }); + + templateResponse = _welcomeHtml; + break; + } + case EmailTemplate.ALBUM_UPDATE: { + const { html: _updateAlbumHtml } = await this.emailRepository.renderEmail({ + template: EmailTemplate.ALBUM_UPDATE, + data: { + baseUrl: getExternalDomain(server), + albumId: '1', + albumName: 'Favorite Photos', + recipientName: 'Jane Doe', + cid: undefined, + }, + customTemplate: customTemplate || templates.email.albumInviteTemplate, + }); + templateResponse = _updateAlbumHtml; + break; + } + + case EmailTemplate.ALBUM_INVITE: { + const { html } = await this.emailRepository.renderEmail({ + template: EmailTemplate.ALBUM_INVITE, + data: { + baseUrl: getExternalDomain(server), + albumId: '1', + albumName: "John Doe's Favorites", + senderName: 'John Doe', + recipientName: 'Jane Doe', + cid: undefined, + }, + customTemplate: customTemplate || templates.email.albumInviteTemplate, + }); + templateResponse = html; + break; + } + default: { + templateResponse = ''; + break; + } + } + + return { name, html: templateResponse }; + } +} diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 5830260753c..133eb9e7f64 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -3,7 +3,6 @@ import { defaults, SystemConfig } from 'src/config'; import { AlbumUser } from 'src/database'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; -import { EmailTemplate } from 'src/repositories/email.repository'; import { NotificationService } from 'src/services/notification.service'; import { INotifyAlbumUpdateJob } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; @@ -241,82 +240,6 @@ describe(NotificationService.name, () => { }); }); - describe('sendTestEmail', () => { - it('should throw error if user could not be found', async () => { - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found'); - }); - - it('should throw error if smtp validation fails', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockRejectedValue(''); - - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow( - 'Failed to verify SMTP configuration', - ); - }); - - it('should send email to default domain', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockResolvedValue(true); - mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); - - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(mocks.email.renderEmail).toHaveBeenCalledWith({ - template: EmailTemplate.TEST_EMAIL, - data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, - }); - expect(mocks.email.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Test email from Immich', - smtp: configs.smtpTransport.notifications.smtp.transport, - }), - ); - }); - - it('should send email to external domain', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockResolvedValue(true); - mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); - mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); - - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(mocks.email.renderEmail).toHaveBeenCalledWith({ - template: EmailTemplate.TEST_EMAIL, - data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, - }); - expect(mocks.email.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Test email from Immich', - smtp: configs.smtpTransport.notifications.smtp.transport, - }), - ); - }); - - it('should send email with replyTo', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockResolvedValue(true); - mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); - - await expect( - sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), - ).resolves.not.toThrow(); - expect(mocks.email.renderEmail).toHaveBeenCalledWith({ - template: EmailTemplate.TEST_EMAIL, - data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, - }); - expect(mocks.email.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Test email from Immich', - smtp: configs.smtpTransport.notifications.smtp.transport, - replyTo: 'demo@immich.app', - }), - ); - }); - }); - describe('handleUserSignup', () => { it('should skip if user could not be found', async () => { await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SKIPPED); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 573be90f93a..be475d1dca1 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,7 +1,24 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { OnEvent, OnJob } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + mapNotification, + NotificationDeleteAllDto, + NotificationDto, + NotificationSearchDto, + NotificationUpdateAllDto, + NotificationUpdateDto, +} from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; -import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum'; +import { + AssetFileType, + JobName, + JobStatus, + NotificationLevel, + NotificationType, + Permission, + QueueName, +} from 'src/enum'; import { EmailTemplate } from 'src/repositories/email.repository'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; @@ -15,6 +32,80 @@ import { getPreferences } from 'src/utils/preferences'; export class NotificationService extends BaseService { private static albumUpdateEmailDelayMs = 300_000; + async search(auth: AuthDto, dto: NotificationSearchDto): Promise { + const items = await this.notificationRepository.search(auth.user.id, dto); + return items.map((item) => mapNotification(item)); + } + + async updateAll(auth: AuthDto, dto: NotificationUpdateAllDto) { + await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_UPDATE }); + await this.notificationRepository.updateAll(dto.ids, { + readAt: dto.readAt, + }); + } + + async deleteAll(auth: AuthDto, dto: NotificationDeleteAllDto) { + await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_DELETE }); + await this.notificationRepository.deleteAll(dto.ids); + } + + async get(auth: AuthDto, id: string) { + await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_READ }); + const item = await this.notificationRepository.get(id); + if (!item) { + throw new BadRequestException('Notification not found'); + } + return mapNotification(item); + } + + async update(auth: AuthDto, id: string, dto: NotificationUpdateDto) { + await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_UPDATE }); + const item = await this.notificationRepository.update(id, { + readAt: dto.readAt, + }); + return mapNotification(item); + } + + async delete(auth: AuthDto, id: string) { + await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_DELETE }); + await this.notificationRepository.delete(id); + } + + @OnJob({ name: JobName.NOTIFICATIONS_CLEANUP, queue: QueueName.BACKGROUND_TASK }) + async onNotificationsCleanup() { + await this.notificationRepository.cleanup(); + } + + @OnEvent({ name: 'job.failed' }) + async onJobFailed({ job, error }: ArgOf<'job.failed'>) { + const admin = await this.userRepository.getAdmin(); + if (!admin) { + return; + } + + this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data)); + + switch (job.name) { + case JobName.BACKUP_DATABASE: { + const errorMessage = error instanceof Error ? error.message : error; + const item = await this.notificationRepository.create({ + userId: admin.id, + type: NotificationType.JobFailed, + level: NotificationLevel.Error, + title: 'Job Failed', + description: `Job ${[job.name]} failed with error: ${errorMessage}`, + }); + + this.eventRepository.clientSend('on_notification', admin.id, mapNotification(item)); + break; + } + + default: { + return; + } + } + } + @OnEvent({ name: 'config.update' }) onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { this.eventRepository.clientBroadcast('on_config_update'); diff --git a/server/src/types.ts b/server/src/types.ts index c5375ae727a..ba33e97aada 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -297,6 +297,10 @@ export type JobItem = // Metadata Extraction | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } | { name: JobName.METADATA_EXTRACTION; data: IEntityJob } + + // Notifications + | { name: JobName.NOTIFICATIONS_CLEANUP; data?: IBaseJob } + // Sidecar Scanning | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob } diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 4e21a9226e3..b04d23f114c 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -221,6 +221,12 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return access.person.checkFaceOwnerAccess(auth.user.id, ids); } + case Permission.NOTIFICATION_READ: + case Permission.NOTIFICATION_UPDATE: + case Permission.NOTIFICATION_DELETE: { + return access.notification.checkOwnerAccess(auth.user.id, ids); + } + case Permission.TAG_ASSET: case Permission.TAG_READ: case Permission.TAG_UPDATE: diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 671a8a50cab..3684837baa6 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -13,9 +13,11 @@ import { AssetRepository } from 'src/repositories/asset.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; +import { EmailRepository } from 'src/repositories/email.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { SearchRepository } from 'src/repositories/search.repository'; @@ -42,10 +44,12 @@ type RepositoriesTypes = { config: ConfigRepository; crypto: CryptoRepository; database: DatabaseRepository; + email: EmailRepository; job: JobRepository; user: UserRepository; logger: LoggingRepository; memory: MemoryRepository; + notification: NotificationRepository; partner: PartnerRepository; person: PersonRepository; search: SearchRepository; @@ -142,6 +146,11 @@ export const getRepository = (key: K, db: Kys return new DatabaseRepository(db, new LoggingRepository(undefined, configRepo), configRepo); } + case 'email': { + const logger = new LoggingRepository(undefined, new ConfigRepository()); + return new EmailRepository(logger); + } + case 'logger': { const configMock = { getEnv: () => ({ noColor: false }) }; return new LoggingRepository(undefined, configMock as ConfigRepository); @@ -151,6 +160,10 @@ export const getRepository = (key: K, db: Kys return new MemoryRepository(db); } + case 'notification': { + return new NotificationRepository(db); + } + case 'partner': { return new PartnerRepository(db); } @@ -221,6 +234,10 @@ const getRepositoryMock = (key: K) => { }); } + case 'email': { + return automock(EmailRepository, { args: [{ setContext: () => {} }] }); + } + case 'job': { return automock(JobRepository, { args: [undefined, undefined, undefined, { setContext: () => {} }] }); } @@ -234,6 +251,10 @@ const getRepositoryMock = (key: K) => { return automock(MemoryRepository); } + case 'notification': { + return automock(NotificationRepository); + } + case 'partner': { return automock(PartnerRepository); } @@ -284,7 +305,7 @@ export const asDeps = (repositories: ServiceOverrides) => { repositories.crypto || getRepositoryMock('crypto'), repositories.database || getRepositoryMock('database'), repositories.downloadRepository, - repositories.email, + repositories.email || getRepositoryMock('email'), repositories.event, repositories.job || getRepositoryMock('job'), repositories.library, @@ -294,6 +315,7 @@ export const asDeps = (repositories: ServiceOverrides) => { repositories.memory || getRepositoryMock('memory'), repositories.metadata, repositories.move, + repositories.notification || getRepositoryMock('notification'), repositories.oauth, repositories.partner || getRepositoryMock('partner'), repositories.person || getRepositoryMock('person'), diff --git a/server/test/medium/specs/controllers/notification.controller.spec.ts b/server/test/medium/specs/controllers/notification.controller.spec.ts new file mode 100644 index 00000000000..f4a0ec82d5a --- /dev/null +++ b/server/test/medium/specs/controllers/notification.controller.spec.ts @@ -0,0 +1,86 @@ +import { NotificationController } from 'src/controllers/notification.controller'; +import { AuthService } from 'src/services/auth.service'; +import { NotificationService } from 'src/services/notification.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { createControllerTestApp, TestControllerApp } from 'test/medium/utils'; +import { factory } from 'test/small.factory'; + +describe(NotificationController.name, () => { + let realApp: TestControllerApp; + let mockApp: TestControllerApp; + + beforeEach(async () => { + realApp = await createControllerTestApp({ authType: 'real' }); + mockApp = await createControllerTestApp({ authType: 'mock' }); + }); + + describe('GET /notifications', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()).get('/notifications'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should call the service with an auth dto', async () => { + const auth = factory.auth({ user: factory.user() }); + mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth); + const service = mockApp.getMockedService(NotificationService); + + const { status } = await request(mockApp.getHttpServer()) + .get('/notifications') + .set('Authorization', `Bearer token`); + + expect(status).toBe(200); + expect(service.search).toHaveBeenCalledWith(auth, {}); + }); + + it(`should reject an invalid notification level`, async () => { + const auth = factory.auth({ user: factory.user() }); + mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth); + const service = mockApp.getMockedService(NotificationService); + + const { status, body } = await request(mockApp.getHttpServer()) + .get(`/notifications`) + .query({ level: 'invalid' }) + .set('Authorization', `Bearer token`); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('level must be one of the following values')])); + expect(service.search).not.toHaveBeenCalled(); + }); + }); + + describe('PUT /notifications', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()) + .put(`/notifications`) + .send({ ids: [], readAt: new Date().toISOString() }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('GET /notifications/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()).get(`/notifications/${factory.uuid()}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('PUT /notifications/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()) + .put(`/notifications/${factory.uuid()}`) + .send({ readAt: factory.date() }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + afterAll(async () => { + await realApp.close(); + await mockApp.close(); + }); +}); diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index ec5115b8399..5b98b95e27f 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -37,6 +37,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, + notification: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + person: { checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 919cdd4b1c7..d2742f7f80e 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -314,4 +314,5 @@ export const factory = { sidecarWrite: assetSidecarWriteFactory, }, uuid: newUuid, + date: newDate, }; diff --git a/server/test/utils.ts b/server/test/utils.ts index c7c29d310e6..2c444f491e8 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -29,6 +29,7 @@ import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -135,6 +136,7 @@ export type ServiceOverrides = { memory: MemoryRepository; metadata: MetadataRepository; move: MoveRepository; + notification: NotificationRepository; oauth: OAuthRepository; partner: PartnerRepository; person: PersonRepository; @@ -202,6 +204,7 @@ export const newTestService = ( memory: automock(MemoryRepository), metadata: newMetadataRepositoryMock(), move: automock(MoveRepository, { strict: false }), + notification: automock(NotificationRepository), oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: newPersonRepositoryMock(), @@ -250,6 +253,7 @@ export const newTestService = ( overrides.memory || (mocks.memory as As), overrides.metadata || (mocks.metadata as As), overrides.move || (mocks.move as As), + overrides.notification || (mocks.notification as As), overrides.oauth || (mocks.oauth as As), overrides.partner || (mocks.partner as As), overrides.person || (mocks.person as As), diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index e91db5cc3ab..2ebe4febabf 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -8,6 +8,7 @@ import SkipLink from '$lib/components/elements/buttons/skip-link.svelte'; import HelpAndFeedbackModal from '$lib/components/shared-components/help-and-feedback-modal.svelte'; import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte'; + import NotificationPanel from '$lib/components/shared-components/navigation-bar/notification-panel.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; import { AppRoute } from '$lib/constants'; import { authManager } from '$lib/stores/auth-manager.svelte'; @@ -18,13 +19,14 @@ import { userInteraction } from '$lib/stores/user.svelte'; import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk'; import { Button, IconButton } from '@immich/ui'; - import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js'; + import { mdiBellBadge, mdiBellOutline, mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import ThemeButton from '../theme-button.svelte'; import UserAvatar from '../user-avatar.svelte'; import AccountInfoPanel from './account-info-panel.svelte'; + import { notificationManager } from '$lib/stores/notification-manager.svelte'; interface Props { showUploadButton?: boolean; @@ -36,7 +38,9 @@ let shouldShowAccountInfo = $state(false); let shouldShowAccountInfoPanel = $state(false); let shouldShowHelpPanel = $state(false); + let shouldShowNotificationPanel = $state(false); let innerWidth: number = $state(0); + const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0); let info: ServerAboutResponseDto | undefined = $state(); @@ -146,6 +150,27 @@ /> +
(shouldShowNotificationPanel = false), + onEscape: () => (shouldShowNotificationPanel = false), + }} + > + (shouldShowNotificationPanel = !shouldShowNotificationPanel)} + aria-label={$t('notifications')} + /> + + {#if shouldShowNotificationPanel} + + {/if} +
+
(shouldShowAccountInfoPanel = false), diff --git a/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte b/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte new file mode 100644 index 00000000000..0d05e2d6d7e --- /dev/null +++ b/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte @@ -0,0 +1,114 @@ + + + diff --git a/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte new file mode 100644 index 00000000000..be9fcd2a444 --- /dev/null +++ b/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte @@ -0,0 +1,82 @@ + + +
+ +
+ {$t('notifications')} +
+ +
+
+ +
+ + {#if noUnreadNotifications} + + + {$t('no_notifications')} + + {:else} + + + {#each notificationManager.notifications as notification (notification.id)} +
+ markAsRead(id)} /> +
+ {/each} +
+
+ {/if} +
+
diff --git a/web/src/lib/stores/notification-manager.svelte.ts b/web/src/lib/stores/notification-manager.svelte.ts new file mode 100644 index 00000000000..c06400fd164 --- /dev/null +++ b/web/src/lib/stores/notification-manager.svelte.ts @@ -0,0 +1,38 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; +import { getNotifications, updateNotification, updateNotifications, type NotificationDto } from '@immich/sdk'; + +class NotificationStore { + notifications = $state([]); + + constructor() { + // TODO replace this with an `auth.login` event + this.refresh().catch(() => {}); + + eventManager.on('auth.logout', () => this.clear()); + } + + get hasUnread() { + return this.notifications.length > 0; + } + + refresh = async () => { + this.notifications = await getNotifications({ unread: true }); + }; + + markAsRead = async (id: string) => { + this.notifications = this.notifications.filter((notification) => notification.id !== id); + await updateNotification({ id, notificationUpdateDto: { readAt: new Date().toISOString() } }); + }; + + markAllAsRead = async () => { + const ids = this.notifications.map(({ id }) => id); + this.notifications = []; + await updateNotifications({ notificationUpdateAllDto: { ids, readAt: new Date().toISOString() } }); + }; + + clear = () => { + this.notifications = []; + }; +} + +export const notificationManager = new NotificationStore(); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 90228a5cbde..ccfcfb78054 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -1,6 +1,7 @@ import { authManager } from '$lib/stores/auth-manager.svelte'; +import { notificationManager } from '$lib/stores/notification-manager.svelte'; import { createEventEmitter } from '$lib/utils/eventemitter'; -import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk'; +import { type AssetResponseDto, type NotificationDto, type ServerVersionResponseDto } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; import { get, writable } from 'svelte/store'; import { user } from './user.store'; @@ -26,6 +27,7 @@ export interface Events { on_config_update: () => void; on_new_release: (newRelase: ReleaseEvent) => void; on_session_delete: (sessionId: string) => void; + on_notification: (notification: NotificationDto) => void; } const websocket: Socket = io({ @@ -50,6 +52,7 @@ websocket .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion)) .on('on_session_delete', () => authManager.logout()) + .on('on_notification', () => notificationManager.refresh()) .on('connect_error', (e) => console.log('Websocket Connect Error', e)); export const openWebsocketConnection = () => { diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index aa756ac2e8c..1dcb91f9967 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -10,6 +10,7 @@ import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; + import { notificationManager } from '$lib/stores/notification-manager.svelte'; interface Props { data: PageData; @@ -24,7 +25,10 @@ let loading = $state(false); let oauthLoading = $state(true); - const onSuccess = async () => await goto(AppRoute.PHOTOS, { invalidateAll: true }); + const onSuccess = async () => { + await notificationManager.refresh(); + await goto(AppRoute.PHOTOS, { invalidateAll: true }); + }; const onFirstLogin = async () => await goto(AppRoute.AUTH_CHANGE_PASSWORD); const onOnboarding = async () => await goto(AppRoute.AUTH_ONBOARDING);