1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

feat(server): wait five minutes before sending email on new album item (#12223)

Album update jobs will now wait five minutes to send. If a new image is added while that job is pending, the old job will be cancelled, and a new one will be enqueued for a minute.

This is to prevent a flood of notifications by dragging in images directly to the album, which adds them to the album one at a time.

Album updates now include a list of users to email, which is generally everybody except the updater. If somebody else updates the album within that minute, both people will get an album update email in a minute, as they both added images and the other should be notified.
This commit is contained in:
Hayden 2024-10-18 13:51:34 -06:00 committed by GitHub
parent 76c0b964eb
commit 4a2a7b7735
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 93 additions and 40 deletions

View file

@ -22,7 +22,7 @@ type EventMap = {
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
// album events // album events
'album.update': [{ id: string; updatedBy: string }]; 'album.update': [{ id: string; recipientIds: string[] }];
'album.invite': [{ id: string; userId: string }]; 'album.invite': [{ id: string; userId: string }];
// asset events // asset events

View file

@ -120,6 +120,11 @@ export interface IBaseJob {
force?: boolean; force?: boolean;
} }
export interface IDelayedJob extends IBaseJob {
/** The minimum time to wait to execute this job, in milliseconds. */
delay?: number;
}
export interface IEntityJob extends IBaseJob { export interface IEntityJob extends IBaseJob {
id: string; id: string;
source?: 'upload' | 'sidecar-write' | 'copy'; source?: 'upload' | 'sidecar-write' | 'copy';
@ -181,8 +186,8 @@ export interface INotifyAlbumInviteJob extends IEntityJob {
recipientId: string; recipientId: string;
} }
export interface INotifyAlbumUpdateJob extends IEntityJob { export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
senderId: string; recipientIds: string[];
} }
export interface JobCounts { export interface JobCounts {
@ -310,4 +315,5 @@ export interface IJobRepository {
getQueueStatus(name: QueueName): Promise<QueueStatus>; getQueueStatus(name: QueueName): Promise<QueueStatus>;
getJobCounts(name: QueueName): Promise<JobCounts>; getJobCounts(name: QueueName): Promise<JobCounts>;
waitForQueueCompletion(...queues: QueueName[]): Promise<void>; waitForQueueCompletion(...queues: QueueName[]): Promise<void>;
removeJob(jobId: string, name: JobName): Promise<IEntityJob | undefined>;
} }

View file

@ -7,6 +7,7 @@ import { CronJob, CronTime } from 'cron';
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { IConfigRepository } from 'src/interfaces/config.interface'; import { IConfigRepository } from 'src/interfaces/config.interface';
import { import {
IEntityJob,
IJobRepository, IJobRepository,
JobCounts, JobCounts,
JobItem, JobItem,
@ -252,6 +253,9 @@ export class JobRepository implements IJobRepository {
private getJobOptions(item: JobItem): JobsOptions | null { private getJobOptions(item: JobItem): JobsOptions | null {
switch (item.name) { switch (item.name) {
case JobName.NOTIFY_ALBUM_UPDATE: {
return { jobId: item.data.id, delay: item.data?.delay };
}
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
return { jobId: item.data.id }; return { jobId: item.data.id };
} }
@ -261,7 +265,6 @@ export class JobRepository implements IJobRepository {
case JobName.QUEUE_FACIAL_RECOGNITION: { case JobName.QUEUE_FACIAL_RECOGNITION: {
return { jobId: JobName.QUEUE_FACIAL_RECOGNITION }; return { jobId: JobName.QUEUE_FACIAL_RECOGNITION };
} }
default: { default: {
return null; return null;
} }
@ -271,4 +274,20 @@ export class JobRepository implements IJobRepository {
private getQueue(queue: QueueName): Queue { private getQueue(queue: QueueName): Queue {
return this.moduleReference.get<Queue>(getQueueToken(queue), { strict: false }); return this.moduleReference.get<Queue>(getQueueToken(queue), { strict: false });
} }
public async removeJob(jobId: string, name: JobName): Promise<IEntityJob | undefined> {
const existingJob = await this.getQueue(JOBS_TO_QUEUE[name]).getJob(jobId);
if (!existingJob) {
return;
}
try {
await existingJob.remove();
} catch (error: any) {
if (error.message?.includes('Missing key for job')) {
return;
}
throw error;
}
return existingJob.data;
}
} }

View file

@ -537,10 +537,6 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(eventMock.emit).toHaveBeenCalledWith('album.update', {
id: 'album-123',
updatedBy: authStub.admin.user.id,
});
}); });
it('should not set the thumbnail if the album has one already', async () => { it('should not set the thumbnail if the album has one already', async () => {
@ -583,7 +579,7 @@ describe(AlbumService.name, () => {
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(eventMock.emit).toHaveBeenCalledWith('album.update', { expect(eventMock.emit).toHaveBeenCalledWith('album.update', {
id: 'album-123', id: 'album-123',
updatedBy: authStub.user1.user.id, recipientIds: ['admin_id'],
}); });
}); });

View file

@ -174,7 +174,13 @@ export class AlbumService extends BaseService {
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId, albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
}); });
await this.eventRepository.emit('album.update', { id, updatedBy: auth.user.id }); const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
);
if (allUsersExceptUs.length > 0) {
await this.eventRepository.emit('album.update', { id, recipientIds: allUsersExceptUs });
}
} }
return results; return results;

