sse für live collab eingebaut
This commit is contained in:
97
listify-api/src/lists/list-realtime.service.ts
Normal file
97
listify-api/src/lists/list-realtime.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Injectable, MessageEvent } from '@nestjs/common';
|
||||
import { Observable, Observer } from 'rxjs';
|
||||
import { UserList } from '../list-templates/list-template.types';
|
||||
|
||||
export type ListRealtimeEvent =
|
||||
| {
|
||||
type: 'list.snapshot';
|
||||
data: UserList;
|
||||
}
|
||||
| {
|
||||
type: 'list.deleted';
|
||||
data: { listId: string };
|
||||
}
|
||||
| {
|
||||
type: 'heartbeat';
|
||||
data: { at: string };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ListRealtimeService {
|
||||
// In-memory SSE fanout for one API process. If the API is scaled horizontally,
|
||||
// replace this map with a shared pub/sub backend while keeping the event shape.
|
||||
private readonly channels = new Map<string, Set<Observer<ListRealtimeEvent>>>();
|
||||
|
||||
/**
|
||||
* Opens an owner-scoped stream. The controller authenticates the request before
|
||||
* calling this method, so subscribers only receive their own list events.
|
||||
*/
|
||||
eventsFor(ownerId: string): Observable<MessageEvent> {
|
||||
return new Observable<ListRealtimeEvent>((observer) => {
|
||||
this.addObserver(ownerId, observer);
|
||||
|
||||
// Keep proxies and browsers from treating an otherwise quiet stream as idle.
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
observer.next({
|
||||
type: 'heartbeat',
|
||||
data: { at: new Date().toISOString() },
|
||||
});
|
||||
}, 25_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
this.removeObserver(ownerId, observer);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
publishSnapshot(ownerId: string, list: UserList): void {
|
||||
this.publish(ownerId, {
|
||||
type: 'list.snapshot',
|
||||
data: list,
|
||||
});
|
||||
}
|
||||
|
||||
publishDeleted(ownerId: string, listId: string): void {
|
||||
this.publish(ownerId, {
|
||||
type: 'list.deleted',
|
||||
data: { listId },
|
||||
});
|
||||
}
|
||||
|
||||
private publish(ownerId: string, event: ListRealtimeEvent): void {
|
||||
const observers = this.channels.get(ownerId);
|
||||
|
||||
if (!observers) {
|
||||
return;
|
||||
}
|
||||
|
||||
observers.forEach((observer) => observer.next(event));
|
||||
}
|
||||
|
||||
private addObserver(
|
||||
ownerId: string,
|
||||
observer: Observer<ListRealtimeEvent>,
|
||||
): void {
|
||||
const observers = this.channels.get(ownerId) ?? new Set();
|
||||
observers.add(observer);
|
||||
this.channels.set(ownerId, observers);
|
||||
}
|
||||
|
||||
private removeObserver(
|
||||
ownerId: string,
|
||||
observer: Observer<ListRealtimeEvent>,
|
||||
): void {
|
||||
const observers = this.channels.get(ownerId);
|
||||
|
||||
if (!observers) {
|
||||
return;
|
||||
}
|
||||
|
||||
observers.delete(observer);
|
||||
|
||||
if (observers.size === 0) {
|
||||
this.channels.delete(ownerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,22 +7,27 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
Sse,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { CreateListDto } from './dto/create-list.dto';
|
||||
import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto';
|
||||
import { UpdateListDto } from './dto/update-list.dto';
|
||||
import { ListRealtimeService } from './list-realtime.service';
|
||||
import { ListsService } from './lists.service';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.types';
|
||||
import type { MessageEvent } from '@nestjs/common';
|
||||
|
||||
@Controller('lists')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ListsController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly listRealtimeService: ListRealtimeService,
|
||||
private readonly listsService: ListsService,
|
||||
) {}
|
||||
|
||||
@@ -39,6 +44,11 @@ export class ListsController {
|
||||
return this.listsService.listLists(this.requireUserId(request));
|
||||
}
|
||||
|
||||
@Sse('events')
|
||||
listEvents(@Req() request: AuthenticatedRequest): Observable<MessageEvent> {
|
||||
return this.listRealtimeService.eventsFor(this.requireUserId(request));
|
||||
}
|
||||
|
||||
@Get(':listId')
|
||||
getList(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ListsController } from './lists.controller';
|
||||
import { ListRealtimeService } from './list-realtime.service';
|
||||
import { ListsService } from './lists.service';
|
||||
import { UserListEntity } from './user-list.entity';
|
||||
import { UserListItemEntity } from './user-list-item.entity';
|
||||
@@ -14,7 +15,7 @@ import { UserListItemEntity } from './user-list-item.entity';
|
||||
TypeOrmModule.forFeature([UserListEntity, UserListItemEntity]),
|
||||
],
|
||||
controllers: [ListsController],
|
||||
providers: [ListsService],
|
||||
exports: [ListsService],
|
||||
providers: [ListRealtimeService, ListsService],
|
||||
exports: [ListRealtimeService, ListsService],
|
||||
})
|
||||
export class ListsModule {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { ListTemplate } from '../list-templates/list-template.types';
|
||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||
import { ListRealtimeService } from './list-realtime.service';
|
||||
import { ListsService } from './lists.service';
|
||||
import { UserListEntity } from './user-list.entity';
|
||||
import { UserListItemEntity } from './user-list-item.entity';
|
||||
@@ -48,6 +49,57 @@ describe('ListsService', () => {
|
||||
await expect(service.listLists('user-1')).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('publishes realtime snapshots and deletions for the owning user', async () => {
|
||||
const realtimeService = {
|
||||
publishDeleted: jest.fn(),
|
||||
publishSnapshot: jest.fn(),
|
||||
} satisfies Partial<ListRealtimeService>;
|
||||
service = new ListsService(
|
||||
new InMemoryRepository<UserListEntity>() as never,
|
||||
new InMemoryRepository<UserListItemEntity>() as never,
|
||||
undefined,
|
||||
realtimeService as never,
|
||||
);
|
||||
|
||||
const list = await service.createList('user-1', {
|
||||
name: 'Live Liste',
|
||||
kind: 'todo',
|
||||
});
|
||||
const withItem = await service.addItem('user-1', list.id, {
|
||||
title: 'Eintrag',
|
||||
});
|
||||
|
||||
await service.updateItem(
|
||||
'user-1',
|
||||
list.id,
|
||||
withItem.items[0].id,
|
||||
{ checked: true },
|
||||
'Test User',
|
||||
);
|
||||
await service.deleteList('user-1', list.id);
|
||||
|
||||
expect(realtimeService.publishSnapshot).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ id: list.id, name: 'Live Liste' }),
|
||||
);
|
||||
expect(realtimeService.publishSnapshot).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
id: list.id,
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
id: withItem.items[0].id,
|
||||
checked: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(realtimeService.publishDeleted).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
list.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('adds, updates, checks and deletes list items', async () => {
|
||||
const list = await service.createList('user-1', {
|
||||
name: 'Einkauf',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { CreateListFromTemplateDto } from '../list-templates/dto/create-list-from-template.dto';
|
||||
import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto';
|
||||
import { CreateListDto } from './dto/create-list.dto';
|
||||
import { ListRealtimeService } from './list-realtime.service';
|
||||
import { UpdateListDto } from './dto/update-list.dto';
|
||||
import { UserListEntity } from './user-list.entity';
|
||||
import { UserListItemEntity } from './user-list-item.entity';
|
||||
@@ -31,6 +32,8 @@ export class ListsService {
|
||||
private readonly listItemsRepository: Repository<UserListItemEntity>,
|
||||
@Optional()
|
||||
private readonly auditLogService?: AuditLogService,
|
||||
@Optional()
|
||||
private readonly listRealtimeService?: ListRealtimeService,
|
||||
) {}
|
||||
|
||||
async createList(ownerId: string, createDto: CreateListDto): Promise<UserList> {
|
||||
@@ -56,7 +59,10 @@ export class ListsService {
|
||||
},
|
||||
});
|
||||
|
||||
return this.toUserList(savedList);
|
||||
const userList = this.toUserList(savedList);
|
||||
this.listRealtimeService?.publishSnapshot(ownerId, userList);
|
||||
|
||||
return userList;
|
||||
}
|
||||
|
||||
async createListFromTemplate(
|
||||
@@ -108,7 +114,10 @@ export class ListsService {
|
||||
},
|
||||
});
|
||||
|
||||
return this.toUserList(savedList);
|
||||
const userList = this.toUserList(savedList);
|
||||
this.listRealtimeService?.publishSnapshot(ownerId, userList);
|
||||
|
||||
return userList;
|
||||
}
|
||||
|
||||
async listLists(ownerId: string): Promise<UserList[]> {
|
||||
@@ -160,7 +169,10 @@ export class ListsService {
|
||||
},
|
||||
});
|
||||
|
||||
return this.toUserList(savedList);
|
||||
const userList = this.toUserList(savedList);
|
||||
this.listRealtimeService?.publishSnapshot(ownerId, userList);
|
||||
|
||||
return userList;
|
||||
}
|
||||
|
||||
async deleteList(ownerId: string, listId: string): Promise<{ message: string }> {
|
||||
@@ -180,6 +192,8 @@ export class ListsService {
|
||||
metadata,
|
||||
});
|
||||
|
||||
this.listRealtimeService?.publishDeleted(ownerId, listId);
|
||||
|
||||
return { message: 'List deleted.' };
|
||||
}
|
||||
|
||||
@@ -209,7 +223,10 @@ export class ListsService {
|
||||
},
|
||||
});
|
||||
|
||||
return this.getList(ownerId, listId);
|
||||
const updatedList = await this.getList(ownerId, listId);
|
||||
this.listRealtimeService?.publishSnapshot(ownerId, updatedList);
|
||||
|
||||
return updatedList;
|
||||
}
|
||||
|
||||
async updateItem(
|
||||
@@ -279,7 +296,10 @@ export class ListsService {
|
||||
},
|
||||
});
|
||||
|
||||
return this.getList(ownerId, listId);
|
||||
const updatedList = await this.getList(ownerId, listId);
|
||||
this.listRealtimeService?.publishSnapshot(ownerId, updatedList);
|
||||
|
||||
return updatedList;
|
||||
}
|
||||
|
||||
async deleteItem(
|
||||
@@ -315,7 +335,10 @@ export class ListsService {
|
||||
},
|
||||
});
|
||||
|
||||
return this.getList(ownerId, listId);
|
||||
const updatedList = await this.getList(ownerId, listId);
|
||||
this.listRealtimeService?.publishSnapshot(ownerId, updatedList);
|
||||
|
||||
return updatedList;
|
||||
}
|
||||
|
||||
private async findOwnedList(
|
||||
|
||||
Reference in New Issue
Block a user