Merge branch 'worktree-notification-center'

This commit is contained in:
Bastian Wagner
2026-08-04 20:51:32 +02:00
51 changed files with 5472 additions and 17 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"@nestjs/common": "9.1.6", "@nestjs/common": "9.1.6",
"@nestjs/config": "2.2.0", "@nestjs/config": "2.2.0",
"@nestjs/core": "9.1.6", "@nestjs/core": "9.1.6",
"@nestjs/event-emitter": "^2.1.1",
"@nestjs/jwt": "9.0.0", "@nestjs/jwt": "9.0.0",
"@nestjs/passport": "9.0.0", "@nestjs/passport": "9.0.0",
"@nestjs/platform-express": "9.1.6", "@nestjs/platform-express": "9.1.6",
@@ -3304,6 +3305,19 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/@nestjs/event-emitter": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.1.1.tgz",
"integrity": "sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==",
"license": "MIT",
"dependencies": {
"eventemitter2": "6.4.9"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/@nestjs/jwt": { "node_modules/@nestjs/jwt": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz",
@@ -7379,6 +7393,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
"license": "MIT"
},
"node_modules/events": { "node_modules/events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -20166,6 +20186,14 @@
} }
} }
}, },
"@nestjs/event-emitter": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.1.1.tgz",
"integrity": "sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==",
"requires": {
"eventemitter2": "6.4.9"
}
},
"@nestjs/jwt": { "@nestjs/jwt": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz",
@@ -23260,6 +23288,11 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
}, },
"eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg=="
},
"events": { "events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",

View File

@@ -33,6 +33,7 @@
"@nestjs/common": "9.1.6", "@nestjs/common": "9.1.6",
"@nestjs/config": "2.2.0", "@nestjs/config": "2.2.0",
"@nestjs/core": "9.1.6", "@nestjs/core": "9.1.6",
"@nestjs/event-emitter": "^2.1.1",
"@nestjs/jwt": "9.0.0", "@nestjs/jwt": "9.0.0",
"@nestjs/passport": "9.0.0", "@nestjs/passport": "9.0.0",
"@nestjs/platform-express": "9.1.6", "@nestjs/platform-express": "9.1.6",

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import databaseConfig from './config/database.config'; import databaseConfig from './config/database.config';
@@ -25,10 +26,12 @@ import { TranslateModule } from './translate/translate.module';
import { PenaltyModule } from './penalty/penalty.module'; import { PenaltyModule } from './penalty/penalty.module';
import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module'; import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module';
import { CashboxExportModule } from './cashbox-export/cashbox-export.module'; import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
import { NotificationsModule } from './notifications/notifications.module';
@Module({ @Module({
imports: [ imports: [
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
load: [databaseConfig, authConfig, appConfig, mailConfig], load: [databaseConfig, authConfig, appConfig, mailConfig],
@@ -61,6 +64,7 @@ import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
PenaltyModule, PenaltyModule,
RecurringTransactionsModule, RecurringTransactionsModule,
CashboxExportModule, CashboxExportModule,
NotificationsModule,
], ],
providers: [], providers: [],
}) })

View File

@@ -14,6 +14,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
let userRepository: any; let userRepository: any;
let service: AuthService; let service: AuthService;
let mailService: any; let mailService: any;
let eventEmitter: any;
beforeEach(() => { beforeEach(() => {
jwtService = { jwtService = {
@@ -44,6 +45,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
dataSource = { dataSource = {
transaction: jest.fn((work) => work(manager)), transaction: jest.fn((work) => work(manager)),
}; };
eventEmitter = { emit: jest.fn() };
service = new AuthService( service = new AuthService(
jwtService, jwtService,
usersService, usersService,
@@ -52,6 +54,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
logger, logger,
dataSource, dataSource,
{ assertAtLeast: jest.fn() } as any, { assertAtLeast: jest.fn() } as any,
eventEmitter as any,
); );
}); });
@@ -155,6 +158,19 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
expect(usersService.linkPlayerToUserId).not.toHaveBeenCalled(); expect(usersService.linkPlayerToUserId).not.toHaveBeenCalled();
}); });
it('emits an invite-link-created event after issuing the token', async () => {
const token = await service.createTeamInvite(
{ teamId: 10, teamName: 'Team A' } as any,
5,
);
expect(token.token).toBeDefined();
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.invite_link.created',
expect.objectContaining({ teamId: 10, actorUserId: 5, teamName: 'Team A' }),
);
});
function user(statusId: StatusEnum) { function user(statusId: StatusEnum) {
return { return {
id: 2, id: 2,

View File

@@ -6,6 +6,7 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { User } from '../users/entities/user.entity'; import { User } from '../users/entities/user.entity';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { AuthEmailLoginDto } from './dto/auth-email-login.dto'; import { AuthEmailLoginDto } from './dto/auth-email-login.dto';
@@ -27,6 +28,8 @@ import { LoggingService } from 'src/database/logging/logging.service';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { TeamAccessService } from 'src/teams/team-access.service'; import { TeamAccessService } from 'src/teams/team-access.service';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum'; import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { NOTIFICATION_EVENT_NAME } from 'src/notifications/events/notification-event-names';
import { InviteLinkCreatedEvent } from 'src/notifications/events/invite-link-created.event';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -38,6 +41,7 @@ export class AuthService {
private logger: LoggingService, private logger: LoggingService,
private dataSource: DataSource, private dataSource: DataSource,
private teamAccess: TeamAccessService, private teamAccess: TeamAccessService,
private eventEmitter: EventEmitter2,
) {} ) {}
async validateLogin( async validateLogin(
@@ -323,6 +327,11 @@ export class AuthService {
userId: 0, userId: 0,
}); });
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.inviteLinkCreated,
new InviteLinkCreatedEvent(object.teamId, actorUserId, object.teamName),
);
return { token }; return { token };
} }

View File

@@ -38,7 +38,12 @@ export type LOGEVENT =
| 'cashbox_export_subscription_run' | 'cashbox_export_subscription_run'
| 'cashbox_export_subscription_run_fail' | 'cashbox_export_subscription_run_fail'
| 'log_retention_cleanup_run' | 'log_retention_cleanup_run'
| 'log_retention_cleanup_run_fail'; | 'log_retention_cleanup_run_fail'
| 'notification_create_fail'
| 'notification_retention_cleanup_run'
| 'notification_retention_cleanup_run_fail'
| 'public_access_enabled'
| 'public_access_rotated';
export const LOGEVENT_VALUES: LOGEVENT[] = [ export const LOGEVENT_VALUES: LOGEVENT[] = [
'user_create', 'user_create',
@@ -80,6 +85,11 @@ export const LOGEVENT_VALUES: LOGEVENT[] = [
'cashbox_export_subscription_run_fail', 'cashbox_export_subscription_run_fail',
'log_retention_cleanup_run', 'log_retention_cleanup_run',
'log_retention_cleanup_run_fail', 'log_retention_cleanup_run_fail',
'notification_create_fail',
'notification_retention_cleanup_run',
'notification_retention_cleanup_run_fail',
'public_access_enabled',
'public_access_rotated',
]; ];
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE'; export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';

View File

@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddNotificationTables1785600000000 implements MigrationInterface {
name = 'AddNotificationTables1785600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "notification" (
"id" SERIAL NOT NULL,
"teamId" integer NOT NULL,
"event" character varying NOT NULL,
"actorUserId" integer NOT NULL,
"payload" text NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_notification_id" PRIMARY KEY ("id")
)
`);
await queryRunner.query(
`CREATE INDEX "IDX_notification_team_id" ON "notification" ("teamId")`,
);
await queryRunner.query(`
ALTER TABLE "notification"
ADD CONSTRAINT "FK_notification_team"
FOREIGN KEY ("teamId") REFERENCES "team"("id")
ON DELETE CASCADE
`);
await queryRunner.query(`
CREATE TABLE "notification_recipient" (
"id" SERIAL NOT NULL,
"notificationId" integer NOT NULL,
"userId" integer NOT NULL,
"read" boolean NOT NULL DEFAULT false,
"readAt" TIMESTAMP,
CONSTRAINT "PK_notification_recipient_id" PRIMARY KEY ("id")
)
`);
await queryRunner.query(
`CREATE INDEX "IDX_notification_recipient_notification_id" ON "notification_recipient" ("notificationId")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_notification_recipient_user_id" ON "notification_recipient" ("userId")`,
);
await queryRunner.query(`
ALTER TABLE "notification_recipient"
ADD CONSTRAINT "FK_notification_recipient_notification"
FOREIGN KEY ("notificationId") REFERENCES "notification"("id")
ON DELETE CASCADE
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "notification_recipient"`);
await queryRunner.query(`DROP TABLE "notification"`);
}
}

View File

