mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 09:15:44 +03:00
feat: notifications (#17701)
* feat: notifications * UI works * chore: pr feedback * initial fetch and clear notification upon logging out * fix: merge --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<NotificationDto> {
|
||||
return this.service.create(auth, dto);
|
||||
}
|
||||
|
||||
@Post('test-email')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
60
server/src/controllers/notification.controller.ts
Normal file
60
server/src/controllers/notification.controller.ts
Normal file
@@ -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<NotificationDto[]> {
|
||||
return this.service.search(auth, dto);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@Authenticated({ permission: Permission.NOTIFICATION_UPDATE })
|
||||
updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise<void> {
|
||||
return this.service.updateAll(auth, dto);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Authenticated({ permission: Permission.NOTIFICATION_DELETE })
|
||||
deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise<void> {
|
||||
return this.service.deleteAll(auth, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Authenticated({ permission: Permission.NOTIFICATION_READ })
|
||||
getNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<NotificationDto> {
|
||||
return this.service.get(auth, id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Authenticated({ permission: Permission.NOTIFICATION_UPDATE })
|
||||
updateNotification(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: NotificationUpdateDto,
|
||||
): Promise<NotificationDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Authenticated({ permission: Permission.NOTIFICATION_DELETE })
|
||||
deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
18
server/src/db.d.ts
vendored
18
server/src/db.d.ts
vendored
@@ -11,6 +11,8 @@ import {
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
MemoryType,
|
||||
NotificationLevel,
|
||||
NotificationType,
|
||||
Permission,
|
||||
SharedLinkType,
|
||||
SourceType,
|
||||
@@ -263,6 +265,21 @@ export interface Memories {
|
||||
updateId: Generated<string>;
|
||||
}
|
||||
|
||||
export interface Notifications {
|
||||
id: Generated<string>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
updateId: Generated<string>;
|
||||
userId: string;
|
||||
level: Generated<NotificationLevel>;
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
58
server/src/queries/notification.repository.sql
Normal file
58
server/src/queries/notification.repository.sql
Normal file
@@ -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
|
||||
@@ -279,6 +279,26 @@ class AuthDeviceAccess {
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationAccess {
|
||||
constructor(private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||
@ChunkedSet({ paramIndex: 1 })
|
||||
async checkOwnerAccess(userId: string, notificationIds: Set<string>) {
|
||||
if (notificationIds.size === 0) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
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<DB>) {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
103
server/src/repositories/notification.repository.ts
Normal file
103
server/src/repositories/notification.repository.ts
Normal file
@@ -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<DB>) {}
|
||||
|
||||
@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<Notifications>) {
|
||||
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<Notifications>) {
|
||||
return this.db
|
||||
.updateTable('notifications')
|
||||
.set(notification)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('id', '=', id)
|
||||
.returning(columns.notification)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async updateAll(ids: string[], notification: Updateable<Notifications>) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
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<any>): Promise<void> {
|
||||
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);
|
||||
}
|
||||
52
server/src/schema/tables/notification.table.ts
Normal file
52
server/src/schema/tables/notification.table.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
111
server/src/services/notification-admin.service.spec.ts
Normal file
111
server/src/services/notification-admin.service.spec.ts
Normal file
@@ -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<SystemConfig>({
|
||||
...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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
120
server/src/services/notification-admin.service.ts
Normal file
120
server/src/services/notification-admin.service.ts
Normal file
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<NotificationDto[]> {
|
||||
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');
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user