From 639ca651d848504cf1388edbad88376675046a20 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 19:05:40 +0200 Subject: [PATCH] feat: add NotificationsController --- .../dto/notification-query.dto.ts | 16 +++ .../notifications/notifications.controller.ts | 62 +++++++++ .../notifications/notifications.http.spec.ts | 119 ++++++++++++++++++ .../src/notifications/notifications.module.ts | 2 + 4 files changed, 199 insertions(+) create mode 100644 myteamwallet_backend/src/notifications/dto/notification-query.dto.ts create mode 100644 myteamwallet_backend/src/notifications/notifications.controller.ts create mode 100644 myteamwallet_backend/src/notifications/notifications.http.spec.ts diff --git a/myteamwallet_backend/src/notifications/dto/notification-query.dto.ts b/myteamwallet_backend/src/notifications/dto/notification-query.dto.ts new file mode 100644 index 0000000..cf0169a --- /dev/null +++ b/myteamwallet_backend/src/notifications/dto/notification-query.dto.ts @@ -0,0 +1,16 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Min } from 'class-validator'; + +export class NotificationQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number; +} diff --git a/myteamwallet_backend/src/notifications/notifications.controller.ts b/myteamwallet_backend/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..46b4920 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.controller.ts @@ -0,0 +1,62 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + Patch, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { TeamAccessService } from '../teams/team-access.service'; +import { NotificationQueryDto } from './dto/notification-query.dto'; +import { NotificationsService } from './notifications.service'; + +@ApiTags('Notifications') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller({ path: 'teams', version: '1' }) +export class NotificationsController { + constructor( + private readonly service: NotificationsService, + private readonly access: TeamAccessService, + ) {} + + @Get(':teamId/notifications') + async list( + @Req() req, + @Param('teamId', ParseIntPipe) teamId: number, + @Query() query: NotificationQueryDto, + ) { + await this.access.assertMember(Number(req.user.id), teamId); + return this.service.listForUser(Number(req.user.id), teamId, query.page ?? 1, query.limit ?? 20); + } + + @Get(':teamId/notifications/unread-count') + async unreadCount(@Req() req, @Param('teamId', ParseIntPipe) teamId: number) { + await this.access.assertMember(Number(req.user.id), teamId); + return { count: await this.service.getUnreadCount(Number(req.user.id), teamId) }; + } + + @Patch(':teamId/notifications/:id/read') + @HttpCode(HttpStatus.OK) + async markRead( + @Req() req, + @Param('teamId', ParseIntPipe) teamId: number, + @Param('id', ParseIntPipe) id: number, + ) { + await this.access.assertMember(Number(req.user.id), teamId); + await this.service.markRead(id, Number(req.user.id)); + } + + @Patch(':teamId/notifications/read-all') + @HttpCode(HttpStatus.OK) + async markAllRead(@Req() req, @Param('teamId', ParseIntPipe) teamId: number) { + await this.access.assertMember(Number(req.user.id), teamId); + await this.service.markAllRead(Number(req.user.id), teamId); + } +} diff --git a/myteamwallet_backend/src/notifications/notifications.http.spec.ts b/myteamwallet_backend/src/notifications/notifications.http.spec.ts new file mode 100644 index 0000000..ef3ca66 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.http.spec.ts @@ -0,0 +1,119 @@ +import { + INestApplication, + UnauthorizedException, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import validationOptions from '../utils/validation-options'; +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; +import { TeamAccessService } from '../teams/team-access.service'; + +describe('notifications HTTP boundary', () => { + let app: INestApplication; + const service = { + listForUser: jest.fn(), + getUnreadCount: jest.fn(), + markRead: jest.fn(), + markAllRead: jest.fn(), + }; + const access = { assertMember: jest.fn() }; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + controllers: [NotificationsController], + providers: [ + { provide: NotificationsService, useValue: service }, + { provide: TeamAccessService, useValue: access }, + ], + }) + .overrideGuard(AuthGuard('jwt')) + .useValue({ + canActivate(context) { + const httpRequest = context.switchToHttp().getRequest(); + if (httpRequest.headers.authorization !== 'Bearer user') { + throw new UnauthorizedException(); + } + httpRequest.user = { id: 42, role: { id: 2 } }; + return true; + }, + }) + .compile(); + app = module.createNestApplication(); + app.setGlobalPrefix('api'); + app.enableVersioning({ type: VersioningType.URI }); + app.useGlobalPipes(new ValidationPipe(validationOptions)); + await app.init(); + }); + + afterAll(() => app.close()); + beforeEach(() => jest.clearAllMocks()); + + it('requires authentication', async () => { + await request(app.getHttpServer()).get('/api/v1/teams/10/notifications').expect(401); + }); + + it('lists notifications for the authenticated user after checking membership', async () => { + access.assertMember.mockResolvedValue(undefined); + service.listForUser.mockResolvedValue({ data: [], page: 2, limit: 5, total: 0, hasNextPage: false }); + + await request(app.getHttpServer()) + .get('/api/v1/teams/10/notifications?page=2&limit=5') + .set('Authorization', 'Bearer user') + .expect(200); + + expect(access.assertMember).toHaveBeenCalledWith(42, 10); + expect(service.listForUser).toHaveBeenCalledWith(42, 10, 2, 5); + }); + + it('defaults to page 1 and limit 20 when not provided', async () => { + access.assertMember.mockResolvedValue(undefined); + service.listForUser.mockResolvedValue({ data: [], page: 1, limit: 20, total: 0, hasNextPage: false }); + + await request(app.getHttpServer()) + .get('/api/v1/teams/10/notifications') + .set('Authorization', 'Bearer user') + .expect(200); + + expect(service.listForUser).toHaveBeenCalledWith(42, 10, 1, 20); + }); + + it('returns the unread count', async () => { + access.assertMember.mockResolvedValue(undefined); + service.getUnreadCount.mockResolvedValue(4); + + const response = await request(app.getHttpServer()) + .get('/api/v1/teams/10/notifications/unread-count') + .set('Authorization', 'Bearer user') + .expect(200); + + expect(response.body).toEqual({ count: 4 }); + }); + + it('marks a single notification as read', async () => { + access.assertMember.mockResolvedValue(undefined); + service.markRead.mockResolvedValue(undefined); + + await request(app.getHttpServer()) + .patch('/api/v1/teams/10/notifications/7/read') + .set('Authorization', 'Bearer user') + .expect(200); + + expect(service.markRead).toHaveBeenCalledWith(7, 42); + }); + + it('marks all notifications as read', async () => { + access.assertMember.mockResolvedValue(undefined); + service.markAllRead.mockResolvedValue(undefined); + + await request(app.getHttpServer()) + .patch('/api/v1/teams/10/notifications/read-all') + .set('Authorization', 'Bearer user') + .expect(200); + + expect(service.markAllRead).toHaveBeenCalledWith(42, 10); + }); +}); diff --git a/myteamwallet_backend/src/notifications/notifications.module.ts b/myteamwallet_backend/src/notifications/notifications.module.ts index 03bd1e0..4db0483 100644 --- a/myteamwallet_backend/src/notifications/notifications.module.ts +++ b/myteamwallet_backend/src/notifications/notifications.module.ts @@ -5,6 +5,7 @@ import { Player } from 'src/players/entities/player.entity'; import { TeamsModule } from 'src/teams/teams.module'; import { Notification } from './entities/notification.entity'; import { NotificationRecipient } from './entities/notification-recipient.entity'; +import { NotificationsController } from './notifications.controller'; import { NotificationsListener } from './notifications.listener'; import { NotificationsService } from './notifications.service'; @@ -14,6 +15,7 @@ import { NotificationsService } from './notifications.service'; LoggingModule, TeamsModule, ], + controllers: [NotificationsController], providers: [NotificationsService, NotificationsListener], }) export class NotificationsModule {}