@@ -0,0 +1,38 @@
describe('AddNotificationTables1785600000000', () => {
it('creates the notification and notification_recipient tables with their indexes and foreign keys', async () => {
const migrationModule = require('./1785600000000-AddNotificationTables');
const migration = new migrationModule.AddNotificationTables1785600000000();
const queryRunner = { query: jest.fn() } as any;
await migration.up(queryRunner);
const calls: string[] = queryRunner.query.mock.calls.map((c: any) => c[0]);
expect(calls).toHaveLength(7);
expect(calls.some((sql) => sql.includes('CREATE TABLE "notification"'))).toBe(true);
expect(calls.some((sql) => sql.includes('CREATE TABLE "notification_recipient"'))).toBe(
true,
);
expect(calls.some((sql) => sql.includes('IDX_notification_team_id'))).toBe(true);
expect(
calls.some((sql) => sql.includes('IDX_notification_recipient_notification_id')),
).toBe(true);
expect(calls.some((sql) => sql.includes('IDX_notification_recipient_user_id'))).toBe(
true,
);
expect(calls.some((sql) => sql.includes('FK_notification_team'))).toBe(true);
expect(calls.some((sql) => sql.includes('FK_notification_recipient_notification'))).toBe(
true,
);
});
it('drops both tables on down, recipient first to respect the foreign key', async () => {
const migrationModule = require('./1785600000000-AddNotificationTables');
const migration = new migrationModule.AddNotificationTables1785600000000();
const queryRunner = { query: jest.fn() } as any;
await migration.down(queryRunner);
const calls: string[] = queryRunner.query.mock.calls.map((c: any) => c[0]);
expect(calls).toEqual(['DROP TABLE "notification_recipient"', 'DROP TABLE "notification"']);
});
});

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,23 @@
import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { EntityHelper } from 'src/utils/entity-helper';
import { Notification } from './notification.entity';
@Entity()
export class NotificationRecipient extends EntityHelper {
@PrimaryGeneratedColumn()
id: number;
@Index('IDX_notification_recipient_notification_id')
@ManyToOne(() => Notification, { onDelete: 'CASCADE' })
notification: Notification;
@Index('IDX_notification_recipient_user_id')
@Column()
userId: number;
@Column({ default: false })
read: boolean;
@Column({ nullable: true })
readAt: Date | null;
}

View File

@@ -0,0 +1,33 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { EntityHelper } from 'src/utils/entity-helper';
import { Team } from 'src/teams/entities/team.entity';
import { NOTIFICATION_EVENT } from '../model/notification-event.type';
@Entity()
export class Notification extends EntityHelper {
@PrimaryGeneratedColumn()
id: number;
@Index('IDX_notification_team_id')
@ManyToOne(() => Team, { eager: false })
team: Team;
@Column()
event: NOTIFICATION_EVENT;
@Column()
actorUserId: number;
@Column({ type: 'text' })
payload: string;
@CreateDateColumn()
createdAt: Date;
}

View File

@@ -0,0 +1,7 @@
export class InviteLinkCreatedEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
public readonly teamName: string,
) {}
}

View File

@@ -0,0 +1,8 @@
export const NOTIFICATION_EVENT_NAME = {
playerActiveChanged: 'notifications.player.active_changed',
playerRoleChanged: 'notifications.player.role_changed',
playerCreated: 'notifications.player.created',
publicAccessEnabled: 'notifications.public_access.enabled',
publicAccessRotated: 'notifications.public_access.rotated',
inviteLinkCreated: 'notifications.invite_link.created',
} as const;

View File

@@ -0,0 +1,9 @@
export class PlayerActiveChangedEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
public readonly playerId: number,
public readonly playerName: string,
public readonly active: boolean,
) {}
}

View File

@@ -0,0 +1,8 @@
export class PlayerCreatedEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
public readonly playerId: number,
public readonly playerName: string,
) {}
}

View File

@@ -0,0 +1,9 @@
export class PlayerRoleChangedEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
public readonly playerId: number,
public readonly playerName: string,
public readonly teamRoleId: number,
) {}
}

View File

@@ -0,0 +1,13 @@
export class PublicAccessEnabledEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
) {}
}
export class PublicAccessRotatedEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
) {}
}

View File

@@ -0,0 +1,16 @@
export type NOTIFICATION_EVENT =
| 'player_active_update'
| 'player_team_role_update'
| 'player_creation'
| 'public_access_enabled'
| 'public_access_rotated'
| 'user_invite_link_create';
export const NOTIFICATION_EVENT_VALUES: NOTIFICATION_EVENT[] = [
'player_active_update',
'player_team_role_update',
'player_creation',
'public_access_enabled',
'public_access_rotated',
'user_invite_link_create',
];

View File

