sse für live collab eingebaut

This commit is contained in:
Bastian Wagner
2026-06-10 14:35:18 +02:00
parent 93a91c7bf6
commit 67b5fb8532
9 changed files with 507 additions and 17 deletions

View 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);
}
}
}

View File

@@ -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,

View File

@@ -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 {}

View File

@@ -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',

View File

@@ -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(