View file

@ -7,7 +7,7 @@ import { AssetFileType, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, INotifyAlbumUpdateJob, JobName, JobStatus } from 'src/interfaces/job.interface';
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
@ -170,10 +170,10 @@ describe(NotificationService.name, () => {
describe('onAlbumUpdateEvent', () => { describe('onAlbumUpdateEvent', () => {
it('should queue notify album update event', async () => { it('should queue notify album update event', async () => {
await sut.onAlbumUpdate({ id: '', updatedBy: '42' }); await sut.onAlbumUpdate({ id: 'album', recipientIds: ['42'] });
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_ALBUM_UPDATE, name: JobName.NOTIFY_ALBUM_UPDATE,
data: { id: '', senderId: '42' }, data: { id: 'album', recipientIds: ['42'], delay: 300_000 },
}); });
}); });
}); });
@ -512,34 +512,17 @@ describe(NotificationService.name, () => {
describe('handleAlbumUpdate', () => { describe('handleAlbumUpdate', () => {
it('should skip if album could not be found', async () => { it('should skip if album could not be found', async () => {
await expect(sut.handleAlbumUpdate({ id: '', senderId: '' })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED);
expect(userMock.get).not.toHaveBeenCalled(); expect(userMock.get).not.toHaveBeenCalled();
}); });
it('should skip if owner could not be found', async () => { it('should skip if owner could not be found', async () => {
albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail);
await expect(sut.handleAlbumUpdate({ id: '', senderId: '' })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED);
expect(systemMock.get).not.toHaveBeenCalled(); expect(systemMock.get).not.toHaveBeenCalled();
}); });
it('should filter out the sender', async () => {
albumMock.getById.mockResolvedValue({
...albumStub.emptyWithValidThumbnail,
albumUsers: [
{ user: { id: userStub.user1.id } } as AlbumUserEntity,
{ user: { id: userStub.user2.id } } as AlbumUserEntity,
],
});
userMock.get.mockResolvedValue(userStub.user1);
notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await sut.handleAlbumUpdate({ id: '', senderId: userStub.user1.id });
expect(userMock.get).not.toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(userMock.get).toHaveBeenCalledWith(userStub.user2.id, { withDeleted: false });
expect(notificationMock.renderEmail).toHaveBeenCalledOnce();
});
it('should skip recipient that could not be looked up', async () => { it('should skip recipient that could not be looked up', async () => {
albumMock.getById.mockResolvedValue({ albumMock.getById.mockResolvedValue({
...albumStub.emptyWithValidThumbnail, ...albumStub.emptyWithValidThumbnail,
@ -548,7 +531,7 @@ describe(NotificationService.name, () => {
userMock.get.mockResolvedValueOnce(userStub.user1); userMock.get.mockResolvedValueOnce(userStub.user1);
notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await sut.handleAlbumUpdate({ id: '', senderId: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(notificationMock.renderEmail).not.toHaveBeenCalled(); expect(notificationMock.renderEmail).not.toHaveBeenCalled();
}); });
@ -571,7 +554,7 @@ describe(NotificationService.name, () => {
}); });
notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await sut.handleAlbumUpdate({ id: '', senderId: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(notificationMock.renderEmail).not.toHaveBeenCalled(); expect(notificationMock.renderEmail).not.toHaveBeenCalled();
}); });
@ -594,7 +577,7 @@ describe(NotificationService.name, () => {
}); });
notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await sut.handleAlbumUpdate({ id: '', senderId: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(notificationMock.renderEmail).not.toHaveBeenCalled(); expect(notificationMock.renderEmail).not.toHaveBeenCalled();
}); });
@ -607,11 +590,24 @@ describe(NotificationService.name, () => {
userMock.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await sut.handleAlbumUpdate({ id: '', senderId: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(notificationMock.renderEmail).toHaveBeenCalled(); expect(notificationMock.renderEmail).toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalled(); expect(jobMock.queue).toHaveBeenCalled();
}); });
it('should add new recipients for new images if job is already queued', async () => {
jobMock.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob);
await sut.onAlbumUpdate({ id: '1', recipientIds: ['1', '2', '3'] } as INotifyAlbumUpdateJob);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_ALBUM_UPDATE,
data: {
id: '1',
delay: 300_000,
recipientIds: ['1', '2', '3', '4'],
},
});
});
}); });
describe('handleSendEmail', () => { describe('handleSendEmail', () => {

View file

@ -5,9 +5,11 @@ import { AlbumEntity } from 'src/entities/album.entity';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf } from 'src/interfaces/event.interface';
import { import {
IEmailJob, IEmailJob,
IEntityJob,
INotifyAlbumInviteJob, INotifyAlbumInviteJob,
INotifyAlbumUpdateJob, INotifyAlbumUpdateJob,
INotifySignupJob, INotifySignupJob,
JobItem,
JobName, JobName,
JobStatus, JobStatus,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
@ -21,6 +23,8 @@ import { getPreferences } from 'src/utils/preferences';
@Injectable() @Injectable()
export class NotificationService extends BaseService { export class NotificationService extends BaseService {
private static albumUpdateEmailDelayMs = 300_000;
@OnEvent({ name: 'config.update' }) @OnEvent({ name: 'config.update' })
onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) {
this.eventRepository.clientBroadcast('on_config_update'); this.eventRepository.clientBroadcast('on_config_update');
@ -100,8 +104,30 @@ export class NotificationService extends BaseService {
} }
@OnEvent({ name: 'album.update' }) @OnEvent({ name: 'album.update' })
async onAlbumUpdate({ id, updatedBy }: ArgOf<'album.update'>) { async onAlbumUpdate({ id, recipientIds }: ArgOf<'album.update'>) {
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } }); // if recipientIds is empty, album likely only has one user part of it, don't queue notification if so
if (recipientIds.length === 0) {
return;
}
const job: JobItem = {
name: JobName.NOTIFY_ALBUM_UPDATE,
data: { id, recipientIds, delay: NotificationService.albumUpdateEmailDelayMs },
};
const previousJobData = await this.jobRepository.removeJob(id, JobName.NOTIFY_ALBUM_UPDATE);
if (previousJobData && this.isAlbumUpdateJob(previousJobData)) {
for (const id of previousJobData.recipientIds) {
if (!recipientIds.includes(id)) {
recipientIds.push(id);
}
}
}
await this.jobRepository.queue(job);
}
private isAlbumUpdateJob(job: IEntityJob): job is INotifyAlbumUpdateJob {
return 'recipientIds' in job;
} }
@OnEvent({ name: 'album.invite' }) @OnEvent({ name: 'album.invite' })
@ -228,7 +254,7 @@ export class NotificationService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleAlbumUpdate({ id, senderId }: INotifyAlbumUpdateJob) { async handleAlbumUpdate({ id, recipientIds }: INotifyAlbumUpdateJob) {
const album = await this.albumRepository.getById(id, { withAssets: false }); const album = await this.albumRepository.getById(id, { withAssets: false });
if (!album) { if (!album) {
@ -240,7 +266,9 @@ export class NotificationService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) => user.id !== senderId); const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) =>
recipientIds.includes(user.id),
);
const attachment = await this.getAlbumThumbnailAttachment(album); const attachment = await this.getAlbumThumbnailAttachment(album);
const { server } = await this.getConfig({ withCache: false }); const { server } = await this.getConfig({ withCache: false });

View file

@ -7,6 +7,7 @@ export const userStub = {
...authStub.admin.user, ...authStub.admin.user,
password: 'admin_password', password: 'admin_password',
name: 'admin_name', name: 'admin_name',
id: 'admin_id',
storageLabel: 'admin', storageLabel: 'admin',
oauthId: '', oauthId: '',
shouldChangePassword: false, shouldChangePassword: false,

View file

@ -16,5 +16,6 @@ export const newJobRepositoryMock = (): Mocked<IJobRepository> => {
getJobCounts: vitest.fn(), getJobCounts: vitest.fn(),
clear: vitest.fn(), clear: vitest.fn(),
waitForQueueCompletion: vitest.fn(), waitForQueueCompletion: vitest.fn(),
removeJob: vitest.fn(),
}; };
}; };