@@ -0,0 +1,55 @@
import { LessThan } from 'typeorm';
import { NotificationRetentionScheduler } from './notification-retention.scheduler';
describe('NotificationRetentionScheduler', () => {
const repository = { delete: jest.fn() };
const configService = { get: jest.fn() };
const logger = { info: jest.fn(), error: jest.fn() };
let scheduler: NotificationRetentionScheduler;
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers().setSystemTime(new Date('2026-08-04T12:00:00.000Z'));
configService.get.mockReturnValue(365);
repository.delete.mockResolvedValue({ affected: 3 });
scheduler = new NotificationRetentionScheduler(repository as any, configService as any, logger as any);
});
afterEach(() => {
jest.useRealTimers();
});
it('deletes notifications older than the configured retention window', async () => {
await scheduler.cleanupOldNotifications();
expect(configService.get).toHaveBeenCalledWith('app.logRetentionDays');
expect(repository.delete).toHaveBeenCalledWith({
createdAt: LessThan(new Date('2025-08-04T12:00:00.000Z')),
});
});
it('logs the number of deleted notifications', async () => {
repository.delete.mockResolvedValue({ affected: 7 });
await scheduler.cleanupOldNotifications();
expect(logger.info).toHaveBeenCalledWith({
event: 'notification_retention_cleanup_run',
details: 'deletedCount=7 retentionDays=365',
userId: -1,
});
});
it('logs and does not rethrow when the delete fails', async () => {
repository.delete.mockRejectedValue(new Error('connection reset'));
await expect(scheduler.cleanupOldNotifications()).resolves.toBeUndefined();
expect(logger.error).toHaveBeenCalledWith({
event: 'notification_retention_cleanup_run_fail',
details: 'connection reset',
userId: -1,
});
expect(logger.info).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThan, Repository } from 'typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { Notification } from './entities/notification.entity';
@Injectable()
export class NotificationRetentionScheduler {
constructor(
@InjectRepository(Notification)
private readonly repository: Repository<Notification>,
private readonly configService: ConfigService,
private readonly logger: LoggingService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_5AM)
async cleanupOldNotifications(): Promise<void> {
const retentionDays = this.configService.get<number>('app.logRetentionDays');
const cutoff = new Date();
cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays);
try {
const result = await this.repository.delete({ createdAt: LessThan(cutoff) });
await this.logger.info({
event: 'notification_retention_cleanup_run',
details: `deletedCount=${result.affected ?? 0} retentionDays=${retentionDays}`,
userId: -1,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await this.logger.error({
event: 'notification_retention_cleanup_run_fail',
details: errorMessage,
userId: -1,
});
}
}
}

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

@@ -0,0 +1,97 @@
import { PlayerActiveChangedEvent } from './events/player-active-changed.event';
import { PlayerRoleChangedEvent } from './events/player-role-changed.event';
import { PlayerCreatedEvent } from './events/player-created.event';
import { PublicAccessEnabledEvent, PublicAccessRotatedEvent } from './events/public-access-changed.event';
import { InviteLinkCreatedEvent } from './events/invite-link-created.event';
import { NotificationsListener } from './notifications.listener';
describe('NotificationsListener', () => {
const notifications = { create: jest.fn() };
const logger = { error: jest.fn() };
let listener: NotificationsListener;
beforeEach(() => {
jest.resetAllMocks();
listener = new NotificationsListener(notifications as any, logger as any);
});
it('creates a player_active_update notification', async () => {
await listener.onPlayerActiveChanged(new PlayerActiveChangedEvent(10, 5, 1, 'Ada Lovelace', false));
expect(notifications.create).toHaveBeenCalledWith({
teamId: 10,
event: 'player_active_update',
actorUserId: 5,
payload: { playerId: 1, playerName: 'Ada Lovelace', active: false },
});
});
it('creates a player_team_role_update notification', async () => {
await listener.onPlayerRoleChanged(new PlayerRoleChangedEvent(10, 5, 1, 'Ada Lovelace', 3));
expect(notifications.create).toHaveBeenCalledWith({
teamId: 10,
event: 'player_team_role_update',
actorUserId: 5,
payload: { playerId: 1, playerName: 'Ada Lovelace', teamRoleId: 3 },
});
});
it('creates a player_creation notification', async () => {
await listener.onPlayerCreated(new PlayerCreatedEvent(10, 5, 1, 'Ada Lovelace'));
expect(notifications.create).toHaveBeenCalledWith({
teamId: 10,
event: 'player_creation',
actorUserId: 5,
payload: { playerId: 1, playerName: 'Ada Lovelace' },
});
});
it('creates a public_access_enabled notification', async () => {
await listener.onPublicAccessEnabled(new PublicAccessEnabledEvent(10, 5));
expect(notifications.create).toHaveBeenCalledWith({
teamId: 10,
event: 'public_access_enabled',
actorUserId: 5,
payload: {},
});
});
it('creates a public_access_rotated notification', async () => {
await listener.onPublicAccessRotated(new PublicAccessRotatedEvent(10, 5));
expect(notifications.create).toHaveBeenCalledWith({
teamId: 10,
event: 'public_access_rotated',
actorUserId: 5,
payload: {},
});
});
it('creates a user_invite_link_create notification', async () => {
await listener.onInviteLinkCreated(new InviteLinkCreatedEvent(10, 5, 'Team A'));
expect(notifications.create).toHaveBeenCalledWith({
teamId: 10,
event: 'user_invite_link_create',
actorUserId: 5,
payload: { teamName: 'Team A' },
});
});
it('logs and swallows errors instead of throwing, so the originating action is unaffected', async () => {
notifications.create.mockRejectedValue(new Error('db unavailable'));
await expect(
listener.onPlayerCreated(new PlayerCreatedEvent(10, 5, 1, 'Ada Lovelace')),
).resolves.toBeUndefined();
expect(logger.error).toHaveBeenCalledWith({
event: 'notification_create_fail',
details: 'teamId=10 event=player_creation: db unavailable',
userId: -1,
});
});
});

View File

@@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { LoggingService } from 'src/database/logging/logging.service';
import { NOTIFICATION_EVENT } from './model/notification-event.type';
import { NOTIFICATION_EVENT_NAME } from './events/notification-event-names';
import { PlayerActiveChangedEvent } from './events/player-active-changed.event';
import { PlayerRoleChangedEvent } from './events/player-role-changed.event';
import { PlayerCreatedEvent } from './events/player-created.event';
import { PublicAccessEnabledEvent, PublicAccessRotatedEvent } from './events/public-access-changed.event';
import { InviteLinkCreatedEvent } from './events/invite-link-created.event';
import { NotificationsService } from './notifications.service';
@Injectable()
export class NotificationsListener {
constructor(
private readonly notifications: NotificationsService,
private readonly logger: LoggingService,
) {}
@OnEvent(NOTIFICATION_EVENT_NAME.playerActiveChanged)
onPlayerActiveChanged(event: PlayerActiveChangedEvent): Promise<void> {
return this.safeCreate('player_active_update', event.teamId, event.actorUserId, {
playerId: event.playerId,
playerName: event.playerName,
active: event.active,
});
}
@OnEvent(NOTIFICATION_EVENT_NAME.playerRoleChanged)
onPlayerRoleChanged(event: PlayerRoleChangedEvent): Promise<void> {
return this.safeCreate('player_team_role_update', event.teamId, event.actorUserId, {
playerId: event.playerId,
playerName: event.playerName,
teamRoleId: event.teamRoleId,
});
}
@OnEvent(NOTIFICATION_EVENT_NAME.playerCreated)
onPlayerCreated(event: PlayerCreatedEvent): Promise<void> {
return this.safeCreate('player_creation', event.teamId, event.actorUserId, {
playerId: event.playerId,
playerName: event.playerName,
});
}
@OnEvent(NOTIFICATION_EVENT_NAME.publicAccessEnabled)
onPublicAccessEnabled(event: PublicAccessEnabledEvent): Promise<void> {
return this.safeCreate('public_access_enabled', event.teamId, event.actorUserId, {});
}
@OnEvent(NOTIFICATION_EVENT_NAME.publicAccessRotated)
onPublicAccessRotated(event: PublicAccessRotatedEvent): Promise<void> {
return this.safeCreate('public_access_rotated', event.teamId, event.actorUserId, {});
}
@OnEvent(NOTIFICATION_EVENT_NAME.inviteLinkCreated)
onInviteLinkCreated(event: InviteLinkCreatedEvent): Promise<void> {
return this.safeCreate('user_invite_link_create', event.teamId, event.actorUserId, {
teamName: event.teamName,
});
}
private async safeCreate(
event: NOTIFICATION_EVENT,
teamId: number,
actorUserId: number,
payload: Record<string, unknown>,
): Promise<void> {
try {
await this.notifications.create({ teamId, event, actorUserId, payload });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await this.logger.error({
event: 'notification_create_fail',
details: `teamId=${teamId} event=${event}: ${errorMessage}`,
userId: -1,
});
}
}
}

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggingModule } from 'src/database/logging/logging.module';
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';
import { NotificationRetentionScheduler } from './notification-retention.scheduler';
@Module({
imports: [
TypeOrmModule.forFeature([Notification, NotificationRecipient, Player]),
LoggingModule,
TeamsModule,
],
controllers: [NotificationsController],
providers: [NotificationsService, NotificationsListener, NotificationRetentionScheduler],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,182 @@
import { NotFoundException } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
describe('NotificationsService', () => {
const notificationRepository = { create: jest.fn(), save: jest.fn() };
const recipientRepository = {
insert: jest.fn(),
createQueryBuilder: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
update: jest.fn(),
};
const playerRepository = { createQueryBuilder: jest.fn() };
let service: NotificationsService;
beforeEach(() => {
jest.resetAllMocks();
notificationRepository.create.mockImplementation((value) => value);
service = new NotificationsService(
notificationRepository as any,
recipientRepository as any,
playerRepository as any,
);
});
function chain(overrides: Record<string, jest.Mock>) {
const query: Record<string, jest.Mock> = {};
['innerJoin', 'innerJoinAndSelect', 'where', 'andWhere', 'select', 'orderBy', 'offset', 'limit']
.forEach((method) => (query[method] = jest.fn(() => query)));
return Object.assign(query, overrides);
}
describe('create', () => {
it('does nothing when the team has no other active members with a login', async () => {
const playerQuery = chain({ getRawMany: jest.fn().mockResolvedValue([]) });
playerRepository.createQueryBuilder.mockReturnValue(playerQuery);
await service.create({
teamId: 10,
event: 'player_creation',
actorUserId: 5,
payload: { playerId: 1, playerName: 'Ada Lovelace' },
});
expect(playerQuery.where).toHaveBeenCalledWith('player.teamId = :teamId', { teamId: 10 });
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.active = :active', { active: true });
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.userId IS NOT NULL');
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.userId != :actorUserId', { actorUserId: 5 });
expect(notificationRepository.save).not.toHaveBeenCalled();
expect(recipientRepository.insert).not.toHaveBeenCalled();
});
it('creates one notification and fans it out to every recipient', async () => {
const playerQuery = chain({ getRawMany: jest.fn().mockResolvedValue([{ userId: 7 }, { userId: 8 }]) });
playerRepository.createQueryBuilder.mockReturnValue(playerQuery);
notificationRepository.save.mockResolvedValue({ id: 99 });
await service.create({
teamId: 10,
event: 'player_creation',
actorUserId: 5,
payload: { playerId: 1, playerName: 'Ada Lovelace' },
});
expect(playerQuery.where).toHaveBeenCalledWith('player.teamId = :teamId', { teamId: 10 });
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.active = :active', { active: true });
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.userId IS NOT NULL');
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.userId != :actorUserId', { actorUserId: 5 });
expect(notificationRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
team: { id: 10 },
event: 'player_creation',
actorUserId: 5,
payload: JSON.stringify({ playerId: 1, playerName: 'Ada Lovelace' }),
}),
);
expect(recipientRepository.insert).toHaveBeenCalledWith([
{ notification: { id: 99 }, userId: 7 },
{ notification: { id: 99 }, userId: 8 },
]);
});
});
describe('listForUser', () => {
it('maps recipient rows to notification DTOs with parsed payloads', async () => {
const query = chain({
getCount: jest.fn().mockResolvedValue(1),
getMany: jest.fn().mockResolvedValue([
{
id: 1,
userId: 7,
read: false,
notification: {
event: 'player_creation',
actorUserId: 5,
payload: JSON.stringify({ playerId: 1, playerName: 'Ada Lovelace' }),
createdAt: new Date('2026-08-04T10:00:00.000Z'),
},
},
]),
});
recipientRepository.createQueryBuilder.mockReturnValue(query);
const page = await service.listForUser(7, 10, 1, 20);
expect(page).toEqual({
data: [
{
id: 1,
event: 'player_creation',
actorUserId: 5,
payload: { playerId: 1, playerName: 'Ada Lovelace' },
read: false,
createdAt: new Date('2026-08-04T10:00:00.000Z'),
},
],
page: 1,
limit: 20,
total: 1,
hasNextPage: false,
});
});
});
describe('getUnreadCount', () => {
it('counts only unread recipient rows for the given user and team', async () => {
const query = chain({ getCount: jest.fn().mockResolvedValue(3) });
recipientRepository.createQueryBuilder.mockReturnValue(query);
await expect(service.getUnreadCount(7, 10)).resolves.toBe(3);
expect(query.where).toHaveBeenCalledWith('recipient.userId = :userId', { userId: 7 });
});
});
describe('markRead', () => {
it('marks a recipient row as read', async () => {
recipientRepository.findOne.mockResolvedValue({ id: 1, userId: 7, read: false, readAt: null });
await service.markRead(1, 7);
expect(recipientRepository.save).toHaveBeenCalledWith(expect.objectContaining({ read: true }));
});
it('rejects marking a recipient row that belongs to another user', async () => {
recipientRepository.findOne.mockResolvedValue({ id: 1, userId: 999, read: false });
await expect(service.markRead(1, 7)).rejects.toBeInstanceOf(NotFoundException);
expect(recipientRepository.save).not.toHaveBeenCalled();
});
it('rejects marking a recipient row that does not exist', async () => {
recipientRepository.findOne.mockResolvedValue(null);
await expect(service.markRead(1, 7)).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('markAllRead', () => {
it('marks every unread recipient row for the user and team as read', async () => {
const query = chain({ getRawMany: jest.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]) });
recipientRepository.createQueryBuilder.mockReturnValue(query);
await service.markAllRead(7, 10);
expect(recipientRepository.update).toHaveBeenCalledWith(
[1, 2],
expect.objectContaining({ read: true }),
);
});
it('does nothing when there is nothing unread', async () => {
const query = chain({ getRawMany: jest.fn().mockResolvedValue([]) });
recipientRepository.createQueryBuilder.mockReturnValue(query);
await service.markAllRead(7, 10);
expect(recipientRepository.update).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,150 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Player } from 'src/players/entities/player.entity';
import { Team } from 'src/teams/entities/team.entity';
import { Notification } from './entities/notification.entity';
import { NotificationRecipient } from './entities/notification-recipient.entity';
import { NOTIFICATION_EVENT } from './model/notification-event.type';
export interface NotificationDto {
id: number;
event: NOTIFICATION_EVENT;
actorUserId: number;
payload: Record<string, unknown>;
read: boolean;
createdAt: Date;
}
export interface NotificationPage {
data: NotificationDto[];
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}
@Injectable()
export class NotificationsService {
constructor(
@InjectRepository(Notification)
private readonly notificationRepository: Repository<Notification>,
@InjectRepository(NotificationRecipient)
private readonly recipientRepository: Repository<NotificationRecipient>,
@InjectRepository(Player)
private readonly playerRepository: Repository<Player>,
) {}
async create(params: {
teamId: number;
event: NOTIFICATION_EVENT;
actorUserId: number;
payload: Record<string, unknown>;
}): Promise<void> {
const recipientUserIds = await this.resolveRecipients(params.teamId, params.actorUserId);
if (recipientUserIds.length === 0) return;
const notification = await this.notificationRepository.save(
this.notificationRepository.create({
team: { id: params.teamId } as Team,
event: params.event,
actorUserId: params.actorUserId,
payload: JSON.stringify(params.payload),
}),
);
await this.recipientRepository.insert(
recipientUserIds.map((userId) => ({
notification: { id: notification.id } as Notification,
userId,
})),
);
}
async listForUser(
userId: number,
teamId: number,
page: number,
limit: number,
): Promise<NotificationPage> {
const builder = this.recipientRepository
.createQueryBuilder('recipient')
.innerJoinAndSelect('recipient.notification', 'notification')
.where('recipient.userId = :userId', { userId })
.andWhere('notification.teamId = :teamId', { teamId })
.orderBy('notification.createdAt', 'DESC');
const total = await builder.getCount();
const rows = await builder.offset((page - 1) * limit).limit(limit).getMany();
return {
data: rows.map((row) => this.toDto(row)),
page,
limit,
total,
hasNextPage: page * limit < total,
};
}
async getUnreadCount(userId: number, teamId: number): Promise<number> {
return this.recipientRepository
.createQueryBuilder('recipient')
.innerJoin('recipient.notification', 'notification')
.where('recipient.userId = :userId', { userId })
.andWhere('notification.teamId = :teamId', { teamId })
.andWhere('recipient.read = false')
.getCount();
}
async markRead(recipientId: number, userId: number): Promise<void> {
const recipient = await this.recipientRepository.findOne({ where: { id: recipientId } });
if (!recipient || recipient.userId !== userId) {
throw new NotFoundException('Benachrichtigung nicht gefunden.');
}
if (recipient.read) return;
recipient.read = true;
recipient.readAt = new Date();
await this.recipientRepository.save(recipient);
}
async markAllRead(userId: number, teamId: number): Promise<void> {
const rows = await this.recipientRepository
.createQueryBuilder('recipient')
.innerJoin('recipient.notification', 'notification')
.where('recipient.userId = :userId', { userId })
.andWhere('notification.teamId = :teamId', { teamId })
.andWhere('recipient.read = false')
.select('recipient.id', 'id')
.getRawMany<{ id: number }>();
if (rows.length === 0) return;
await this.recipientRepository.update(rows.map((row) => row.id), {
read: true,
readAt: new Date(),
});
}
private async resolveRecipients(teamId: number, actorUserId: number): Promise<number[]> {
const rows = await this.playerRepository
.createQueryBuilder('player')
.where('player.teamId = :teamId', { teamId })
.andWhere('player.active = :active', { active: true })
.andWhere('player.userId IS NOT NULL')
.andWhere('player.userId != :actorUserId', { actorUserId })
.select('DISTINCT player.userId', 'userId')
.getRawMany<{ userId: number }>();
return rows.map((row) => row.userId);
}
private toDto(recipient: NotificationRecipient): NotificationDto {
return {
id: recipient.id,
event: recipient.notification.event,
actorUserId: recipient.notification.actorUserId,
payload: JSON.parse(recipient.notification.payload),
read: recipient.read,
createdAt: recipient.notification.createdAt,
};
}
}

View File

@@ -42,7 +42,12 @@ describe('RecurringTransactionsScheduler', () => {
await scheduler.runDueRecurringTransactions(); await scheduler.runDueRecurringTransactions();
expect(dataSource.transaction).not.toHaveBeenCalled(); expect(dataSource.transaction).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({ event: 'scheduled_recurring_transaction_check_start' }),
);
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({ event: 'scheduled_recurring_transaction_check_finished' }),
);
}); });
it('books a transaction for every active player and skips inactive ones', async () => { it('books a transaction for every active player and skips inactive ones', async () => {

View File

@@ -13,6 +13,8 @@ describe('PublicTeamAccessService', () => {
const penaltyRepository = { find: jest.fn() }; const penaltyRepository = { find: jest.fn() };
const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() }; const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() };
let service: PublicTeamAccessService; let service: PublicTeamAccessService;
let logger: any;
let eventEmitter: any;
const managedTeam = { const managedTeam = {
id: 7, id: 7,
@@ -25,12 +27,16 @@ describe('PublicTeamAccessService', () => {
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
teamRepository.save.mockImplementation(async (team) => team); teamRepository.save.mockImplementation(async (team) => team);
logger = { info: jest.fn() };
eventEmitter = { emit: jest.fn() };
service = new PublicTeamAccessService( service = new PublicTeamAccessService(
teamRepository as any, teamRepository as any,
playerRepository as any, playerRepository as any,
transactionRepository as any, transactionRepository as any,
penaltyRepository as any, penaltyRepository as any,
access as any, access as any,
logger as any,
eventEmitter as any,
); );
}); });
@@ -82,6 +88,47 @@ describe('PublicTeamAccessService', () => {
expect(status.token).not.toBe('a'.repeat(64)); expect(status.token).not.toBe('a'.repeat(64));
}); });
it('logs and emits when public access is enabled', async () => {
mockManagedTeam();
await service.setEnabled(4, 7, true);
expect(logger.info).toHaveBeenCalledWith({
event: 'public_access_enabled',
details: 'teamId=7',
userId: 4,
});
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.public_access.enabled',
expect.objectContaining({ teamId: 7, actorUserId: 4 }),
);
});
it('does not log or emit when public access is disabled', async () => {
mockManagedTeam({ ...managedTeam, publicAccessEnabled: true, publicAccessToken: 'a'.repeat(64) });
await service.setEnabled(4, 7, false);
expect(logger.info).not.toHaveBeenCalled();
expect(eventEmitter.emit).not.toHaveBeenCalled();
});
it('logs and emits when the token is rotated', async () => {
mockManagedTeam({ ...managedTeam, publicAccessEnabled: true, publicAccessToken: 'a'.repeat(64) });
await service.rotate(4, 7);
expect(logger.info).toHaveBeenCalledWith({
event: 'public_access_rotated',
details: 'teamId=7',
userId: 4,
});
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.public_access.rotated',
expect.objectContaining({ teamId: 7, actorUserId: 4 }),
);
});
it('returns only whitelisted public team fields and active players', async () => { it('returns only whitelisted public team fields and active players', async () => {
teamRepository.findOne.mockResolvedValue({ teamRepository.findOne.mockResolvedValue({
id: 7, id: 7,

View File

@@ -1,6 +1,13 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { LoggingService } from '../database/logging/logging.service';
import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names';
import {
PublicAccessEnabledEvent,
PublicAccessRotatedEvent,
} from '../notifications/events/public-access-changed.event';
import { PenaltyEntity } from '../penalty/entities/penalty.entity'; import { PenaltyEntity } from '../penalty/entities/penalty.entity';
import { Player } from '../players/entities/player.entity'; import { Player } from '../players/entities/player.entity';
import { TeamRolesEnum } from '../team-roles/team-roles.enum'; import { TeamRolesEnum } from '../team-roles/team-roles.enum';
@@ -28,6 +35,8 @@ export class PublicTeamAccessService {
@InjectRepository(PenaltyEntity) @InjectRepository(PenaltyEntity)
private readonly penaltyRepository: Repository<PenaltyEntity>, private readonly penaltyRepository: Repository<PenaltyEntity>,
private readonly access: TeamAccessService, private readonly access: TeamAccessService,
private readonly logger: LoggingService,
private readonly eventEmitter: EventEmitter2,
) {} ) {}
async getStatus( async getStatus(
@@ -55,6 +64,19 @@ export class PublicTeamAccessService {
} }
team.publicAccessEnabled = enabled; team.publicAccessEnabled = enabled;
await this.teamRepository.save(team); await this.teamRepository.save(team);
if (enabled) {
await this.logger.info({
event: 'public_access_enabled',
details: `teamId=${teamId}`,
userId,
});
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.publicAccessEnabled,
new PublicAccessEnabledEvent(teamId, userId),
);
}
return this.toStatus(team); return this.toStatus(team);
} }
@@ -68,6 +90,17 @@ export class PublicTeamAccessService {
const team = await this.loadManagedTeam(teamId); const team = await this.loadManagedTeam(teamId);
team.publicAccessToken = this.createToken(); team.publicAccessToken = this.createToken();
await this.teamRepository.save(team); await this.teamRepository.save(team);
await this.logger.info({
event: 'public_access_rotated',
details: `teamId=${teamId}`,
userId,
});
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.publicAccessRotated,
new PublicAccessRotatedEvent(teamId, userId),
);
return this.toStatus(team); return this.toStatus(team);
} }

View File

@@ -18,6 +18,7 @@ describe('TeamMembersService', () => {
let dataSource: any; let dataSource: any;
let logger: any; let logger: any;
let access: any; let access: any;
let eventEmitter: any;
let service: TeamMembersService; let service: TeamMembersService;
beforeEach(() => { beforeEach(() => {
@@ -44,7 +45,8 @@ describe('TeamMembersService', () => {
dataSource = { transaction: jest.fn((work) => work(manager)) }; dataSource = { transaction: jest.fn((work) => work(manager)) };
logger = { info: jest.fn() }; logger = { info: jest.fn() };
access = { assertAtLeast: jest.fn(() => Promise.resolve()) }; access = { assertAtLeast: jest.fn(() => Promise.resolve()) };
service = new TeamMembersService(dataSource, logger, access as any); eventEmitter = { emit: jest.fn() };
service = new TeamMembersService(dataSource, logger, access as any, eventEmitter as any);
}); });
it('checks the team-manager permission before touching the database', async () => { it('checks the team-manager permission before touching the database', async () => {
@@ -188,6 +190,53 @@ describe('TeamMembersService', () => {
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
it('emits a player-active-changed event after a real deactivation', async () => {
player.balance = 42;
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
await service.setActive(5, teamId, player.id, false);
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.player.active_changed',
expect.objectContaining({
teamId,
actorUserId: 5,
playerId: player.id,
playerName: 'Pat Player',
active: false,
}),
);
});
it('does not emit when the active state is unchanged (idempotent)', async () => {
player = makePlayer(101, true, TeamRolesEnum.player, 0);
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
playerRepository.createQueryBuilder = jest.fn((alias: string) =>
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
);
await service.setActive(5, teamId, player.id, true);
expect(eventEmitter.emit).not.toHaveBeenCalled();
});
it('emits a player-role-changed event after a real role change', async () => {
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
await service.setTeamRole(5, teamId, player.id, TeamRolesEnum.captain);
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.player.role_changed',
expect.objectContaining({
teamId,
actorUserId: 5,
playerId: player.id,
playerName: 'Pat Player',
teamRoleId: TeamRolesEnum.captain,
}),
);
});
function makePlayer( function makePlayer(
id: number, id: number,
active: boolean, active: boolean,

View File

@@ -1,6 +1,10 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, EntityManager, Repository } from 'typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm';
import { LoggingService } from '../database/logging/logging.service'; import { LoggingService } from '../database/logging/logging.service';
import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names';
import { PlayerActiveChangedEvent } from '../notifications/events/player-active-changed.event';
import { PlayerRoleChangedEvent } from '../notifications/events/player-role-changed.event';
import { Player } from '../players/entities/player.entity'; import { Player } from '../players/entities/player.entity';
import { TeamRole } from '../team-roles/entities/team-roles.entity'; import { TeamRole } from '../team-roles/entities/team-roles.entity';
import { TeamRolesEnum } from '../team-roles/team-roles.enum'; import { TeamRolesEnum } from '../team-roles/team-roles.enum';
@@ -18,6 +22,7 @@ export class TeamMembersService {
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly logger: LoggingService, private readonly logger: LoggingService,
private readonly access: TeamAccessService, private readonly access: TeamAccessService,
private readonly eventEmitter: EventEmitter2,
) {} ) {}
async setActive( async setActive(
@@ -33,12 +38,12 @@ export class TeamMembersService {
TeamRolesEnum.captain, TeamRolesEnum.captain,
); );
return this.dataSource.transaction(async (manager) => { const result = await this.dataSource.transaction(async (manager) => {
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
const playerRepository = manager.getRepository(Player); const playerRepository = manager.getRepository(Player);
const player = await this.findLockedPlayer(playerRepository, playerId, teamId); const player = await this.findLockedPlayer(playerRepository, playerId, teamId);
if (player.active === active) return player; if (player.active === active) return { player, changed: false };
const isDeactivation = player.active && !active; const isDeactivation = player.active && !active;
if ( if (
@@ -66,8 +71,23 @@ export class TeamMembersService {
actorUserId, actorUserId,
`teamId=${teamId} playerId=${playerId} active=${active}`, `teamId=${teamId} playerId=${playerId} active=${active}`,
); );
return player; return { player, changed: true };
}); });
if (result.changed) {
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.playerActiveChanged,
new PlayerActiveChangedEvent(
teamId,
actorUserId,
playerId,
`${result.player.firstName} ${result.player.lastName}`,
active,
),
);
}
return result.player;
} }
async setTeamRole( async setTeamRole(
@@ -83,12 +103,12 @@ export class TeamMembersService {
TeamRolesEnum.captain, TeamRolesEnum.captain,
); );
return this.dataSource.transaction(async (manager) => { const result = await this.dataSource.transaction(async (manager) => {
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
const playerRepository = manager.getRepository(Player); const playerRepository = manager.getRepository(Player);
const player = await this.findLockedPlayer(playerRepository, playerId, teamId); const player = await this.findLockedPlayer(playerRepository, playerId, teamId);
if (player.teamRole?.id === teamRoleId) return player; if (player.teamRole?.id === teamRoleId) return { player, changed: false };
const isDemotionFromTreasurer = const isDemotionFromTreasurer =
player.active && player.active &&
@@ -108,8 +128,23 @@ export class TeamMembersService {
actorUserId, actorUserId,
`teamId=${teamId} playerId=${playerId} teamRoleId=${teamRoleId}`, `teamId=${teamId} playerId=${playerId} teamRoleId=${teamRoleId}`,
); );
return player; return { player, changed: true };
}); });
if (result.changed) {
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.playerRoleChanged,
new PlayerRoleChangedEvent(
teamId,
actorUserId,
playerId,
`${result.player.firstName} ${result.player.lastName}`,
teamRoleId,
),
);
}
return result.player;
} }
// insert() statt save(): umgeht bewusst @BeforeInsert setBalance() auf Transaction, // insert() statt save(): umgeht bewusst @BeforeInsert setBalance() auf Transaction,

View File

@@ -31,6 +31,7 @@ describe('TeamsService#getOverviewStats theoretical balance', () => {
access as any, access as any,
{} as any, {} as any,
{} as any, {} as any,
{ emit: jest.fn() } as any,
); );
}); });
@@ -290,6 +291,7 @@ describe('TeamsService#getTeamTransactionsJournal', () => {
access as any, access as any,
{} as any, {} as any,
{} as any, {} as any,
{ emit: jest.fn() } as any,
); );
}); });
@@ -410,6 +412,7 @@ describe('TeamsService#createNewTeam', () => {
{} as any, {} as any,
{} as any, {} as any,
dataSource as any, dataSource as any,
{ emit: jest.fn() } as any,
); );
}); });
@@ -464,3 +467,50 @@ describe('TeamsService#createNewTeam', () => {
expect(logger.info).not.toHaveBeenCalled(); expect(logger.info).not.toHaveBeenCalled();
}); });
}); });
describe('TeamsService.createNewPlayer', () => {
const repository = { findOneBy: jest.fn() };
const playerRepository = { create: jest.fn((value) => value), save: jest.fn() };
const rolesRepository = { findOneBy: jest.fn() };
const logger = { info: jest.fn() };
const access = { assertManager: jest.fn() };
const eventEmitter = { emit: jest.fn() };
let service: TeamsService;
beforeEach(() => {
jest.resetAllMocks();
access.assertManager.mockResolvedValue(undefined);
rolesRepository.findOneBy.mockResolvedValue({ id: 1, name: 'player' });
repository.findOneBy.mockResolvedValue({ id: 10, name: 'Team A' });
playerRepository.save.mockImplementation((value) =>
Promise.resolve({ ...value, id: 55 }),
);
service = new TeamsService(
repository as any,
playerRepository as any,
{} as any,
rolesRepository as any,
{} as any,
{} as any,
logger as any,
access as any,
{} as any,
{} as any,
eventEmitter as any,
);
});
it('emits a player-created event with the new player id and name', async () => {
await service.createNewPlayer('10', { firstName: 'Ada', lastName: 'Lovelace', teamRole: undefined }, '5');
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.player.created',
expect.objectContaining({
teamId: 10,
actorUserId: 5,
playerId: 55,
playerName: 'Ada Lovelace',
}),
);
});
});

