diff --git a/README.md b/README.md index fc47f11..92e565a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ If `songUsage` needs to be rebuilt from all existing shows, log in with a user t await window.wgeneratorAdmin.rebuildSongUsage() ``` +If the `songIds` index on shows needs to be backfilled for tooltip usage in song details, run: + +```js +await window.wgeneratorAdmin.rebuildShowSongIds() +``` + +The command logs progress in the browser console while it runs and prints the final summary when finished. + The migration: - resets `songUsage` for all users @@ -16,4 +24,11 @@ The migration: It returns a summary object with processed user, show and show-song counts. +The show index migration: + +- scans all shows and all `shows/{id}/songs` entries +- writes the distinct `songIds` array to each show document + +It returns a summary object with processed show and show-song counts. + This is intended as a manual one-off migration and is read-heavy by design. diff --git a/src/app/modules/shows/list/list.component.html b/src/app/modules/shows/list/list.component.html index 1ad910d..3a934a4 100644 --- a/src/app/modules/shows/list/list.component.html +++ b/src/app/modules/shows/list/list.component.html @@ -13,7 +13,7 @@ [padding]="false" heading="Meine Veranstaltungen" > - @for (show of shows | sortBy: 'desc':'date'; track show) { + @for (show of shows | sortBy: 'desc':'date'; track trackBy($index, show)) { { + let service: ShowSongIndexService; + let showDataServiceSpy: jasmine.SpyObj; + let showSongDataServiceSpy: jasmine.SpyObj; + let sessionSpy: jasmine.SpyObj; + + beforeEach(async () => { + showDataServiceSpy = jasmine.createSpyObj('ShowDataService', ['listRaw$', 'update']); + showSongDataServiceSpy = jasmine.createSpyObj('ShowSongDataService', ['list$']); + sessionSpy = jasmine.createSpyObj('UserSessionService', ['update$'], { + user$: of({id: 'admin-1', name: 'Admin', role: 'admin', chordMode: 'onlyFirst', songUsage: {}} as User), + }); + + showDataServiceSpy.listRaw$.and.returnValue(of([{id: 'show-1'}, {id: 'show-2'}] as never) as unknown as ReturnType); + showDataServiceSpy.update.and.resolveTo(); + showSongDataServiceSpy.list$.and.callFake((showId: string) => { + if (showId === 'show-1') { + return of([{songId: 'song-1'}, {songId: 'song-2'}, {songId: 'song-1'}] as never) as never; + } + + return of([{songId: 'song-3'}] as never) as never; + }); + + await TestBed.configureTestingModule({ + providers: [ + {provide: ShowDataService, useValue: showDataServiceSpy}, + {provide: ShowSongDataService, useValue: showSongDataServiceSpy}, + {provide: UserSessionService, useValue: sessionSpy}, + ], + }); + + service = TestBed.inject(ShowSongIndexService); + }); + + it('should rebuild the distinct songIds index for all shows', async () => { + await expectAsync(service.rebuildShowSongIds()).toBeResolvedTo({ + showsProcessed: 2, + showSongsProcessed: 4, + }); + + expect(showDataServiceSpy.update).toHaveBeenCalledWith('show-1', {songIds: ['song-1', 'song-2']}); + expect(showDataServiceSpy.update).toHaveBeenCalledWith('show-2', {songIds: ['song-3']}); + }); + + it('should reject index rebuilds for non-admin users', async () => { + Object.defineProperty(sessionSpy, 'user$', { + value: of({id: 'user-1', name: 'User', role: 'leader', chordMode: 'onlyFirst', songUsage: {}} as User), + }); + + await expectAsync(service.rebuildShowSongIds()).toBeRejectedWithError('Admin role required to rebuild show song ids.'); + }); +}); diff --git a/src/app/modules/shows/services/show-song-index.service.ts b/src/app/modules/shows/services/show-song-index.service.ts new file mode 100644 index 0000000..f4d4c59 --- /dev/null +++ b/src/app/modules/shows/services/show-song-index.service.ts @@ -0,0 +1,65 @@ +import {Injectable, inject} from '@angular/core'; +import {firstValueFrom} from 'rxjs'; +import {take} from 'rxjs/operators'; +import {ShowDataService} from './show-data.service'; +import {ShowSongDataService} from './show-song-data.service'; +import {UserSessionService} from '../../../services/user/user-session.service'; + +export interface ShowSongIndexMigrationResult { + showsProcessed: number; + showSongsProcessed: number; +} + +export interface MigrationProgress { + processed: number; + total: number; + showId: string; + showSongsProcessed: number; +} + +@Injectable({ + providedIn: 'root', +}) +export class ShowSongIndexService { + private session = inject(UserSessionService); + private showDataService = inject(ShowDataService); + private showSongDataService = inject(ShowSongDataService); + + public async rebuildShowSongIds(onProgress?: (progress: MigrationProgress) => void): Promise { + const currentUser = await firstValueFrom(this.session.user$.pipe(take(1))); + if (!currentUser || !this.hasAdminRole(currentUser.role)) { + throw new Error('Admin role required to rebuild show song ids.'); + } + + const shows = await firstValueFrom(this.showDataService.listRaw$()); + let showSongsProcessed = 0; + let processed = 0; + + for (const show of shows) { + const showSongs = await firstValueFrom(this.showSongDataService.list$(show.id)); + const songIds = [...new Set(showSongs.map(showSong => showSong.songId).filter(Boolean))]; + showSongsProcessed += showSongs.length; + await this.showDataService.update(show.id, {songIds}); + processed += 1; + onProgress?.({ + processed, + total: shows.length, + showId: show.id, + showSongsProcessed, + }); + } + + return { + showsProcessed: shows.length, + showSongsProcessed, + }; + } + + private hasAdminRole(role: string | null | undefined): boolean { + if (!role) { + return false; + } + + return role.split(';').includes('admin'); + } +} diff --git a/src/app/modules/shows/services/show-song.service.spec.ts b/src/app/modules/shows/services/show-song.service.spec.ts index 9218194..09f875d 100644 --- a/src/app/modules/shows/services/show-song.service.spec.ts +++ b/src/app/modules/shows/services/show-song.service.spec.ts @@ -22,7 +22,7 @@ describe('ShowSongService', () => { const show = {id: 'show-1', order: ['show-song-1', 'show-song-2']} as unknown as Show; beforeEach(async () => { - user$ = new BehaviorSubject({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'letters', songUsage: {}}); + user$ = new BehaviorSubject({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'onlyFirst', songUsage: {}}); showSongDataServiceSpy = jasmine.createSpyObj('ShowSongDataService', ['add', 'read$', 'list$', 'delete', 'update$']); songDataServiceSpy = jasmine.createSpyObj('SongDataService', ['read$']); userServiceSpy = jasmine.createSpyObj('UserService', ['incSongCount', 'decSongCount'], { @@ -66,7 +66,7 @@ describe('ShowSongService', () => { songId: 'song-1', key: 'G', keyOriginal: 'G', - chordMode: 'letters', + chordMode: 'onlyFirst', addedLive: true, }); }); @@ -103,7 +103,7 @@ describe('ShowSongService', () => { await service.delete$('show-1', 'show-song-1', 0); expect(showSongDataServiceSpy.delete).toHaveBeenCalledWith('show-1', 'show-song-1'); - expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {order: ['show-song-2']}); + expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', jasmine.objectContaining({order: ['show-song-2']})); expect(userServiceSpy.decSongCount).toHaveBeenCalledWith('song-1'); }); diff --git a/src/app/modules/shows/services/show-song.service.ts b/src/app/modules/shows/services/show-song.service.ts index 3fdd330..3294661 100644 --- a/src/app/modules/shows/services/show-song.service.ts +++ b/src/app/modules/shows/services/show-song.service.ts @@ -5,6 +5,7 @@ import {ShowSong} from './show-song'; import {SongDataService} from '../../songs/services/song-data.service'; import {UserService} from '../../../services/user/user.service'; import {ShowService} from './show.service'; +import {arrayRemove, arrayUnion} from '@angular/fire/firestore'; @Injectable({ providedIn: 'root', @@ -27,8 +28,9 @@ export class ShowSongService { chordMode: user.chordMode, addedLive, }; - await this.userService.incSongCount(songId); - return await this.showSongDataService.add(showId, data); + const showSongId = await this.showSongDataService.add(showId, data); + await Promise.all([this.userService.incSongCount(songId), this.showService.update$(showId, {songIds: arrayUnion(songId) as never})]); + return showSongId; } public read$ = (showId: string, songId: string): Observable => this.showSongDataService.read$(showId, songId); @@ -38,14 +40,19 @@ export class ShowSongService { public list = (showId: string): Promise => firstValueFrom(this.list$(showId)); public async delete$(showId: string, showSongId: string, index: number): Promise { - const [showSong, show] = await Promise.all([this.read(showId, showSongId), firstValueFrom(this.showService.read$(showId))]); + const [showSong, show, showSongs] = await Promise.all([this.read(showId, showSongId), firstValueFrom(this.showService.read$(showId)), this.list(showId)]); if (!show) return; if (!showSong) return; const order = [...show.order]; order.splice(index, 1); + const hasSameSongStillInShow = showSongs.some(song => song.id !== showSongId && song.songId === showSong.songId); - await Promise.all([this.showSongDataService.delete(showId, showSongId), this.showService.update$(showId, {order}), this.userService.decSongCount(showSong.songId)]); + await Promise.all([ + this.showSongDataService.delete(showId, showSongId), + this.showService.update$(showId, hasSameSongStillInShow ? {order} : {order, songIds: arrayRemove(showSong.songId) as never}), + this.userService.decSongCount(showSong.songId), + ]); } public update$ = async (showId: string, songId: string, data: Partial): Promise => await this.showSongDataService.update$(showId, songId, data); diff --git a/src/app/modules/shows/services/show.service.spec.ts b/src/app/modules/shows/services/show.service.spec.ts index bfb2cc0..4ebd38c 100644 --- a/src/app/modules/shows/services/show.service.spec.ts +++ b/src/app/modules/shows/services/show.service.spec.ts @@ -16,9 +16,11 @@ describe('ShowService', () => { beforeEach(async () => { user$ = new BehaviorSubject({id: 'user-1'}); - showDataServiceSpy = jasmine.createSpyObj('ShowDataService', ['read$', 'listPublicSince$', 'update', 'add'], { - list$: of(shows), - }); + showDataServiceSpy = jasmine.createSpyObj( + 'ShowDataService', + ['read$', 'listPublicSince$', 'update', 'add'], + {list$: of(shows) as unknown as ShowDataService['list$']} + ); showDataServiceSpy.read$.and.returnValue(of(shows[0])); showDataServiceSpy.listPublicSince$.and.returnValue(of([shows[1]])); showDataServiceSpy.update.and.resolveTo(); @@ -97,6 +99,7 @@ describe('ShowService', () => { showType: type, owner: 'user-1', order: [], + songIds: [], public: true, }); }); @@ -111,6 +114,7 @@ describe('ShowService', () => { showType: type, owner: 'user-1', order: [], + songIds: [], public: false, }); }); diff --git a/src/app/modules/shows/services/show.service.ts b/src/app/modules/shows/services/show.service.ts index 76c2b6c..63e4581 100644 --- a/src/app/modules/shows/services/show.service.ts +++ b/src/app/modules/shows/services/show.service.ts @@ -39,6 +39,7 @@ export class ShowService { ...data, owner: user.id, order: [], + songIds: [], public: ShowService.SHOW_TYPE_PUBLIC.indexOf(data.showType) !== -1, }; return await this.showDataService.add(calculatedData); diff --git a/src/app/modules/shows/services/show.ts b/src/app/modules/shows/services/show.ts index 9425eb3..e5e1ec7 100644 --- a/src/app/modules/shows/services/show.ts +++ b/src/app/modules/shows/services/show.ts @@ -7,6 +7,7 @@ export interface Show { showType: string; date: Timestamp; owner: string; + songIds?: string[]; public: boolean; reported: boolean; published: boolean; diff --git a/src/app/modules/shows/show/show.component.ts b/src/app/modules/shows/show/show.component.ts index c932bdd..f8898b5 100644 --- a/src/app/modules/shows/show/show.component.ts +++ b/src/app/modules/shows/show/show.component.ts @@ -49,6 +49,7 @@ import {OwnerDirective} from '../../../services/user/owner.directive'; import {ButtonComponent} from '../../../widget-modules/components/button/button.component'; import {MatMenu, MatMenuTrigger} from '@angular/material/menu'; import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe'; +import {UserService} from '../../../services/user/user.service'; @Component({ selector: 'app-show', @@ -88,6 +89,7 @@ export class ShowComponent implements OnInit, OnDestroy { private docxService = inject(DocxService); private router = inject(Router); private cRef = inject(ChangeDetectorRef); + private userService = inject(UserService); public dialog = inject(MatDialog); private guestShowService = inject(GuestShowService); @@ -171,14 +173,14 @@ export class ShowComponent implements OnInit, OnDestroy { } public onArchive(archived: boolean): void { - if (!archived && this.showId != null) void this.showService.update$(this.showId, {archived}); + if (!archived && this.showId != null) void this.setArchiveState(false); else { const dialogRef = this.dialog.open(ArchiveDialogComponent, { width: '350px', }); dialogRef.afterClosed().pipe(take(1)).subscribe((archive: boolean) => { - if (archive && this.showId != null) void this.showService.update$(this.showId, {archived}); + if (archive && this.showId != null) void this.setArchiveState(true); }); } } @@ -274,6 +276,20 @@ export class ShowComponent implements OnInit, OnDestroy { const widthInCh = Math.max(3, longestLabelLength); return `${widthInCh}ch`; } + + private async setArchiveState(archived: boolean): Promise { + if (!this.showId) { + return; + } + + const updates: Array> = [this.showService.update$(this.showId, {archived})]; + + (this.showSongs ?? []).forEach(showSong => { + updates.push(archived ? this.userService.decSongCount(showSong.songId) : this.userService.incSongCount(showSong.songId)); + }); + + await Promise.all(updates); + } } export interface Swiper { diff --git a/src/app/modules/songs/song/song.component.html b/src/app/modules/songs/song/song.component.html index 53232e8..f5a801f 100644 --- a/src/app/modules/songs/song/song.component.html +++ b/src/app/modules/songs/song/song.component.html @@ -40,7 +40,12 @@ @if (song.origin) {
Quelle: {{ song.origin }}
} -
Wie oft verwendet: {{ songCount$ | async }}
+
+ Wie oft verwendet: {{ songCount$ | async }} +
@if (user$ | async; as user) { @@ -81,7 +86,7 @@ Zu Veranstaltung hinzufügen - @for (show of privateShows$|async; track show) { + @for (show of privateShows$|async; track show.id) { {{ show.date.toDate() | date: "dd.MM.yyyy" }} {{ show.showType | showType }} diff --git a/src/app/modules/songs/song/song.component.spec.ts b/src/app/modules/songs/song/song.component.spec.ts index 4d5a7b7..1cd36c9 100644 --- a/src/app/modules/songs/song/song.component.spec.ts +++ b/src/app/modules/songs/song/song.component.spec.ts @@ -3,6 +3,11 @@ import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; import {SongComponent} from './song.component'; import {of} from 'rxjs'; import {ActivatedRoute} from '@angular/router'; +import {SongService} from '../services/song.service'; +import {FileDataService} from '../services/file-data.service'; +import {UserService} from '../../../services/user/user.service'; +import {ShowService} from '../../shows/services/show.service'; +import {ShowSongService} from '../../shows/services/show-song.service'; describe('SongComponent', () => { let component: SongComponent; @@ -13,9 +18,28 @@ describe('SongComponent', () => { }; beforeEach(waitForAsync(() => { + const songServiceSpy = jasmine.createSpyObj('SongService', ['read$']); + const fileDataServiceSpy = jasmine.createSpyObj('FileDataService', ['read$']); + const userServiceSpy = jasmine.createSpyObj('UserService', ['incSongCount', 'decSongCount'], { + user$: of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'onlyFirst', songUsage: {'4711': 2}}), + }); + const showServiceSpy = jasmine.createSpyObj('ShowService', ['list$', 'update$']); + const showSongServiceSpy = jasmine.createSpyObj('ShowSongService', ['new$']); + + songServiceSpy.read$.and.returnValue(of({id: '4711', title: 'Test Song', number: '1', text: '', showType: '', flags: ''} as never)); + fileDataServiceSpy.read$.and.returnValue(of([])); + showServiceSpy.list$.and.returnValue(of([])); + void TestBed.configureTestingModule({ imports: [SongComponent], - providers: [{provide: ActivatedRoute, useValue: mockActivatedRoute}], + providers: [ + {provide: ActivatedRoute, useValue: mockActivatedRoute}, + {provide: SongService, useValue: songServiceSpy}, + {provide: FileDataService, useValue: fileDataServiceSpy}, + {provide: UserService, useValue: userServiceSpy}, + {provide: ShowService, useValue: showServiceSpy}, + {provide: ShowSongService, useValue: showSongServiceSpy}, + ], }).compileComponents(); })); diff --git a/src/app/modules/songs/song/song.component.ts b/src/app/modules/songs/song/song.component.ts index 82c9490..d5f95f0 100644 --- a/src/app/modules/songs/song/song.component.ts +++ b/src/app/modules/songs/song/song.component.ts @@ -25,6 +25,7 @@ import {SongTypePipe} from '../../../widget-modules/pipes/song-type-translater/s import {LegalOwnerPipe} from '../../../widget-modules/pipes/legal-owner-translator/legal-owner.pipe'; import {StatusPipe} from '../../../widget-modules/pipes/status-translater/status.pipe'; import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe'; +import {MatTooltip} from '@angular/material/tooltip'; @Component({ selector: 'app-song', @@ -48,6 +49,7 @@ import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/s LegalOwnerPipe, StatusPipe, ShowTypePipe, + MatTooltip, ], }) export class SongComponent implements OnInit { @@ -63,10 +65,14 @@ export class SongComponent implements OnInit { public files$: Observable | null = null; public user$: Observable | null = null; public songCount$: Observable | null = null; + public songUsageShows$: Observable | null = null; + public songUsageTooltip$: Observable | null = null; public faEdit = faEdit; public faDelete = faTrash; public faFileCirclePlus = faFileCirclePlus; public privateShows$ = this.showService.list$().pipe(map(show => show.filter(_ => !_.published).sort((a, b) => b.date.toMillis() - a.date.toMillis()))); + private dateFormatter = new Intl.DateTimeFormat('de-DE', {day: '2-digit', month: '2-digit', year: 'numeric'}); + private showTypePipe = new ShowTypePipe(); public constructor() { const userService = this.userService; @@ -98,6 +104,33 @@ export class SongComponent implements OnInit { }), distinctUntilChanged() ); + + this.songUsageShows$ = combineLatest([this.userService.user$, this.showService.list$(), song$]).pipe( + map(([user, shows, song]) => { + if (!user || !song) { + return []; + } + + return shows + .filter(show => show.owner === user.id) + .filter(show => (show.songIds ?? []).includes(song.id)) + .sort((a, b) => b.date.toMillis() - a.date.toMillis()); + }) + ); + + this.songUsageTooltip$ = combineLatest([this.songCount$, this.songUsageShows$]).pipe( + map(([count, shows]) => { + if (count === 0) { + return 'Noch in keiner Show verwendet.'; + } + + if (shows.length === 0) { + return 'Verwendungen vorhanden, aber Show-Zuordnung noch nicht indexiert.'; + } + + return shows.map(show => `${this.dateFormatter.format(show.date.toDate())} - ${this.showTypePipe.transform(show.showType)}`).join('\n'); + }) + ); } public getFlags = (flags: string): string[] => { diff --git a/src/app/services/db.service.ts b/src/app/services/db.service.ts index e6e9cb9..5dfbe10 100644 --- a/src/app/services/db.service.ts +++ b/src/app/services/db.service.ts @@ -30,7 +30,7 @@ export class DbCollection { ) {} public add(data: Partial): Promise> { - return addDoc(this.ref as CollectionReference, data as WithFieldValue); + return runInInjectionContext(this.environmentInjector, () => addDoc(this.ref as CollectionReference, data as WithFieldValue)); } public valueChanges(options?: {idField?: string}): Observable { @@ -38,7 +38,7 @@ export class DbCollection { } private get ref(): CollectionReference { - return collection(this.fs, this.path); + return runInInjectionContext(this.environmentInjector, () => collection(this.fs, this.path)); } } @@ -50,15 +50,15 @@ export class DbDocument { ) {} public set(data: Partial): Promise { - return setDoc(this.ref as DocumentReference, data as WithFieldValue); + return runInInjectionContext(this.environmentInjector, () => setDoc(this.ref as DocumentReference, data as WithFieldValue)); } public update(data: Partial): Promise { - return updateDoc(this.ref, data as Partial); + return runInInjectionContext(this.environmentInjector, () => updateDoc(this.ref, data as Partial)); } public delete(): Promise { - return deleteDoc(this.ref); + return runInInjectionContext(this.environmentInjector, () => deleteDoc(this.ref)); } public collection(subPath: string): DbCollection { @@ -73,7 +73,7 @@ export class DbDocument { } private get ref(): DocumentReference { - return doc(this.fs, this.path); + return runInInjectionContext(this.environmentInjector, () => doc(this.fs, this.path)); } } @@ -103,7 +103,9 @@ export class DbService { return this.col(ref).valueChanges({idField: 'id'}); } - const q = query(collection(this.fs, ref), ...queryConstraints); - return runInInjectionContext(this.environmentInjector, () => collectionData(q, {idField: 'id'}) as Observable); + return runInInjectionContext(this.environmentInjector, () => { + const q = query(collection(this.fs, ref), ...queryConstraints); + return collectionData(q, {idField: 'id'}) as Observable; + }); } } diff --git a/src/app/services/user/user-song-usage.service.spec.ts b/src/app/services/user/user-song-usage.service.spec.ts index f962c32..b351603 100644 --- a/src/app/services/user/user-song-usage.service.spec.ts +++ b/src/app/services/user/user-song-usage.service.spec.ts @@ -25,8 +25,8 @@ describe('UserSongUsageService', () => { sessionSpy.update$.and.resolveTo(); showDataServiceSpy.listRaw$.and.returnValue( of([ - {id: 'show-1', owner: 'user-1'}, - {id: 'show-2', owner: 'user-2'}, + {id: 'show-1', owner: 'user-1', archived: false}, + {id: 'show-2', owner: 'user-2', archived: true}, ] as never) ); showSongDataServiceSpy.list$.and.callFake((showId: string) => @@ -66,11 +66,11 @@ describe('UserSongUsageService', () => { await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({ usersProcessed: 2, showsProcessed: 2, - showSongsProcessed: 4, + showSongsProcessed: 3, }); expect(sessionSpy.update$).toHaveBeenCalledWith('user-1', {songUsage: {'song-1': 2, 'song-2': 1}}); - expect(sessionSpy.update$).toHaveBeenCalledWith('user-2', {songUsage: {'song-3': 1}}); + expect(sessionSpy.update$).toHaveBeenCalledWith('user-2', {songUsage: {}}); }); it('should reject song usage rebuilds for non-admin users', async () => { diff --git a/src/app/services/user/user-song-usage.service.ts b/src/app/services/user/user-song-usage.service.ts index e869665..3383bf3 100644 --- a/src/app/services/user/user-song-usage.service.ts +++ b/src/app/services/user/user-song-usage.service.ts @@ -40,6 +40,10 @@ export class UserSongUsageService { let showSongsProcessed = 0; for (const show of shows) { + if (show.archived) { + continue; + } + const ownerId = show.owner; if (!ownerId) { continue; diff --git a/src/app/services/user/user.service.spec.ts b/src/app/services/user/user.service.spec.ts index 6f33dc4..34acc8e 100644 --- a/src/app/services/user/user.service.spec.ts +++ b/src/app/services/user/user.service.spec.ts @@ -3,11 +3,13 @@ import {of} from 'rxjs'; import {UserService} from './user.service'; import {UserSessionService} from './user-session.service'; import {UserSongUsageService} from './user-song-usage.service'; +import {ShowSongIndexService} from '../../modules/shows/services/show-song-index.service'; describe('UserService', () => { let service: UserService; let sessionSpy: jasmine.SpyObj; let songUsageSpy: jasmine.SpyObj; + let showSongIndexSpy: jasmine.SpyObj; beforeEach(async () => { sessionSpy = jasmine.createSpyObj( @@ -20,6 +22,7 @@ describe('UserService', () => { } ); songUsageSpy = jasmine.createSpyObj('UserSongUsageService', ['incSongCount', 'decSongCount', 'rebuildSongUsage']); + showSongIndexSpy = jasmine.createSpyObj('ShowSongIndexService', ['rebuildShowSongIds']); sessionSpy.currentUser.and.resolveTo({id: 'user-1'} as never); sessionSpy.getUserbyId.and.resolveTo({id: 'user-2'} as never); @@ -34,11 +37,13 @@ describe('UserService', () => { songUsageSpy.incSongCount.and.resolveTo(); songUsageSpy.decSongCount.and.resolveTo(); songUsageSpy.rebuildSongUsage.and.resolveTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3}); + showSongIndexSpy.rebuildShowSongIds.and.resolveTo({showsProcessed: 2, showSongsProcessed: 3}); await TestBed.configureTestingModule({ providers: [ {provide: UserSessionService, useValue: sessionSpy}, {provide: UserSongUsageService, useValue: songUsageSpy}, + {provide: ShowSongIndexService, useValue: showSongIndexSpy}, ], }); @@ -100,9 +105,11 @@ describe('UserService', () => { await service.incSongCount('song-1'); await service.decSongCount('song-2'); await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3}); + await expectAsync(service.rebuildShowSongIds()).toBeResolvedTo({showsProcessed: 2, showSongsProcessed: 3}); expect(songUsageSpy.incSongCount).toHaveBeenCalledWith('song-1'); expect(songUsageSpy.decSongCount).toHaveBeenCalledWith('song-2'); expect(songUsageSpy.rebuildSongUsage).toHaveBeenCalled(); + expect(showSongIndexSpy.rebuildShowSongIds).toHaveBeenCalled(); }); }); diff --git a/src/app/services/user/user.service.ts b/src/app/services/user/user.service.ts index 3c31ea2..85cbfc7 100644 --- a/src/app/services/user/user.service.ts +++ b/src/app/services/user/user.service.ts @@ -3,6 +3,7 @@ import {Observable} from 'rxjs'; import {User} from './user'; import {SongUsageMigrationResult, UserSongUsageService} from './user-song-usage.service'; import {UserSessionService} from './user-session.service'; +import {MigrationProgress, ShowSongIndexMigrationResult, ShowSongIndexService} from '../../modules/shows/services/show-song-index.service'; @Injectable({ providedIn: 'root', @@ -10,6 +11,7 @@ import {UserSessionService} from './user-session.service'; export class UserService { private session = inject(UserSessionService); private songUsage = inject(UserSongUsageService); + private showSongIndex = inject(ShowSongIndexService); public users$ = this.session.users$; @@ -34,4 +36,6 @@ export class UserService { public incSongCount = (songId: string): Promise => this.songUsage.incSongCount(songId); public decSongCount = (songId: string): Promise => this.songUsage.decSongCount(songId); public rebuildSongUsage = (): Promise => this.songUsage.rebuildSongUsage(); + public rebuildShowSongIds = (onProgress?: (progress: MigrationProgress) => void): Promise => + this.showSongIndex.rebuildShowSongIds(onProgress); } diff --git a/src/app/widget-modules/components/add-song/add-song.component.html b/src/app/widget-modules/components/add-song/add-song.component.html index e4456a9..a381ad7 100644 --- a/src/app/widget-modules/components/add-song/add-song.component.html +++ b/src/app/widget-modules/components/add-song/add-song.component.html @@ -6,7 +6,7 @@ - @for (song of filteredSongs(); track song) { + @for (song of filteredSongs(); track song.id) { {{ song.title }} } diff --git a/src/main.ts b/src/main.ts index 8cf233a..08f4231 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ declare global { interface Window { wgeneratorAdmin?: { rebuildSongUsage(): Promise; + rebuildShowSongIds(): Promise; }; } } @@ -49,6 +50,20 @@ bootstrapApplication(AppComponent, { const userService = appRef.injector.get(UserService); window.wgeneratorAdmin = { rebuildSongUsage: () => userService.rebuildSongUsage(), + rebuildShowSongIds: async () => { + console.info('[wgeneratorAdmin] rebuildShowSongIds started'); + const result = await userService.rebuildShowSongIds(progress => { + console.info( + `[wgeneratorAdmin] rebuildShowSongIds ${progress.processed}/${progress.total} shows processed`, + { + showId: progress.showId, + showSongsProcessed: progress.showSongsProcessed, + } + ); + }); + console.info('[wgeneratorAdmin] rebuildShowSongIds finished', result); + return result; + }, }; }) .catch(err => console.error(err)); diff --git a/src/types/qrcode.d.ts b/src/types/qrcode.d.ts new file mode 100644 index 0000000..8584df2 --- /dev/null +++ b/src/types/qrcode.d.ts @@ -0,0 +1,20 @@ +declare module 'qrcode' { + export interface QRCodeToDataURLOptions { + type?: string; + quality?: number; + width?: number; + height?: number; + color?: { + dark?: string; + light?: string; + }; + } + + export function toDataURL(text: string, options?: QRCodeToDataURLOptions): Promise; + + const QRCode: { + toDataURL: typeof toDataURL; + }; + + export default QRCode; +}