From d81fb3743be4f4fbe16ee7c3af7a01a13d0d74ac Mon Sep 17 00:00:00 2001 From: benjamin Date: Mon, 9 Mar 2026 17:41:24 +0100 Subject: [PATCH] optimize firebase reads --- README.md | 20 ++++- .../modules/guest/guest-show-data.service.ts | 4 +- .../shows/services/show-data.service.ts | 4 +- .../songs/services/song-data.service.ts | 7 +- .../modules/songs/song/song.component.html | 3 +- src/app/modules/songs/song/song.component.ts | 20 +++-- src/app/services/config.service.ts | 10 ++- src/app/services/global-settings.service.ts | 14 +-- src/app/services/user/user.service.ts | 85 ++++++++++++++----- src/main.ts | 18 +++- 10 files changed, 136 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 4c5ae40..fc47f11 100644 --- a/README.md +++ b/README.md @@ -1 +1,19 @@ -# wgenerator \ No newline at end of file +# wgenerator + +## Admin migration + +If `songUsage` needs to be rebuilt from all existing shows, log in with a user that has the `admin` role and run this in the browser console: + +```js +await window.wgeneratorAdmin.rebuildSongUsage() +``` + +The migration: + +- resets `songUsage` for all users +- scans all shows and all `shows/{id}/songs` entries +- rebuilds the per-user counters based on show ownership + +It returns a summary object with processed user, 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/guest/guest-show-data.service.ts b/src/app/modules/guest/guest-show-data.service.ts index a053081..7777f52 100644 --- a/src/app/modules/guest/guest-show-data.service.ts +++ b/src/app/modules/guest/guest-show-data.service.ts @@ -1,6 +1,6 @@ import {Injectable} from '@angular/core'; import {Observable} from 'rxjs'; -import {map, shareReplay} from 'rxjs/operators'; +import {shareReplay} from 'rxjs/operators'; import {DbService} from 'src/app/services/db.service'; import {GuestShow} from './guest-show'; @@ -18,7 +18,7 @@ export class GuestShowDataService { public constructor(private dbService: DbService) {} - public read$: (id: string) => Observable = (id: string): Observable => this.list$.pipe(map(_ => _.find(s => s.id === id) || null)); + public read$: (id: string) => Observable = (id: string): Observable => this.dbService.doc$(`${this.collection}/${id}`); public update$: (id: string, data: Partial) => Promise = async (id: string, data: Partial): Promise => await this.dbService.doc(this.collection + '/' + id).update(data); public add: (data: Partial) => Promise = async (data: Partial): Promise => (await this.dbService.col(this.collection).add(data)).id; diff --git a/src/app/modules/shows/services/show-data.service.ts b/src/app/modules/shows/services/show-data.service.ts index b2c53b9..e3b5f21 100644 --- a/src/app/modules/shows/services/show-data.service.ts +++ b/src/app/modules/shows/services/show-data.service.ts @@ -22,10 +22,8 @@ export class ShowDataService { public listRaw$ = () => this.dbService.col$(this.collection); - public read$ = (showId: string): Observable => this.list$.pipe(map(_ => _.find(s => s.id === showId) || null)); + public read$ = (showId: string): Observable => this.dbService.doc$(`${this.collection}/${showId}`); - // public list$ = (): Observable => this.dbService.col$(this.collection); - // public read$ = (showId: string): Observable => this.dbService.doc$(`${this.collection}/${showId}`); public update = async (showId: string, data: Partial): Promise => await this.dbService.doc(`${this.collection}/${showId}`).update(data); public add = async (data: Partial): Promise => (await this.dbService.col(this.collection).add(data)).id; } diff --git a/src/app/modules/songs/services/song-data.service.ts b/src/app/modules/songs/services/song-data.service.ts index e3730cc..15ecd07 100644 --- a/src/app/modules/songs/services/song-data.service.ts +++ b/src/app/modules/songs/services/song-data.service.ts @@ -2,7 +2,7 @@ import {Injectable} from '@angular/core'; import {Song} from './song'; import {Observable} from 'rxjs'; import {DbService} from '../../../services/db.service'; -import {map, shareReplay, startWith} from 'rxjs/operators'; +import {shareReplay, startWith} from 'rxjs/operators'; @Injectable({ providedIn: 'root', @@ -22,10 +22,7 @@ export class SongDataService { // this.list$.subscribe(); } - // public list$ = (): Observable => this.dbService.col$(this.collection); - - //public read$ = (songId: string): Observable => this.dbService.doc$(this.collection + '/' + songId); - public read$ = (songId: string): Observable => this.list$.pipe(map(_ => _.find(s => s.id === songId) || null)); + public read$ = (songId: string): Observable => this.dbService.doc$(this.collection + '/' + songId); public update$ = async (songId: string, data: Partial): Promise => await this.dbService.doc(this.collection + '/' + songId).update(data); public add = async (data: Partial): Promise => (await this.dbService.col(this.collection).add(data)).id; public delete = async (songId: string): Promise => await this.dbService.doc(this.collection + '/' + songId).delete(); diff --git a/src/app/modules/songs/song/song.component.html b/src/app/modules/songs/song/song.component.html index cb9b89e..e6d1126 100644 --- a/src/app/modules/songs/song/song.component.html +++ b/src/app/modules/songs/song/song.component.html @@ -28,8 +28,7 @@
Künstler: {{ song.artist }}
Verlag: {{ song.label }}
Quelle: {{ song.origin }}
-
Quelle: {{ song.origin }}
-
Wie oft verwendet: {{ count }}
+
Wie oft verwendet: {{ songCount$ | async }}
diff --git a/src/app/modules/songs/song/song.component.ts b/src/app/modules/songs/song/song.component.ts index eccddcb..558fecb 100644 --- a/src/app/modules/songs/song/song.component.ts +++ b/src/app/modules/songs/song/song.component.ts @@ -56,6 +56,7 @@ export class SongComponent implements OnInit { public song$: Observable | null = null; public files$: Observable | null = null; public user$: Observable | null = null; + public songCount$: Observable | null = null; public faEdit = faEdit; public faDelete = faTrash; public faFileCirclePlus = faFileCirclePlus; @@ -85,6 +86,17 @@ export class SongComponent implements OnInit { map(param => param.songId), switchMap(songId => this.fileService.read$(songId)) ); + + this.songCount$ = combineLatest([this.user$, this.song$]).pipe( + map(([user, song]) => { + if (!song) { + return 0; + } + + return user?.songUsage?.[song.id] ?? 0; + }), + distinctUntilChanged() + ); } public getFlags = (flags: string): string[] => { @@ -105,12 +117,4 @@ export class SongComponent implements OnInit { await this.showService.update$(show?.id, {order: [...show.order, newId ?? '']}); await this.router.navigateByUrl('/shows/' + show.id); } - - public songCount$ = () => - combineLatest([this.user$, this.song$]).pipe( - map(([user, song]) => { - return user.songUsage[song.id]; - }), - distinctUntilChanged() - ); } diff --git a/src/app/services/config.service.ts b/src/app/services/config.service.ts index a6896f6..2ea68ac 100644 --- a/src/app/services/config.service.ts +++ b/src/app/services/config.service.ts @@ -2,13 +2,21 @@ import {Injectable} from '@angular/core'; import {DbService} from './db.service'; import {firstValueFrom, Observable} from 'rxjs'; import {Config} from './config'; +import {shareReplay} from 'rxjs/operators'; @Injectable({ providedIn: 'root', }) export class ConfigService { + private readonly config$ = this.db.doc$('global/config').pipe( + shareReplay({ + bufferSize: 1, + refCount: true, + }) + ); + public constructor(private db: DbService) {} - public get$ = (): Observable => this.db.doc$('global/config'); + public get$ = (): Observable => this.config$; public get = (): Promise => firstValueFrom(this.get$()); } diff --git a/src/app/services/global-settings.service.ts b/src/app/services/global-settings.service.ts index 420df8a..36c507f 100644 --- a/src/app/services/global-settings.service.ts +++ b/src/app/services/global-settings.service.ts @@ -8,15 +8,17 @@ import {shareReplay} from 'rxjs/operators'; providedIn: 'root', }) export class GlobalSettingsService { + private readonly settings$ = this.db.doc$('global/static').pipe( + shareReplay({ + bufferSize: 1, + refCount: true, + }) + ); + public constructor(private db: DbService) {} public get get$(): Observable { - return this.db.doc$('global/static').pipe( - shareReplay({ - bufferSize: 1, - refCount: true, - }) - ); + return this.settings$; } public async set(data: Partial): Promise { diff --git a/src/app/services/user/user.service.ts b/src/app/services/user/user.service.ts index 1647437..824d959 100644 --- a/src/app/services/user/user.service.ts +++ b/src/app/services/user/user.service.ts @@ -1,13 +1,20 @@ import {Injectable} from '@angular/core'; import {AngularFireAuth} from '@angular/fire/compat/auth'; import {BehaviorSubject, firstValueFrom, Observable} from 'rxjs'; -import {filter, map, shareReplay, switchMap, tap} from 'rxjs/operators'; +import {filter, map, shareReplay, switchMap, take, tap} from 'rxjs/operators'; import {User} from './user'; import {DbService} from '../db.service'; import {environment} from '../../../environments/environment'; import {Router} from '@angular/router'; import {ShowDataService} from '../../modules/shows/services/show-data.service'; import {ShowSongDataService} from '../../modules/shows/services/show-song-data.service'; +import firebase from 'firebase/compat/app'; + +export interface SongUsageMigrationResult { + usersProcessed: number; + showsProcessed: number; + showSongsProcessed: number; +} @Injectable({ providedIn: 'root', @@ -51,6 +58,7 @@ export class UserService { const aUser = await this.afAuth.signInWithEmailAndPassword(user, password); if (!aUser.user) return null; const dUser = await this.readUser(aUser.user.uid); + if (!dUser) return null; await this.initSongUsage(dUser); this.iUser$.next(dUser); this.iUserId$.next(aUser.user.uid); @@ -81,8 +89,9 @@ export class UserService { const aUser = await this.afAuth.createUserWithEmailAndPassword(user, password); if (!aUser.user) return; const userId = aUser.user.uid; - await this.db.doc('users/' + userId).set({name, chordMode: 'onlyFirst'}); + await this.db.doc('users/' + userId).set({name, chordMode: 'onlyFirst', songUsage: {}}); const dUser = await this.readUser(aUser.user.uid); + if (!dUser) return; this.iUser$.next(dUser); await this.router.navigateByUrl('/brand/new-user'); } @@ -90,35 +99,71 @@ export class UserService { public incSongCount = (songId: string) => this.updateSongUsage(songId, 1); public decSongCount = (songId: string) => this.updateSongUsage(songId, -1); + public async rebuildSongUsage(): Promise { + const currentUser = await firstValueFrom(this.iUser$.pipe(take(1))); + if (!currentUser || !this.hasAdminRole(currentUser.role)) { + throw new Error('Admin role required to rebuild songUsage.'); + } + + const [users, shows] = await Promise.all([firstValueFrom(this.users$), firstValueFrom(this.showDataService.listRaw$())]); + const songUsageByUserId: Record> = {}; + + users.forEach(user => { + songUsageByUserId[user.id] = {}; + }); + + let showSongsProcessed = 0; + for (const show of shows) { + const ownerId = show.owner; + if (!ownerId) { + continue; + } + + const showSongs = await firstValueFrom(this.showSongDataService.list$(show.id)); + const usage = songUsageByUserId[ownerId] ?? {}; + songUsageByUserId[ownerId] = usage; + + for (const showSong of showSongs) { + showSongsProcessed += 1; + usage[showSong.songId] = (usage[showSong.songId] ?? 0) + 1; + } + } + + await Promise.all( + users.map(user => + this.update$(user.id, { + songUsage: songUsageByUserId[user.id] ?? {}, + }) + ) + ); + + return { + usersProcessed: users.length, + showsProcessed: shows.length, + showSongsProcessed, + }; + } + private async updateSongUsage(songId: string, direction: number) { const user = await firstValueFrom(this.user$); if (!user) return null; - const songUsage = user?.songUsage ?? {}; - let currentSongCount = songUsage[songId]; - if (currentSongCount === null || currentSongCount === undefined) currentSongCount = 0; - else currentSongCount = currentSongCount + direction; - songUsage[songId] = Math.max(0, currentSongCount); - - await this.update$(user.id, {songUsage}); + await this.db.doc('users/' + user.id).update({ + [`songUsage.${songId}`]: firebase.firestore.FieldValue.increment(direction), + }); } private async initSongUsage(user: User) { if (user.songUsage) return; + await this.update$(user.id, {songUsage: {}}); + } - const shows = await firstValueFrom(this.showDataService.listRaw$()); - const myShows = shows.filter(show => show.owner === user.id); - const songUsage: {[songId: string]: number} = {}; - for (const show of myShows) { - const showSongs = await firstValueFrom(this.showSongDataService.list$(show.id)); - for (const showSong of showSongs) { - const current = songUsage[showSong.songId] ?? 0; - songUsage[showSong.songId] = current + 1; - } + private hasAdminRole(role: string | null | undefined): boolean { + if (!role) { + return false; } - await this.update$(user.id, {songUsage}); - return; + return role.split(';').includes('admin'); } private readUser$ = (uid: string) => this.db.doc$('users/' + uid); diff --git a/src/main.ts b/src/main.ts index dca5ca6..15c0ae3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,15 @@ import {FontAwesomeModule} from '@fortawesome/angular-fontawesome'; import {AppComponent} from './app/app.component'; import {provideFirebaseApp, initializeApp} from '@angular/fire/app'; import {provideFirestore, getFirestore} from '@angular/fire/firestore'; +import {UserService} from './app/services/user/user.service'; + +declare global { + interface Window { + wgeneratorAdmin?: { + rebuildSongUsage(): Promise; + }; + } +} if (environment.production) { enableProdMode(); @@ -42,4 +51,11 @@ bootstrapApplication(AppComponent, { {provide: MAT_DATE_LOCALE, useValue: 'de-DE'}, provideAnimations(), ], -}).catch(err => console.error(err)); +}) + .then(appRef => { + const userService = appRef.injector.get(UserService); + window.wgeneratorAdmin = { + rebuildSongUsage: () => userService.rebuildSongUsage(), + }; + }) + .catch(err => console.error(err));