View File

@@ -1,6 +1,9 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { LoggingService } from 'src/database/logging/logging.service'; import { LoggingService } from 'src/database/logging/logging.service';
import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names';
import { PlayerCreatedEvent } from '../notifications/events/player-created.event';
import { Player } from 'src/players/entities/player.entity'; import { Player } from 'src/players/entities/player.entity';
import { TeamRole } from 'src/team-roles/entities/team-roles.entity'; import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto'; import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto';
@@ -51,6 +54,7 @@ export class TeamsService {
@InjectRepository(User) @InjectRepository(User)
private usersRepository: Repository<User>, private usersRepository: Repository<User>,
private dataSource: DataSource, private dataSource: DataSource,
private eventEmitter: EventEmitter2,
) {} ) {}
async getOverview(teamId: string, actorUserId: string) { async getOverview(teamId: string, actorUserId: string) {
@@ -165,6 +169,11 @@ export class TeamsService {
details: `Spieler ${playerSaved.id}, ${p.firstName} ${p.lastName} erstellt`, details: `Spieler ${playerSaved.id}, ${p.firstName} ${p.lastName} erstellt`,
userId: Number(id), userId: Number(id),
}); });
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.playerCreated,
new PlayerCreatedEvent(Number(id), Number(actorUserId), playerSaved.id, `${p.firstName} ${p.lastName}`),
);
return playerSaved; return playerSaved;
} }

