feat(server): add websocket events for activity changes
- Add 'activity.change' event to event repository - Emit event when new activity (reaction/comment) is created - Add notification handler to broadcast activity changes to relevant users - Update frontend websocket types to include on_activity_change event - Update tests to mock album repository calls
This commit is contained in:
@@ -47,6 +47,9 @@ type EventMap = {
|
|||||||
];
|
];
|
||||||
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
|
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
|
||||||
|
|
||||||
|
// activity events
|
||||||
|
'activity.change': [{ recipientId: string[]; userId: string; albumId: string; assetId: string | null }];
|
||||||
|
|
||||||
// album events
|
// album events
|
||||||
'album.update': [
|
'album.update': [
|
||||||
{ id: string; recipientId: string[]; assetId: string[]; userId: string; status: 'added' | 'removed' },
|
{ id: string; recipientId: string[]; assetId: string[]; userId: string; status: 'added' | 'removed' },
|
||||||
@@ -54,6 +57,7 @@ type EventMap = {
|
|||||||
'album.invite': [{ id: string; userId: string }];
|
'album.invite': [{ id: string; userId: string }];
|
||||||
|
|
||||||
// asset events
|
// asset events
|
||||||
|
'asset.update': [{ assetIds: string[]; userId: string }];
|
||||||
'asset.person': [
|
'asset.person': [
|
||||||
{ assetId: string; userId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' },
|
{ assetId: string; userId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' },
|
||||||
];
|
];
|
||||||
@@ -102,11 +106,12 @@ export type ArgsOf<T extends EmitEvent> = EventMap[T];
|
|||||||
export interface ClientEventMap {
|
export interface ClientEventMap {
|
||||||
on_upload_success: [AssetResponseDto];
|
on_upload_success: [AssetResponseDto];
|
||||||
on_user_delete: [string];
|
on_user_delete: [string];
|
||||||
|
on_activity_change: [{ albumId: string; assetId: string | null }];
|
||||||
on_album_update: [{ albumId: string; assetId: string[]; status: 'added' | 'removed' }];
|
on_album_update: [{ albumId: string; assetId: string[]; status: 'added' | 'removed' }];
|
||||||
on_asset_person: [{ assetId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' }];
|
on_asset_person: [{ assetId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' }];
|
||||||
on_asset_delete: [string];
|
on_asset_delete: [string];
|
||||||
on_asset_trash: [string[]];
|
on_asset_trash: [string[]];
|
||||||
on_asset_update: [AssetResponseDto];
|
on_asset_update: [string[]];
|
||||||
on_asset_hidden: [string];
|
on_asset_hidden: [string];
|
||||||
on_asset_restore: [string[]];
|
on_asset_restore: [string[]];
|
||||||
on_asset_stack_update: string[];
|
on_asset_stack_update: string[];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { ReactionType } from 'src/dtos/activity.dto';
|
import { ReactionType } from 'src/dtos/activity.dto';
|
||||||
import { ActivityService } from 'src/services/activity.service';
|
import { ActivityService } from 'src/services/activity.service';
|
||||||
|
import { albumStub } from 'test/fixtures/album.stub';
|
||||||
import { factory, newUuid, newUuids } from 'test/small.factory';
|
import { factory, newUuid, newUuids } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ describe(ActivityService.name, () => {
|
|||||||
|
|
||||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.create.mockResolvedValue(activity);
|
mocks.activity.create.mockResolvedValue(activity);
|
||||||
|
mocks.album.getById.mockResolvedValue({ ...albumStub.empty, owner: factory.user({ id: userId }), albumUsers: [] });
|
||||||
|
|
||||||
await sut.create(factory.auth({ user: { id: userId } }), {
|
await sut.create(factory.auth({ user: { id: userId } }), {
|
||||||
albumId,
|
albumId,
|
||||||
@@ -115,6 +117,7 @@ describe(ActivityService.name, () => {
|
|||||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.create.mockResolvedValue(activity);
|
mocks.activity.create.mockResolvedValue(activity);
|
||||||
mocks.activity.search.mockResolvedValue([]);
|
mocks.activity.search.mockResolvedValue([]);
|
||||||
|
mocks.album.getById.mockResolvedValue({ ...albumStub.empty, owner: factory.user({ id: userId }), albumUsers: [] });
|
||||||
|
|
||||||
await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });
|
await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { Activity } from 'src/database';
|
import { Activity } from 'src/database';
|
||||||
import {
|
import {
|
||||||
ActivityCreateDto,
|
ActivityCreateDto,
|
||||||
@@ -58,11 +58,24 @@ export class ActivityService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!activity) {
|
if (!activity) {
|
||||||
|
const album = await this.albumRepository.getById(common.albumId, { withAssets: false });
|
||||||
|
if (!album) {
|
||||||
|
throw new BadRequestException('Album not found');
|
||||||
|
}
|
||||||
activity = await this.activityRepository.create({
|
activity = await this.activityRepository.create({
|
||||||
...common,
|
...common,
|
||||||
isLiked: dto.type === ReactionType.LIKE,
|
isLiked: dto.type === ReactionType.LIKE,
|
||||||
comment: dto.comment,
|
comment: dto.comment,
|
||||||
});
|
});
|
||||||
|
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
|
||||||
|
(userId) => userId !== auth.user.id,
|
||||||
|
);
|
||||||
|
await this.eventRepository.emit('activity.change', {
|
||||||
|
recipientId: allUsersExceptUs,
|
||||||
|
userId: common.userId,
|
||||||
|
albumId: activity.albumId,
|
||||||
|
assetId: activity.assetId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { duplicate, value: mapActivity(activity) };
|
return { duplicate, value: mapActivity(activity) };
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { AlbumUser } from 'src/database';
|
|||||||
import { SystemConfigDto } from 'src/dtos/system-config.dto';
|
import { SystemConfigDto } from 'src/dtos/system-config.dto';
|
||||||
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
|
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
|
||||||
import { NotificationService } from 'src/services/notification.service';
|
import { NotificationService } from 'src/services/notification.service';
|
||||||
import { INotifyAlbumUpdateJob } from 'src/types';
|
|
||||||
import { albumStub } from 'test/fixtures/album.stub';
|
import { albumStub } from 'test/fixtures/album.stub';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
@@ -154,7 +153,7 @@ 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: 'album', recipientId: '42' });
|
await sut.onAlbumUpdate({ id: 'album', recipientId: ['42'], userId: '', assetId: [], status: 'added' });
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.NOTIFY_ALBUM_UPDATE,
|
name: JobName.NOTIFY_ALBUM_UPDATE,
|
||||||
data: { id: 'album', recipientId: '42', delay: 300_000 },
|
data: { id: 'album', recipientId: '42', delay: 300_000 },
|
||||||
@@ -499,7 +498,13 @@ describe(NotificationService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should add new recipients for new images if job is already queued', async () => {
|
it('should add new recipients for new images if job is already queued', async () => {
|
||||||
await sut.onAlbumUpdate({ id: '1', recipientId: '2' } as INotifyAlbumUpdateJob);
|
await sut.onAlbumUpdate({
|
||||||
|
id: '1',
|
||||||
|
recipientId: ['2'],
|
||||||
|
userId: '',
|
||||||
|
assetId: [],
|
||||||
|
status: 'added',
|
||||||
|
});
|
||||||
expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NOTIFY_ALBUM_UPDATE, '1/2');
|
expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NOTIFY_ALBUM_UPDATE, '1/2');
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.NOTIFY_ALBUM_UPDATE,
|
name: JobName.NOTIFY_ALBUM_UPDATE,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import {
|
import {
|
||||||
mapNotification,
|
mapNotification,
|
||||||
@@ -128,6 +127,15 @@ export class NotificationService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent({ name: 'activity.change' })
|
||||||
|
onActivityChange({ recipientId, assetId, userId, albumId }: ArgOf<'activity.change'>) {
|
||||||
|
for (const recipient of recipientId) {
|
||||||
|
this.eventRepository.clientSend('on_activity_change', recipient, { albumId, assetId });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventRepository.clientSend('on_activity_change', userId, { albumId, assetId });
|
||||||
|
}
|
||||||
|
|
||||||
@OnEvent({ name: 'asset.person' })
|
@OnEvent({ name: 'asset.person' })
|
||||||
onAssetPerson({ assetId, userId, personId, status }: ArgOf<'asset.person'>) {
|
onAssetPerson({ assetId, userId, personId, status }: ArgOf<'asset.person'>) {
|
||||||
this.eventRepository.clientSend('on_asset_person', userId, { assetId, personId, status });
|
this.eventRepository.clientSend('on_asset_person', userId, { assetId, personId, status });
|
||||||
@@ -158,16 +166,17 @@ export class NotificationService extends BaseService {
|
|||||||
this.eventRepository.clientSend('on_asset_trash', userId, assetIds);
|
this.eventRepository.clientSend('on_asset_trash', userId, assetIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent({ name: 'asset.update' })
|
||||||
|
onAssetUpdate({ assetIds, userId }: ArgOf<'asset.update'>) {
|
||||||
|
this.eventRepository.clientSend('on_asset_update', userId, assetIds);
|
||||||
|
}
|
||||||
|
|
||||||
@OnEvent({ name: 'asset.metadataExtracted' })
|
@OnEvent({ name: 'asset.metadataExtracted' })
|
||||||
async onAssetMetadataExtracted({ assetId, userId, source }: ArgOf<'asset.metadataExtracted'>) {
|
onAssetMetadataExtracted({ assetId, userId, source }: ArgOf<'asset.metadataExtracted'>) {
|
||||||
if (source !== 'sidecar-write') {
|
if (source !== 'sidecar-write') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.eventRepository.clientSend('on_asset_update', userId, [assetId]);
|
||||||
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
|
|
||||||
if (asset) {
|
|
||||||
this.eventRepository.clientSend('on_asset_update', userId, mapAsset(asset));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent({ name: 'assets.restore' })
|
@OnEvent({ name: 'assets.restore' })
|
||||||
@@ -211,6 +220,11 @@ export class NotificationService extends BaseService {
|
|||||||
name: JobName.NOTIFY_ALBUM_UPDATE,
|
name: JobName.NOTIFY_ALBUM_UPDATE,
|
||||||
data: { id, recipientId: recipient, delay: NotificationService.albumUpdateEmailDelayMs },
|
data: { id, recipientId: recipient, delay: NotificationService.albumUpdateEmailDelayMs },
|
||||||
});
|
});
|
||||||
|
this.eventRepository.clientSend('on_album_update', recipient, { albumId: id, assetId, status });
|
||||||
|
}
|
||||||
|
} else if (status === 'removed') {
|
||||||
|
for (const recipient of recipientId) {
|
||||||
|
this.eventRepository.clientSend('on_album_update', recipient, { albumId: id, assetId, status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface ReleaseEvent {
|
|||||||
export interface Events {
|
export interface Events {
|
||||||
on_upload_success: (asset: AssetResponseDto) => void;
|
on_upload_success: (asset: AssetResponseDto) => void;
|
||||||
on_user_delete: (id: string) => void;
|
on_user_delete: (id: string) => void;
|
||||||
|
on_activity_change: (data: { albumId: string; assetId: string | null }) => void;
|
||||||
on_album_update: (data: { albumId: string; assetId: string[]; status: 'added' | 'removed' }) => void;
|
on_album_update: (data: { albumId: string; assetId: string[]; status: 'added' | 'removed' }) => void;
|
||||||
on_asset_person: ({
|
on_asset_person: ({
|
||||||
assetId,
|
assetId,
|
||||||
@@ -27,7 +28,7 @@ export interface Events {
|
|||||||
}) => void;
|
}) => void;
|
||||||
on_asset_delete: (assetId: string) => void;
|
on_asset_delete: (assetId: string) => void;
|
||||||
on_asset_trash: (assetIds: string[]) => void;
|
on_asset_trash: (assetIds: string[]) => void;
|
||||||
on_asset_update: (asset: AssetResponseDto) => void;
|
on_asset_update: (assetIds: string[]) => void;
|
||||||
on_asset_hidden: (assetId: string) => void;
|
on_asset_hidden: (assetId: string) => void;
|
||||||
on_asset_restore: (assetIds: string[]) => void;
|
on_asset_restore: (assetIds: string[]) => void;
|
||||||
on_asset_stack_update: (assetIds: string[]) => void;
|
on_asset_stack_update: (assetIds: string[]) => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user