Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion server/src/interfaces/event.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type EventMap = {
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];

// album events
'album.update': [{ id: string; updatedBy: string }];
'album.update': [{ id: string; recipientIds: string[] }];
'album.invite': [{ id: string; userId: string }];

// asset events
Expand Down
10 changes: 8 additions & 2 deletions server/src/interfaces/job.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ export interface IBaseJob {
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 {
id: string;
source?: 'upload' | 'sidecar-write' | 'copy';
Expand Down Expand Up @@ -181,8 +186,8 @@ export interface INotifyAlbumInviteJob extends IEntityJob {
recipientId: string;
}

export interface INotifyAlbumUpdateJob extends IEntityJob {
senderId: string;
export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
recipientIds: string[];
}

export interface JobCounts {
Expand Down Expand Up @@ -310,4 +315,5 @@ export interface IJobRepository {
getQueueStatus(name: QueueName): Promise<QueueStatus>;
getJobCounts(name: QueueName): Promise<JobCounts>;
waitForQueueCompletion(...queues: QueueName[]): Promise<void>;
removeJob(jobId: string, name: JobName): Promise<IEntityJob | undefined>;
}
21 changes: 20 additions & 1 deletion server/src/repositories/job.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CronJob, CronTime } from 'cron';
import { setTimeout } from 'node:timers/promises';
import { bullConfig } from 'src/config';
import {
IEntityJob,
IJobRepository,
JobCounts,
JobItem,
Expand Down Expand Up @@ -250,6 +251,9 @@ export class JobRepository implements IJobRepository {

private getJobOptions(item: JobItem): JobsOptions | null {
switch (item.name) {
case JobName.NOTIFY_ALBUM_UPDATE: {
return { jobId: item.data.id, delay: item.data?.delay };
}
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
return { jobId: item.data.id };
}
Expand All @@ -259,7 +263,6 @@ export class JobRepository implements IJobRepository {
case JobName.QUEUE_FACIAL_RECOGNITION: {
return { jobId: JobName.QUEUE_FACIAL_RECOGNITION };
}

default: {
return null;
}
Expand All @@ -269,4 +272,20 @@ export class JobRepository implements IJobRepository {
private getQueue(queue: QueueName): Queue {
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;
}
}
6 changes: 1 addition & 5 deletions server/src/services/album.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,10 +537,6 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1',
});
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 () => {
Expand Down Expand Up @@ -583,7 +579,7 @@ describe(AlbumService.name, () => {
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(eventMock.emit).toHaveBeenCalledWith('album.update', {
id: 'album-123',
updatedBy: authStub.user1.user.id,
recipientIds: ['admin_id'],
});
});

Expand Down
8 changes: 7 additions & 1 deletion server/src/services/album.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,13 @@ export class AlbumService extends BaseService {
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;
Expand Down
48 changes: 22 additions & 26 deletions server/src/services/notification.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AssetFileType, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.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 { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
Expand Down Expand Up @@ -170,10 +170,10 @@ describe(NotificationService.name, () => {

describe('onAlbumUpdateEvent', () => {
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({
name: JobName.NOTIFY_ALBUM_UPDATE,
data: { id: '', senderId: '42' },
data: { id: 'album', recipientIds: ['42'], delay: 300_000 },
});
});
});
Expand Down Expand Up @@ -512,34 +512,17 @@ describe(NotificationService.name, () => {

describe('handleAlbumUpdate', () => {
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();
});

it('should skip if owner could not be found', async () => {
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();
});

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 () => {
albumMock.getById.mockResolvedValue({
...albumStub.emptyWithValidThumbnail,
Expand All @@ -548,7 +531,7 @@ describe(NotificationService.name, () => {
userMock.get.mockResolvedValueOnce(userStub.user1);
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(notificationMock.renderEmail).not.toHaveBeenCalled();
});
Expand All @@ -571,7 +554,7 @@ describe(NotificationService.name, () => {
});
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(notificationMock.renderEmail).not.toHaveBeenCalled();
});
Expand All @@ -594,7 +577,7 @@ describe(NotificationService.name, () => {
});
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(notificationMock.renderEmail).not.toHaveBeenCalled();
});
Expand All @@ -607,11 +590,24 @@ describe(NotificationService.name, () => {
userMock.get.mockResolvedValue(userStub.user1);
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(notificationMock.renderEmail).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', () => {
Expand Down
36 changes: 32 additions & 4 deletions server/src/services/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { AlbumEntity } from 'src/entities/album.entity';
import { ArgOf } from 'src/interfaces/event.interface';
import {
IEmailJob,
IEntityJob,
INotifyAlbumInviteJob,
INotifyAlbumUpdateJob,
INotifySignupJob,
JobItem,
JobName,
JobStatus,
} from 'src/interfaces/job.interface';
Expand All @@ -21,6 +23,8 @@ import { getPreferences } from 'src/utils/preferences';

@Injectable()
export class NotificationService extends BaseService {
private static albumUpdateEmailDelayMs = 300_000;

@OnEvent({ name: 'config.update' })
onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) {
this.eventRepository.clientBroadcast('on_config_update');
Expand Down Expand Up @@ -100,8 +104,30 @@ export class NotificationService extends BaseService {
}

@OnEvent({ name: 'album.update' })
async onAlbumUpdate({ id, updatedBy }: ArgOf<'album.update'>) {
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } });
async onAlbumUpdate({ id, recipientIds }: ArgOf<'album.update'>) {
// 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' })
Expand Down Expand Up @@ -225,7 +251,7 @@ export class NotificationService extends BaseService {
return JobStatus.SUCCESS;
}

async handleAlbumUpdate({ id, senderId }: INotifyAlbumUpdateJob) {
async handleAlbumUpdate({ id, recipientIds }: INotifyAlbumUpdateJob) {
const album = await this.albumRepository.getById(id, { withAssets: false });

if (!album) {
Expand All @@ -237,7 +263,9 @@ export class NotificationService extends BaseService {
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 { server } = await this.getConfig({ withCache: false });
Expand Down
1 change: 1 addition & 0 deletions server/test/fixtures/user.stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const userStub = {
...authStub.admin.user,
password: 'admin_password',
name: 'admin_name',
id: 'admin_id',
storageLabel: 'admin',
oauthId: '',
shouldChangePassword: false,
Expand Down
1 change: 1 addition & 0 deletions server/test/repositories/job.repository.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ export const newJobRepositoryMock = (): Mocked<IJobRepository> => {
getJobCounts: vitest.fn(),
clear: vitest.fn(),
waitForQueueCompletion: vitest.fn(),
removeJob: vitest.fn(),
};
};
Loading