View File

@@ -123,6 +123,11 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/team/more/guide/guide').then((m) => m.Guide), import('./features/team/more/guide/guide').then((m) => m.Guide),
}, },
{
path: 'notifications',
loadComponent: () =>
import('./features/team/notifications/notifications').then((m) => m.Notifications),
},
], ],
}, },
{ {

View File

@@ -12,6 +12,46 @@
} @else { } @else {
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span> <span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
} }
<span class="shell-header-spacer"></span>
<button
mat-icon-button
class="shell-notification-bell"
[matMenuTriggerFor]="notificationMenu"
(menuOpened)="onNotificationsMenuOpened()"
[matBadge]="unreadCount()"
[matBadgeHidden]="unreadCount() === 0"
matBadgeSize="small"
matBadgeColor="warn"
aria-label="Benachrichtigungen"
>
<mat-icon>notifications</mat-icon>
</button>
<mat-menu #notificationMenu="matMenu" class="shell-notification-menu">
<div class="shell-notification-menu__header">
<span>Benachrichtigungen</span>
<button mat-button (click)="onMarkAllRead()">Alle als gelesen markieren</button>
</div>
@if (notifications().length === 0) {
<div class="shell-notification-menu__empty">Keine Benachrichtigungen</div>
} @else {
@for (item of notifications(); track item.id) {
<button
mat-menu-item
class="shell-notification-menu__item"
[class.shell-notification-menu__item--unread]="!item.read"
(click)="onNotificationClick(item)"
>
<mat-icon>{{ notificationIcon(item) }}</mat-icon>
<span>{{ notificationLabel(item) }}</span>
</button>
}
@if (currentTeamId(); as teamId) {
<a mat-menu-item [routerLink]="['/team', teamId, 'notifications']">Alle anzeigen</a>
}
}
</mat-menu>
</mat-toolbar> </mat-toolbar>
<main class="shell-content"> <main class="shell-content">

