diff --git a/myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.spec.ts b/myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.spec.ts new file mode 100644 index 0000000..7ce37c2 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.spec.ts @@ -0,0 +1,75 @@ +import { NotificationItem } from '../../models/notification.model'; +import { notificationIcon, notificationLabel, notificationTarget } from './notification-presentation'; + +function item(overrides: Partial): 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', + ]); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.ts b/myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.ts new file mode 100644 index 0000000..d745dee --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.ts @@ -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']; + } +} diff --git a/myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.spec.ts new file mode 100644 index 0000000..31e1e76 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.spec.ts @@ -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); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.ts b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.ts new file mode 100644 index 0000000..d8d73b1 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.ts @@ -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 { + const params = new HttpParams().set('page', query.page).set('limit', query.limit); + return this.http.get(`${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 { + return this.http.patch(`${environment.apiUrl}teams/${teamId}/notifications/${id}/read`, {}); + } + + markAllRead(teamId: number): Observable { + return this.http.patch(`${environment.apiUrl}teams/${teamId}/notifications/read-all`, {}); + } +} diff --git a/myteamwallet_frontend_modern/src/app/models/notification.model.ts b/myteamwallet_frontend_modern/src/app/models/notification.model.ts new file mode 100644 index 0000000..6a833fb --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/models/notification.model.ts @@ -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; +}