feat: add notification model, presentation helpers, and API client

This commit is contained in:
Bastian Wagner
2026-08-04 19:51:11 +02:00
parent a7b087050c
commit 6b3a9d69cc
5 changed files with 241 additions and 0 deletions

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