View File

@@ -53,3 +53,37 @@ main {
} }
} }
} }
.shell-header-spacer {
flex: 1;
}
.shell-notification-bell {
color: var(--mat-sys-on-surface);
}
.shell-notification-menu {
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
gap: 0.5rem;
}
&__empty {
padding: 1rem;
color: var(--mat-sys-on-surface-variant);
font-size: 0.875rem;
}
&__item {
display: flex;
align-items: center;
gap: 0.5rem;
&--unread {
font-weight: 600;
}
}
}

View File

@@ -1,27 +1,55 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; import { ActivatedRoute, ParamMap, Router, convertToParamMap, provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Shell } from './shell'; import { Shell } from './shell';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { AuthStore } from '../../auth/auth-store'; import { AuthStore } from '../../auth/auth-store';
import { Player } from '../../../models/player.model'; import { Player } from '../../../models/player.model';
import { NotificationsStore } from '../../notifications/notifications-store';
describe('Shell', () => { describe('Shell', () => {
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
let authStore: AuthStore; let authStore: AuthStore;
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>; let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let notificationsStore: {
unreadCount: ReturnType<typeof signal<number>>;
notifications: ReturnType<typeof signal<any[]>>;
startPolling: ReturnType<typeof vi.fn>;
loadRecent: ReturnType<typeof vi.fn>;
markRead: ReturnType<typeof vi.fn>;
markAllRead: ReturnType<typeof vi.fn>;
};
beforeEach(async () => { beforeEach(async () => {
localStorage.clear(); localStorage.clear();
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' })); routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
notificationsStore = {
unreadCount: signal(3),
notifications: signal([
{
id: 1,
event: 'player_creation',
actorUserId: 9,
payload: { playerId: 21, playerName: 'Ada Lovelace' },
read: false,
createdAt: '2026-08-04T10:00:00.000Z',
},
]),
startPolling: vi.fn(),
loadRecent: vi.fn(),
markRead: vi.fn(),
markAllRead: vi.fn(),
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [Shell], imports: [Shell],
providers: [ providers: [
provideHttpClient(), provideHttpClient(),
provideHttpClientTesting(), provideHttpClientTesting(),
provideRouter([]), provideRouter([]),
{ provide: NotificationsStore, useValue: notificationsStore },
{ {
provide: ActivatedRoute, provide: ActivatedRoute,
useValue: { paramMap: routeParams.asObservable() }, useValue: { paramMap: routeParams.asObservable() },
@@ -153,4 +181,58 @@ describe('Shell', () => {
expect(httpMock.match((request) => request.url.includes('/teams/')).length).toBe(0); expect(httpMock.match((request) => request.url.includes('/teams/')).length).toBe(0);
}); });
it('starts polling notifications for the routed team id', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
expect(notificationsStore.startPolling).toHaveBeenCalledWith(5);
});
it('exposes the unread count from the notifications store', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
expect((fixture.componentInstance as any).unreadCount()).toBe(3);
});
it('loads recent notifications when the bell menu is opened', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
(fixture.componentInstance as any).onNotificationsMenuOpened();
expect(notificationsStore.loadRecent).toHaveBeenCalledWith(5);
});
it('marks a clicked notification as read and navigates to its target', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
const navigateSpy = vi.spyOn(TestBed.inject(Router), 'navigate');
const item = notificationsStore.notifications()[0];
(fixture.componentInstance as any).onNotificationClick(item);
expect(notificationsStore.markRead).toHaveBeenCalledWith(5, 1);
expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'members', 21]);
});
it('marks all notifications as read', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
(fixture.componentInstance as any).onMarkAllRead();
expect(notificationsStore.markAllRead).toHaveBeenCalledWith(5);
});
}); });

View File

@@ -1,4 +1,4 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
ActivatedRoute, ActivatedRoute,
@@ -7,6 +7,7 @@ import {
RouterLinkActive, RouterLinkActive,
RouterOutlet, RouterOutlet,
} from '@angular/router'; } from '@angular/router';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
@@ -14,7 +15,14 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { AuthStore } from '../../auth/auth-store'; import { AuthStore } from '../../auth/auth-store';
import { MyTeamsStore } from '../../team/my-teams-store'; import { MyTeamsStore } from '../../team/my-teams-store';
import { TeamStore } from '../../team/team-store'; import { TeamStore } from '../../team/team-store';
import { NotificationsStore } from '../../notifications/notifications-store';
import {
notificationIcon,
notificationLabel,
notificationTarget,
} from '../../notifications/notification-presentation';
import { UserTeamReference } from '../../../models/user-directory.model'; import { UserTeamReference } from '../../../models/user-directory.model';
import { NotificationItem } from '../../../models/notification.model';
@Component({ @Component({
selector: 'app-shell', selector: 'app-shell',
@@ -26,6 +34,7 @@ import { UserTeamReference } from '../../../models/user-directory.model';
MatIconModule, MatIconModule,
MatMenuModule, MatMenuModule,
MatButtonModule, MatButtonModule,
MatBadgeModule,
], ],
templateUrl: './shell.html', templateUrl: './shell.html',
styleUrl: './shell.scss', styleUrl: './shell.scss',
@@ -36,8 +45,12 @@ export class Shell {
private readonly authStore = inject(AuthStore); private readonly authStore = inject(AuthStore);
private readonly myTeamsStore = inject(MyTeamsStore); private readonly myTeamsStore = inject(MyTeamsStore);
private readonly teamStore = inject(TeamStore); private readonly teamStore = inject(TeamStore);
private readonly notificationsStore = inject(NotificationsStore);
protected readonly currentTeam = this.teamStore.team; protected readonly currentTeam = this.teamStore.team;
protected readonly currentTeamId = signal<number | null>(null);
protected readonly unreadCount = this.notificationsStore.unreadCount;
protected readonly notifications = this.notificationsStore.notifications;
protected readonly myTeams = computed(() => { protected readonly myTeams = computed(() => {
const seen = new Set<number>(); const seen = new Set<number>();
@@ -57,17 +70,13 @@ export class Shell {
this.myTeamsStore.ensureLoaded(userId); this.myTeamsStore.ensureLoaded(userId);
} }
// A direct subscription (not `effect()` + `toSignal()`) so the initial
// team load happens synchronously during construction, exactly like
// `ensureLoaded` above — `ActivatedRoute.paramMap` always replays its
// current value synchronously to a new subscriber. This keeps the
// component's behavior deterministic and trivial to test: no signal
// effect scheduling to wait for.
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => { this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
const raw = params.get('id'); const raw = params.get('id');
const id = raw === null ? Number.NaN : Number(raw); const id = raw === null ? Number.NaN : Number(raw);
if (Number.isInteger(id) && id > 0) { if (Number.isInteger(id) && id > 0) {
this.teamStore.loadTeam(id); this.teamStore.loadTeam(id);
this.currentTeamId.set(id);
this.notificationsStore.startPolling(id);
} }
}); });
} }
@@ -75,4 +84,33 @@ export class Shell {
protected switchTeam(teamId: number): void { protected switchTeam(teamId: number): void {
void this.router.navigate(['/team', teamId, 'overview']); void this.router.navigate(['/team', teamId, 'overview']);
} }
protected notificationLabel(item: NotificationItem): string {
return notificationLabel(item);
}
protected notificationIcon(item: NotificationItem): string {
return notificationIcon(item.event);
}
protected onNotificationsMenuOpened(): void {
const teamId = this.currentTeamId();
if (teamId !== null) {
this.notificationsStore.loadRecent(teamId);
}
}
protected onNotificationClick(item: NotificationItem): void {
const teamId = this.currentTeamId();
if (teamId === null) return;
this.notificationsStore.markRead(teamId, item.id);
void this.router.navigate(notificationTarget(item, teamId));
}
protected onMarkAllRead(): void {
const teamId = this.currentTeamId();
if (teamId !== null) {
this.notificationsStore.markAllRead(teamId);
}
}
} }

