mirror of
https://github.com/immich-app/immich.git
synced 2025-01-07 00:50:23 -05:00
refactor(server): client emit events (#12606)
* refactor(server): client emit events * chore: test coverage
This commit is contained in:
parent
7b737786b3
commit
ba57646f9f
8 changed files with 142 additions and 20 deletions
|
@ -22,10 +22,24 @@ type EmitEventMap = {
|
||||||
'asset.untag': [{ assetId: string }];
|
'asset.untag': [{ assetId: string }];
|
||||||
'asset.hide': [{ assetId: string; userId: string }];
|
'asset.hide': [{ assetId: string; userId: string }];
|
||||||
'asset.show': [{ assetId: string; userId: string }];
|
'asset.show': [{ assetId: string; userId: string }];
|
||||||
|
'asset.trash': [{ assetId: string; userId: string }];
|
||||||
|
'asset.delete': [{ assetId: string; userId: string }];
|
||||||
|
|
||||||
|
// asset bulk events
|
||||||
|
'assets.trash': [{ assetIds: string[]; userId: string }];
|
||||||
|
'assets.restore': [{ assetIds: string[]; userId: string }];
|
||||||
|
|
||||||
// session events
|
// session events
|
||||||
'session.delete': [{ sessionId: string }];
|
'session.delete': [{ sessionId: string }];
|
||||||
|
|
||||||
|
// stack events
|
||||||
|
'stack.create': [{ stackId: string; userId: string }];
|
||||||
|
'stack.update': [{ stackId: string; userId: string }];
|
||||||
|
'stack.delete': [{ stackId: string; userId: string }];
|
||||||
|
|
||||||
|
// stack bulk events
|
||||||
|
'stacks.delete': [{ stackIds: string[]; userId: string }];
|
||||||
|
|
||||||
// user events
|
// user events
|
||||||
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
|
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
|
||||||
};
|
};
|
||||||
|
|
|
@ -30,7 +30,7 @@ import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entit
|
||||||
import { AssetType, Permission } from 'src/enum';
|
import { AssetType, Permission } from 'src/enum';
|
||||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||||
|
@ -194,8 +194,7 @@ export class AssetMediaService {
|
||||||
const copiedPhoto = await this.createCopy(asset);
|
const copiedPhoto = await this.createCopy(asset);
|
||||||
// and immediate trash it
|
// and immediate trash it
|
||||||
await this.assetRepository.softDeleteAll([copiedPhoto.id]);
|
await this.assetRepository.softDeleteAll([copiedPhoto.id]);
|
||||||
|
await this.eventRepository.emit('asset.trash', { assetId: copiedPhoto.id, userId: auth.user.id });
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]);
|
|
||||||
|
|
||||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { Permission } from 'src/enum';
|
import { Permission } from 'src/enum';
|
||||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import {
|
import {
|
||||||
IAssetDeleteJob,
|
IAssetDeleteJob,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
|
@ -273,7 +273,8 @@ export class AssetService {
|
||||||
if (!asset.libraryId) {
|
if (!asset.libraryId) {
|
||||||
await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0));
|
await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0));
|
||||||
}
|
}
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id);
|
|
||||||
|
await this.eventRepository.emit('asset.delete', { assetId: id, userId: asset.ownerId });
|
||||||
|
|
||||||
// delete the motion if it is not used by another asset
|
// delete the motion if it is not used by another asset
|
||||||
if (asset.livePhotoVideoId) {
|
if (asset.livePhotoVideoId) {
|
||||||
|
@ -311,7 +312,7 @@ export class AssetService {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.assetRepository.softDeleteAll(ids);
|
await this.assetRepository.softDeleteAll(ids);
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, ids);
|
await this.eventRepository.emit('assets.trash', { assetIds: ids, userId: auth.user.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -144,6 +144,23 @@ describe(NotificationService.name, () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('onAssetHide', () => {
|
||||||
|
it('should send connected clients an event', () => {
|
||||||
|
sut.onAssetHide({ assetId: 'asset-id', userId: 'user-id' });
|
||||||
|
expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_hidden', 'user-id', 'asset-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onAssetShow', () => {
|
||||||
|
it('should queue the generate thumbnail job', async () => {
|
||||||
|
await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' });
|
||||||
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
|
name: JobName.GENERATE_THUMBNAIL,
|
||||||
|
data: { id: 'asset-id', notify: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('onUserSignupEvent', () => {
|
describe('onUserSignupEvent', () => {
|
||||||
it('skips when notify is false', async () => {
|
it('skips when notify is false', async () => {
|
||||||
await sut.onUserSignup({ id: '', notify: false });
|
await sut.onUserSignup({ id: '', notify: false });
|
||||||
|
@ -179,6 +196,62 @@ describe(NotificationService.name, () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('onAssetTrash', () => {
|
||||||
|
it('should send connected clients an event', () => {
|
||||||
|
sut.onAssetTrash({ assetId: 'asset-id', userId: 'user-id' });
|
||||||
|
expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onAssetDelete', () => {
|
||||||
|
it('should send connected clients an event', () => {
|
||||||
|
sut.onAssetDelete({ assetId: 'asset-id', userId: 'user-id' });
|
||||||
|
expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_delete', 'user-id', 'asset-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onAssetsTrash', () => {
|
||||||
|
it('should send connected clients an event', () => {
|
||||||
|
sut.onAssetsTrash({ assetIds: ['asset-id'], userId: 'user-id' });
|
||||||
|
expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onAssetsRestore', () => {
|
||||||
|
it('should send connected clients an event', () => {
|
||||||
|
sut.onAssetsRestore({ assetIds: ['asset-id'], userId: 'user-id' });
|
||||||
|
expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_restore', 'user-id', ['asset-id']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onStackCreate', () => {
|
||||||
|
it('should send connected clients an event', () => {
|
||||||
|
sut.onStackCreate({ stackId: 'stack-id', userId: 'user-id' });
|
||||||
|
expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onStackUpdate', () => {
|
||||||
|
it('should send connected clients an event', () => {
|
||||||
|
sut.onStackUpdate({ stackId: 'stack-id', userId: 'user-id' });
|
||||||
|
expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onStackDelete', () => {
|
||||||
|
it('should send connected clients an event', () => {
|
||||||
|
sut.onStackDelete({ stackId: 'stack-id', userId: 'user-id' });
|
||||||
|
expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onStacksDelete', () => {
|
||||||
|
it('should send connected clients an event', () => {
|
||||||
|
sut.onStacksDelete({ stackIds: ['stack-id'], userId: 'user-id' });
|
||||||
|
expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('sendTestEmail', () => {
|
describe('sendTestEmail', () => {
|
||||||
it('should throw error if user could not be found', async () => {
|
it('should throw error if user could not be found', async () => {
|
||||||
await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found');
|
await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found');
|
||||||
|
|
|
@ -60,7 +60,6 @@ export class NotificationService {
|
||||||
|
|
||||||
@OnEmit({ event: 'asset.hide' })
|
@OnEmit({ event: 'asset.hide' })
|
||||||
onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) {
|
onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) {
|
||||||
// Notify clients to hide the linked live photo asset
|
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId);
|
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,6 +68,46 @@ export class NotificationService {
|
||||||
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAIL, data: { id: assetId, notify: true } });
|
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAIL, data: { id: assetId, notify: true } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEmit({ event: 'asset.trash' })
|
||||||
|
onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) {
|
||||||
|
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, [assetId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEmit({ event: 'asset.delete' })
|
||||||
|
onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) {
|
||||||
|
this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, userId, assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEmit({ event: 'assets.trash' })
|
||||||
|
onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) {
|
||||||
|
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, assetIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEmit({ event: 'assets.restore' })
|
||||||
|
onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) {
|
||||||
|
this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, userId, assetIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEmit({ event: 'stack.create' })
|
||||||
|
onStackCreate({ userId }: ArgOf<'stack.create'>) {
|
||||||
|
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEmit({ event: 'stack.update' })
|
||||||
|
onStackUpdate({ userId }: ArgOf<'stack.update'>) {
|
||||||
|
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEmit({ event: 'stack.delete' })
|
||||||
|
onStackDelete({ userId }: ArgOf<'stack.delete'>) {
|
||||||
|
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEmit({ event: 'stacks.delete' })
|
||||||
|
onStacksDelete({ userId }: ArgOf<'stacks.delete'>) {
|
||||||
|
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
|
||||||
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'user.signup' })
|
@OnEmit({ event: 'user.signup' })
|
||||||
async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
|
async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
|
||||||
if (notify) {
|
if (notify) {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto';
|
import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto';
|
||||||
import { Permission } from 'src/enum';
|
import { Permission } from 'src/enum';
|
||||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import { IStackRepository } from 'src/interfaces/stack.interface';
|
import { IStackRepository } from 'src/interfaces/stack.interface';
|
||||||
import { requireAccess } from 'src/utils/access';
|
import { requireAccess } from 'src/utils/access';
|
||||||
|
|
||||||
|
@ -30,7 +30,7 @@ export class StackService {
|
||||||
|
|
||||||
const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds });
|
const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds });
|
||||||
|
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []);
|
await this.eventRepository.emit('stack.create', { stackId: stack.id, userId: auth.user.id });
|
||||||
|
|
||||||
return mapStack(stack, { auth });
|
return mapStack(stack, { auth });
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,7 @@ export class StackService {
|
||||||
|
|
||||||
const updatedStack = await this.stackRepository.update({ id, primaryAssetId: dto.primaryAssetId });
|
const updatedStack = await this.stackRepository.update({ id, primaryAssetId: dto.primaryAssetId });
|
||||||
|
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []);
|
await this.eventRepository.emit('stack.update', { stackId: id, userId: auth.user.id });
|
||||||
|
|
||||||
return mapStack(updatedStack, { auth });
|
return mapStack(updatedStack, { auth });
|
||||||
}
|
}
|
||||||
|
@ -58,15 +58,13 @@ export class StackService {
|
||||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||||
await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: [id] });
|
await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: [id] });
|
||||||
await this.stackRepository.delete(id);
|
await this.stackRepository.delete(id);
|
||||||
|
await this.eventRepository.emit('stack.delete', { stackId: id, userId: auth.user.id });
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
|
async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
|
||||||
await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: dto.ids });
|
await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: dto.ids });
|
||||||
await this.stackRepository.deleteAll(dto.ids);
|
await this.stackRepository.deleteAll(dto.ids);
|
||||||
|
await this.eventRepository.emit('stacks.delete', { stackIds: dto.ids, userId: auth.user.id });
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findOrFail(id: string) {
|
private async findOrFail(id: string) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||||
import { TrashService } from 'src/services/trash.service';
|
import { TrashService } from 'src/services/trash.service';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
|
@ -62,9 +62,7 @@ describe(TrashService.name, () => {
|
||||||
assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
|
assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
|
||||||
await expect(sut.restore(authStub.user1)).resolves.toBeUndefined();
|
await expect(sut.restore(authStub.user1)).resolves.toBeUndefined();
|
||||||
expect(assetMock.restoreAll).toHaveBeenCalledWith([assetStub.image.id]);
|
expect(assetMock.restoreAll).toHaveBeenCalledWith([assetStub.image.id]);
|
||||||
expect(eventMock.clientSend).toHaveBeenCalledWith(ClientEvent.ASSET_RESTORE, authStub.user1.user.id, [
|
expect(eventMock.emit).toHaveBeenCalledWith('assets.restore', { assetIds: ['asset-id'], userId: 'user-id' });
|
||||||
assetStub.image.id,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { Permission } from 'src/enum';
|
import { Permission } from 'src/enum';
|
||||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/interfaces/job.interface';
|
import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/interfaces/job.interface';
|
||||||
import { requireAccess } from 'src/utils/access';
|
import { requireAccess } from 'src/utils/access';
|
||||||
import { usePagination } from 'src/utils/pagination';
|
import { usePagination } from 'src/utils/pagination';
|
||||||
|
@ -64,6 +64,6 @@ export class TrashService {
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.assetRepository.restoreAll(ids);
|
await this.assetRepository.restoreAll(ids);
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, auth.user.id, ids);
|
await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue