feat: add NotificationsController

This commit is contained in:
Bastian Wagner
2026-08-04 19:05:40 +02:00
parent 8de4c11e24
commit 639ca651d8
4 changed files with 199 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import { Player } from 'src/players/entities/player.entity';
import { TeamsModule } from 'src/teams/teams.module'; import { TeamsModule } from 'src/teams/teams.module';
import { Notification } from './entities/notification.entity'; import { Notification } from './entities/notification.entity';
import { NotificationRecipient } from './entities/notification-recipient.entity'; import { NotificationRecipient } from './entities/notification-recipient.entity';
import { NotificationsController } from './notifications.controller';
import { NotificationsListener } from './notifications.listener'; import { NotificationsListener } from './notifications.listener';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
@@ -14,6 +15,7 @@ import { NotificationsService } from './notifications.service';
LoggingModule, LoggingModule,
TeamsModule, TeamsModule,
], ],
controllers: [NotificationsController],
providers: [NotificationsService, NotificationsListener], providers: [NotificationsService, NotificationsListener],
}) })
export class NotificationsModule {} export class NotificationsModule {}