View File

@@ -0,0 +1,75 @@
import { NotificationItem } from '../../models/notification.model';
import { notificationIcon, notificationLabel, notificationTarget } from './notification-presentation';
function item(overrides: Partial<NotificationItem>): NotificationItem {
return {
id: 1,
event: 'player_creation',
actorUserId: 9,
payload: {},
read: false,
createdAt: '2026-08-04T10:00:00.000Z',
...overrides,
};
}
describe('notification-presentation', () => {
it('describes an active-state change', () => {
expect(
notificationLabel(item({ event: 'player_active_update', payload: { playerName: 'Ada Lovelace', active: false } })),
).toBe('Ada Lovelace wurde deaktiviert');
expect(
notificationLabel(item({ event: 'player_active_update', payload: { playerName: 'Ada Lovelace', active: true } })),
).toBe('Ada Lovelace wurde aktiviert');
});
it('describes a role change', () => {
expect(
notificationLabel(item({ event: 'player_team_role_update', payload: { playerName: 'Ada Lovelace' } })),
).toBe('Team-Rolle von Ada Lovelace wurde geändert');
});
it('describes a new player', () => {
expect(
notificationLabel(item({ event: 'player_creation', payload: { playerName: 'Ada Lovelace' } })),
).toBe('Ada Lovelace wurde zum Team hinzugefügt');
});
it('describes share-link events', () => {
expect(notificationLabel(item({ event: 'public_access_enabled' }))).toBe('Der Freigabelink wurde aktiviert');
expect(notificationLabel(item({ event: 'public_access_rotated' }))).toBe('Der Freigabelink wurde erneuert');
});
it('describes a new invite link', () => {
expect(notificationLabel(item({ event: 'user_invite_link_create' }))).toBe(
'Ein neuer Einladungslink wurde erstellt',
);
});
it('maps each event to an icon', () => {
expect(notificationIcon('player_active_update')).toBe('person');
expect(notificationIcon('player_team_role_update')).toBe('badge');
expect(notificationIcon('player_creation')).toBe('person_add');
expect(notificationIcon('public_access_enabled')).toBe('link');
expect(notificationIcon('public_access_rotated')).toBe('link');
expect(notificationIcon('user_invite_link_create')).toBe('mail');
});
it('routes player-related notifications to the member detail page', () => {
expect(notificationTarget(item({ event: 'player_creation', payload: { playerId: 21 } }), 5)).toEqual([
'/team', 5, 'members', 21,
]);
});
it('routes share-link notifications to the public-access settings page', () => {
expect(notificationTarget(item({ event: 'public_access_rotated' }), 5)).toEqual([
'/team', 5, 'more', 'public-access',
]);
});
it('routes invite-link notifications to the invite page', () => {
expect(notificationTarget(item({ event: 'user_invite_link_create' }), 5)).toEqual([
'/team', 5, 'more', 'invite',
]);
});
});

View File

@@ -0,0 +1,50 @@
import { NotificationEvent, NotificationItem } from '../../models/notification.model';
export function notificationLabel(item: NotificationItem): string {
switch (item.event) {
case 'player_active_update':
return item.payload.active
? `${item.payload.playerName} wurde aktiviert`
: `${item.payload.playerName} wurde deaktiviert`;
case 'player_team_role_update':
return `Team-Rolle von ${item.payload.playerName} wurde geändert`;
case 'player_creation':
return `${item.payload.playerName} wurde zum Team hinzugefügt`;
case 'public_access_enabled':
return 'Der Freigabelink wurde aktiviert';
case 'public_access_rotated':
return 'Der Freigabelink wurde erneuert';
case 'user_invite_link_create':
return 'Ein neuer Einladungslink wurde erstellt';
}
}
export function notificationIcon(event: NotificationEvent): string {
switch (event) {
case 'player_active_update':
return 'person';
case 'player_team_role_update':
return 'badge';
case 'player_creation':
return 'person_add';
case 'public_access_enabled':
case 'public_access_rotated':
return 'link';
case 'user_invite_link_create':
return 'mail';
}
}
export function notificationTarget(item: NotificationItem, teamId: number): (string | number)[] {
switch (item.event) {
case 'player_active_update':
case 'player_team_role_update':
case 'player_creation':
return ['/team', teamId, 'members', item.payload.playerId ?? 0];
case 'public_access_enabled':
case 'public_access_rotated':
return ['/team', teamId, 'more', 'public-access'];
case 'user_invite_link_create':
return ['/team', teamId, 'more', 'invite'];
}
}

View File

@@ -0,0 +1,48 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { environment } from '../../../environments/environment';
import { NotificationsApi } from './notifications-api';
describe('NotificationsApi', () => {
let api: NotificationsApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(NotificationsApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads a page of notifications for a team', () => {
api.loadNotifications(5, { page: 2, limit: 20 }).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/notifications?page=2&limit=20`);
expect(request.request.method).toBe('GET');
request.flush({ data: [], page: 2, limit: 20, total: 0, hasNextPage: false });
});
it('loads the unread count for a team', () => {
api.loadUnreadCount(5).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/notifications/unread-count`);
expect(request.request.method).toBe('GET');
request.flush({ count: 0 });
});
it('marks a single notification as read', () => {
api.markRead(5, 7).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/notifications/7/read`);
expect(request.request.method).toBe('PATCH');
request.flush(null);
});
it('marks all notifications as read', () => {
api.markAllRead(5).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/notifications/read-all`);
expect(request.request.method).toBe('PATCH');
request.flush(null);
});
});

View File

@@ -0,0 +1,31 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { NotificationPage, NotificationQuery } from '../../models/notification.model';
@Injectable({ providedIn: 'root' })
export class NotificationsApi {
private readonly http = inject(HttpClient);
loadNotifications(teamId: number, query: NotificationQuery): Observable<NotificationPage> {
const params = new HttpParams().set('page', query.page).set('limit', query.limit);
return this.http.get<NotificationPage>(`${environment.apiUrl}teams/${teamId}/notifications`, {
params,
});
}
loadUnreadCount(teamId: number): Observable<{ count: number }> {
return this.http.get<{ count: number }>(
`${environment.apiUrl}teams/${teamId}/notifications/unread-count`,
);
}
markRead(teamId: number, id: number): Observable<void> {
return this.http.patch<void>(`${environment.apiUrl}teams/${teamId}/notifications/${id}/read`, {});
}
markAllRead(teamId: number): Observable<void> {
return this.http.patch<void>(`${environment.apiUrl}teams/${teamId}/notifications/read-all`, {});
}
}

View File

@@ -0,0 +1,102 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { NotificationsApi } from './notifications-api';
import { NotificationsStore } from './notifications-store';
describe('NotificationsStore', () => {
let api: {
loadUnreadCount: ReturnType<typeof vi.fn>;
loadNotifications: ReturnType<typeof vi.fn>;
markRead: ReturnType<typeof vi.fn>;
markAllRead: ReturnType<typeof vi.fn>;
};
let store: NotificationsStore;
beforeEach(() => {
api = {
loadUnreadCount: vi.fn().mockReturnValue(of({ count: 0 })),
loadNotifications: vi.fn().mockReturnValue(of({ data: [], page: 1, limit: 20, total: 0, hasNextPage: false })),
markRead: vi.fn().mockReturnValue(of(undefined)),
markAllRead: vi.fn().mockReturnValue(of(undefined)),
};
TestBed.configureTestingModule({ providers: [{ provide: NotificationsApi, useValue: api }] });
store = TestBed.inject(NotificationsStore);
});
it('polls the unread count immediately when polling starts for a team', () => {
api.loadUnreadCount.mockReturnValue(of({ count: 4 }));
store.startPolling(10);
expect(api.loadUnreadCount).toHaveBeenCalledWith(10);
expect(store.unreadCount()).toBe(4);
});
it('does not start a second poll loop for the same team id', () => {
store.startPolling(10);
store.startPolling(10);
expect(api.loadUnreadCount).toHaveBeenCalledTimes(1);
});
it('switches polling to a newly routed team', () => {
store.startPolling(10);
api.loadUnreadCount.mockReturnValue(of({ count: 7 }));
store.startPolling(11);
expect(api.loadUnreadCount).toHaveBeenCalledWith(11);
expect(store.unreadCount()).toBe(7);
});
it('loads the recent notification list', () => {
const data = [
{
id: 1,
event: 'player_creation' as const,
actorUserId: 9,
payload: { playerId: 21, playerName: 'Ada Lovelace' },
read: false,
createdAt: '2026-08-04T10:00:00.000Z',
},
];
api.loadNotifications.mockReturnValue(of({ data, page: 1, limit: 20, total: 1, hasNextPage: false }));
store.loadRecent(10);
expect(api.loadNotifications).toHaveBeenCalledWith(10, { page: 1, limit: 20 });
expect(store.notifications()).toEqual(data);
});
it('marks a notification as read locally and decrements the unread count', () => {
api.loadUnreadCount.mockReturnValue(of({ count: 3 }));
store.startPolling(10);
const data = [
{ id: 1, event: 'player_creation' as const, actorUserId: 9, payload: {}, read: false, createdAt: 'x' },
];
api.loadNotifications.mockReturnValue(of({ data, page: 1, limit: 20, total: 1, hasNextPage: false }));
store.loadRecent(10);
store.markRead(10, 1);
expect(api.markRead).toHaveBeenCalledWith(10, 1);
expect(store.notifications()[0].read).toBe(true);
expect(store.unreadCount()).toBe(2);
});
it('marks all notifications as read locally and zeroes the unread count', () => {
api.loadUnreadCount.mockReturnValue(of({ count: 5 }));
store.startPolling(10);
const data = [
{ id: 1, event: 'player_creation' as const, actorUserId: 9, payload: {}, read: false, createdAt: 'x' },
];
api.loadNotifications.mockReturnValue(of({ data, page: 1, limit: 20, total: 1, hasNextPage: false }));
store.loadRecent(10);
store.markAllRead(10);
expect(api.markAllRead).toHaveBeenCalledWith(10);
expect(store.notifications()[0].read).toBe(true);
expect(store.unreadCount()).toBe(0);
});
});

