GET /api/v1/cards/stream (Bearer ou ?access_token=). Events card.created|updated|deleted, response.added + heartbeat. Co-authored-by: Cursor <cursoragent@cursor.com>
95 lines
2.8 KiB
TypeScript
95 lines
2.8 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Headers,
|
|
MessageEvent,
|
|
Query,
|
|
Sse,
|
|
UnauthorizedException,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import {
|
|
ApiBearerAuth,
|
|
ApiOperation,
|
|
ApiQuery,
|
|
ApiTags,
|
|
} from '@nestjs/swagger';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { Observable } from 'rxjs';
|
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
|
import { Public } from 'src/common/decorators/public.decorator';
|
|
import { User } from 'src/common/decorators/user.decorator';
|
|
import { RoleType } from 'src/entities/users.entity';
|
|
import { CardsRealtimeService } from './cards-realtime.service';
|
|
|
|
/**
|
|
* SSE Cartes — #195
|
|
* Auth : Bearer (recommandé) ou `?access_token=` (EventSource navigateur).
|
|
*/
|
|
@ApiTags('Cartes')
|
|
@Controller('cards')
|
|
export class CardsRealtimeController {
|
|
constructor(
|
|
private readonly realtime: CardsRealtimeService,
|
|
private readonly jwtService: JwtService,
|
|
private readonly configService: ConfigService,
|
|
) {}
|
|
|
|
@Sse('stream')
|
|
@Public()
|
|
@ApiOperation({
|
|
summary: 'Flux SSE bulles (card.created|updated|deleted, response.added) — #195',
|
|
})
|
|
@ApiQuery({
|
|
name: 'access_token',
|
|
required: false,
|
|
description: 'JWT si pas de header Authorization (EventSource)',
|
|
})
|
|
@ApiBearerAuth('access-token')
|
|
async stream(
|
|
@Headers('authorization') authorization?: string,
|
|
@Query('access_token') accessToken?: string,
|
|
): Promise<Observable<MessageEvent>> {
|
|
const userId = await this.resolveUserId(authorization, accessToken);
|
|
return this.realtime.streamFor(userId);
|
|
}
|
|
|
|
/** Variante gardée (clients qui envoient Bearer correctement). */
|
|
@Get('stream/info')
|
|
@UseGuards(AuthGuard, RolesGuard)
|
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
|
@ApiBearerAuth('access-token')
|
|
@ApiOperation({ summary: 'Debug : abonnés SSE pour mon user — #195' })
|
|
info(@User('id') userId: string) {
|
|
return {
|
|
user_id: userId,
|
|
subscribers: this.realtime.subscriberCount(userId),
|
|
};
|
|
}
|
|
|
|
private async resolveUserId(
|
|
authorization?: string,
|
|
accessToken?: string,
|
|
): Promise<string> {
|
|
let token = accessToken?.trim();
|
|
if (!token && authorization?.startsWith('Bearer ')) {
|
|
token = authorization.slice(7).trim();
|
|
}
|
|
if (!token) {
|
|
throw new UnauthorizedException('Token manquant (Bearer ou access_token)');
|
|
}
|
|
try {
|
|
const payload = await this.jwtService.verifyAsync<{ sub: string }>(token, {
|
|
secret: this.configService.get<string>('jwt.accessSecret'),
|
|
});
|
|
if (!payload?.sub) throw new UnauthorizedException('Token invalide');
|
|
return payload.sub;
|
|
} catch {
|
|
throw new UnauthorizedException('Token invalide ou expiré');
|
|
}
|
|
}
|
|
}
|