feat: add notification model, presentation helpers, and API client
This commit is contained in:
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`, {});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user