View File

@@ -0,0 +1,71 @@
import { Injectable, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject, interval } from 'rxjs';
import { startWith, switchMap } from 'rxjs/operators';
import { NotificationItem } from '../../models/notification.model';
import { NotificationsApi } from './notifications-api';
const POLL_INTERVAL_MS = 30000;
const DROPDOWN_PAGE_SIZE = 20;
@Injectable({ providedIn: 'root' })
export class NotificationsStore {
private readonly api = inject(NotificationsApi);
private readonly unreadCountSignal = signal(0);
private readonly notificationsSignal = signal<NotificationItem[]>([]);
private readonly loadingSignal = signal(false);
private readonly pollingTeamId = signal<number | null>(null);
private readonly pollRequests = new Subject<number>();
readonly unreadCount = this.unreadCountSignal.asReadonly();
readonly notifications = this.notificationsSignal.asReadonly();
readonly loading = this.loadingSignal.asReadonly();
constructor() {
this.pollRequests
.pipe(
switchMap((teamId) =>
interval(POLL_INTERVAL_MS).pipe(
startWith(-1),
switchMap(() => this.api.loadUnreadCount(teamId)),
),
),
takeUntilDestroyed(),
)
.subscribe((result) => this.unreadCountSignal.set(result.count));
}
startPolling(teamId: number): void {
if (this.pollingTeamId() === teamId) return;
this.pollingTeamId.set(teamId);
this.pollRequests.next(teamId);
}
loadRecent(teamId: number): void {
this.loadingSignal.set(true);
this.api.loadNotifications(teamId, { page: 1, limit: DROPDOWN_PAGE_SIZE }).subscribe({
next: (page) => {
this.notificationsSignal.set(page.data);
this.loadingSignal.set(false);
},
error: () => this.loadingSignal.set(false),
});
}
markRead(teamId: number, id: number): void {
this.api.markRead(teamId, id).subscribe(() => {
this.notificationsSignal.update((items) =>
items.map((item) => (item.id === id ? { ...item, read: true } : item)),
);
this.unreadCountSignal.update((count) => Math.max(0, count - 1));
});
}
markAllRead(teamId: number): void {
this.api.markAllRead(teamId).subscribe(() => {
this.notificationsSignal.update((items) => items.map((item) => ({ ...item, read: true })));
this.unreadCountSignal.set(0);
});
}
}

View File

@@ -0,0 +1,29 @@
<div class="notifications-page">
<h1>Benachrichtigungen</h1>
@if (items().length === 0 && !loading()) {
<p class="notifications-page__empty">Keine Benachrichtigungen vorhanden.</p>
}
<mat-nav-list>
@for (item of items(); track item.id) {
<a
mat-list-item
class="notifications-page__item"
[class.notifications-page__item--unread]="!item.read"
(click)="onItemClick(item)"
>
<mat-icon matListItemIcon>{{ notificationIcon(item) }}</mat-icon>
<span matListItemTitle>{{ notificationLabel(item) }}</span>
</a>
}
</mat-nav-list>
@if (loading()) {
<mat-spinner diameter="32" class="notifications-page__spinner" />
}
@if (hasNextPage() && !loading()) {
<button mat-button (click)="loadMore()">Weitere laden</button>
}
</div>

View File

@@ -0,0 +1,15 @@
.notifications-page {
padding: 1rem;
&__empty {
color: var(--mat-sys-on-surface-variant);
}
&__item--unread {
font-weight: 600;
}
&__spinner {
margin: 1rem auto;
}
}

View File

@@ -0,0 +1,74 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, ParamMap, Router, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject, of } from 'rxjs';
import { Notifications } from './notifications';
import { NotificationsApi } from '../../../core/notifications/notifications-api';
import { NotificationsStore } from '../../../core/notifications/notifications-store';
import { NotificationItem } from '../../../models/notification.model';
describe('Notifications', () => {
let routeParams: BehaviorSubject<ParamMap>;
let fixture: ComponentFixture<Notifications>;
let api: { loadNotifications: ReturnType<typeof vi.fn> };
let store: { markRead: ReturnType<typeof vi.fn> };
const item: NotificationItem = {
id: 1,
event: 'player_creation',
actorUserId: 9,
payload: { playerId: 21, playerName: 'Ada Lovelace' },
read: false,
createdAt: '2026-08-04T10:00:00.000Z',
};
beforeEach(async () => {
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
api = { loadNotifications: vi.fn() };
store = { markRead: vi.fn() };
await TestBed.configureTestingModule({
imports: [Notifications],
providers: [
provideRouter([]),
{ provide: NotificationsApi, useValue: api },
{ provide: NotificationsStore, useValue: store },
{ provide: ActivatedRoute, useValue: { parent: { paramMap: routeParams } } },
],
}).compileComponents();
fixture = TestBed.createComponent(Notifications);
});
it('loads the first page for the routed team id', () => {
api.loadNotifications.mockReturnValue(of({ data: [item], page: 1, limit: 20, total: 1, hasNextPage: false }));
fixture.detectChanges();
expect(api.loadNotifications).toHaveBeenCalledWith(5, { page: 1, limit: 20 });
expect((fixture.componentInstance as any).items()).toEqual([item]);
});
it('loads the next page and appends results', () => {
api.loadNotifications
.mockReturnValueOnce(of({ data: [item], page: 1, limit: 20, total: 21, hasNextPage: true }))
.mockReturnValueOnce(of({ data: [{ ...item, id: 2 }], page: 2, limit: 20, total: 21, hasNextPage: false }));
fixture.detectChanges();
(fixture.componentInstance as any).loadMore();
expect(api.loadNotifications).toHaveBeenLastCalledWith(5, { page: 2, limit: 20 });
expect((fixture.componentInstance as any).items().length).toBe(2);
});
it('marks a clicked item as read and navigates to its target', () => {
api.loadNotifications.mockReturnValue(of({ data: [item], page: 1, limit: 20, total: 1, hasNextPage: false }));
fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate');
(fixture.componentInstance as any).onItemClick(item);
expect(store.markRead).toHaveBeenCalledWith(5, 1);
expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'members', 21]);
});
});

View File

@@ -0,0 +1,92 @@
import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { NotificationItem } from '../../../models/notification.model';
import { NotificationsApi } from '../../../core/notifications/notifications-api';
import { NotificationsStore } from '../../../core/notifications/notifications-store';
import {
notificationIcon,
notificationLabel,
notificationTarget,
} from '../../../core/notifications/notification-presentation';
const PAGE_SIZE = 20;
@Component({
selector: 'app-notifications',
imports: [MatButtonModule, MatIconModule, MatListModule, MatProgressSpinnerModule],
templateUrl: './notifications.html',
styleUrl: './notifications.scss',
})
export class Notifications implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly api = inject(NotificationsApi);
private readonly notificationsStore = inject(NotificationsStore);
private readonly destroyRef = inject(DestroyRef);
protected readonly items = signal<NotificationItem[]>([]);
protected readonly loading = signal(false);
protected readonly hasNextPage = signal(false);
private teamId: number | null = null;
private page = 1;
ngOnInit(): void {
const parentRoute = this.route.parent;
if (!parentRoute) return;
parentRoute.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
const raw = params.get('id');
const id = raw === null ? Number.NaN : Number(raw);
if (Number.isInteger(id) && id > 0 && id !== this.teamId) {
this.teamId = id;
this.page = 1;
this.items.set([]);
this.hasNextPage.set(false);
this.loadPage();
}
});
}
protected notificationLabel(item: NotificationItem): string {
return notificationLabel(item);
}
protected notificationIcon(item: NotificationItem): string {
return notificationIcon(item.event);
}
protected loadMore(): void {
this.page += 1;
this.loadPage();
}
protected onItemClick(item: NotificationItem): void {
if (this.teamId === null) return;
const teamId = this.teamId;
this.notificationsStore.markRead(teamId, item.id);
this.items.update((current) =>
current.map((entry) => (entry.id === item.id ? { ...entry, read: true } : entry)),
);
void this.router.navigate(notificationTarget(item, teamId));
}
private loadPage(): void {
if (this.teamId === null) return;
const teamId = this.teamId;
this.loading.set(true);
this.api.loadNotifications(teamId, { page: this.page, limit: PAGE_SIZE }).subscribe({
next: (result) => {
this.items.update((current) => [...current, ...result.data]);
this.hasNextPage.set(result.hasNextPage);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,37 @@
export type NotificationEvent =
| 'player_active_update'
| 'player_team_role_update'
| 'player_creation'
| 'public_access_enabled'
| 'public_access_rotated'
| 'user_invite_link_create';
export interface NotificationPayload {
playerId?: number;
playerName?: string;
active?: boolean;
teamRoleId?: number;
teamName?: string;
}
export interface NotificationItem {
id: number;
event: NotificationEvent;
actorUserId: number;
payload: NotificationPayload;
read: boolean;
createdAt: string;
}
export interface NotificationQuery {
page: number;
limit: number;
}
export interface NotificationPage {
data: NotificationItem[];
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}