optimize firebase reads
This commit is contained in:
18
README.md
18
README.md
@@ -1 +1,19 @@
|
|||||||
# wgenerator
|
# 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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Injectable} from '@angular/core';
|
import {Injectable} from '@angular/core';
|
||||||
import {Observable} from 'rxjs';
|
import {Observable} from 'rxjs';
|
||||||
import {map, shareReplay} from 'rxjs/operators';
|
import {shareReplay} from 'rxjs/operators';
|
||||||
import {DbService} from 'src/app/services/db.service';
|
import {DbService} from 'src/app/services/db.service';
|
||||||
import {GuestShow} from './guest-show';
|
import {GuestShow} from './guest-show';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export class GuestShowDataService {
|
|||||||
|
|
||||||
public constructor(private dbService: DbService) {}
|
public constructor(private dbService: DbService) {}
|
||||||
|
|
||||||
public read$: (id: string) => Observable<GuestShow | null> = (id: string): Observable<GuestShow | null> => this.list$.pipe(map(_ => _.find(s => s.id === id) || null));
|
public read$: (id: string) => Observable<GuestShow | null> = (id: string): Observable<GuestShow | null> => this.dbService.doc$(`${this.collection}/${id}`);
|
||||||
public update$: (id: string, data: Partial<GuestShow>) => Promise<void> = async (id: string, data: Partial<GuestShow>): Promise<void> =>
|
public update$: (id: string, data: Partial<GuestShow>) => Promise<void> = async (id: string, data: Partial<GuestShow>): Promise<void> =>
|
||||||
await this.dbService.doc(this.collection + '/' + id).update(data);
|
await this.dbService.doc(this.collection + '/' + id).update(data);
|
||||||
public add: (data: Partial<GuestShow>) => Promise<string> = async (data: Partial<GuestShow>): Promise<string> => (await this.dbService.col(this.collection).add(data)).id;
|
public add: (data: Partial<GuestShow>) => Promise<string> = async (data: Partial<GuestShow>): Promise<string> => (await this.dbService.col(this.collection).add(data)).id;
|
||||||
|
|||||||
@@ -22,10 +22,8 @@ export class ShowDataService {
|
|||||||
|
|
||||||
public listRaw$ = () => this.dbService.col$<Show>(this.collection);
|
public listRaw$ = () => this.dbService.col$<Show>(this.collection);
|
||||||
|
|
||||||
public read$ = (showId: string): Observable<Show | null> => this.list$.pipe(map(_ => _.find(s => s.id === showId) || null));
|
public read$ = (showId: string): Observable<Show | null> => this.dbService.doc$(`${this.collection}/${showId}`);
|
||||||
|
|
||||||
// public list$ = (): Observable<Show[]> => this.dbService.col$(this.collection);
|
|
||||||
// public read$ = (showId: string): Observable<Show | null> => this.dbService.doc$(`${this.collection}/${showId}`);
|
|
||||||
public update = async (showId: string, data: Partial<Show>): Promise<void> => await this.dbService.doc(`${this.collection}/${showId}`).update(data);
|
public update = async (showId: string, data: Partial<Show>): Promise<void> => await this.dbService.doc(`${this.collection}/${showId}`).update(data);
|
||||||
public add = async (data: Partial<Show>): Promise<string> => (await this.dbService.col(this.collection).add(data)).id;
|
public add = async (data: Partial<Show>): Promise<string> => (await this.dbService.col(this.collection).add(data)).id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {Injectable} from '@angular/core';
|
|||||||
import {Song} from './song';
|
import {Song} from './song';
|
||||||
import {Observable} from 'rxjs';
|
import {Observable} from 'rxjs';
|
||||||
import {DbService} from '../../../services/db.service';
|
import {DbService} from '../../../services/db.service';
|
||||||
import {map, shareReplay, startWith} from 'rxjs/operators';
|
import {shareReplay, startWith} from 'rxjs/operators';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -22,10 +22,7 @@ export class SongDataService {
|
|||||||
// this.list$.subscribe();
|
// this.list$.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
// public list$ = (): Observable<Song[]> => this.dbService.col$(this.collection);
|
public read$ = (songId: string): Observable<Song | null> => this.dbService.doc$(this.collection + '/' + songId);
|
||||||
|
|
||||||
//public read$ = (songId: string): Observable<Song | null> => this.dbService.doc$(this.collection + '/' + songId);
|
|
||||||
public read$ = (songId: string): Observable<Song | null> => this.list$.pipe(map(_ => _.find(s => s.id === songId) || null));
|
|
||||||
public update$ = async (songId: string, data: Partial<Song>): Promise<void> => await this.dbService.doc(this.collection + '/' + songId).update(data);
|
public update$ = async (songId: string, data: Partial<Song>): Promise<void> => await this.dbService.doc(this.collection + '/' + songId).update(data);
|
||||||
public add = async (data: Partial<Song>): Promise<string> => (await this.dbService.col(this.collection).add(data)).id;
|
public add = async (data: Partial<Song>): Promise<string> => (await this.dbService.col(this.collection).add(data)).id;
|
||||||
public delete = async (songId: string): Promise<void> => await this.dbService.doc(this.collection + '/' + songId).delete();
|
public delete = async (songId: string): Promise<void> => await this.dbService.doc(this.collection + '/' + songId).delete();
|
||||||
|
|||||||
@@ -28,8 +28,7 @@
|
|||||||
<div *ngIf="song.artist">Künstler: {{ song.artist }}</div>
|
<div *ngIf="song.artist">Künstler: {{ song.artist }}</div>
|
||||||
<div *ngIf="song.label">Verlag: {{ song.label }}</div>
|
<div *ngIf="song.label">Verlag: {{ song.label }}</div>
|
||||||
<div *ngIf="song.origin">Quelle: {{ song.origin }}</div>
|
<div *ngIf="song.origin">Quelle: {{ song.origin }}</div>
|
||||||
<div *ngIf="song.origin">Quelle: {{ song.origin }}</div>
|
<div>Wie oft verwendet: {{ songCount$ | async }}</div>
|
||||||
<div *ngIf="songCount$()|async as count">Wie oft verwendet: {{ count }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export class SongComponent implements OnInit {
|
|||||||
public song$: Observable<Song | null> | null = null;
|
public song$: Observable<Song | null> | null = null;
|
||||||
public files$: Observable<File[] | null> | null = null;
|
public files$: Observable<File[] | null> | null = null;
|
||||||
public user$: Observable<User | null> | null = null;
|
public user$: Observable<User | null> | null = null;
|
||||||
|
public songCount$: Observable<number> | null = null;
|
||||||
public faEdit = faEdit;
|
public faEdit = faEdit;
|
||||||
public faDelete = faTrash;
|
public faDelete = faTrash;
|
||||||
public faFileCirclePlus = faFileCirclePlus;
|
public faFileCirclePlus = faFileCirclePlus;
|
||||||
@@ -85,6 +86,17 @@ export class SongComponent implements OnInit {
|
|||||||
map(param => param.songId),
|
map(param => param.songId),
|
||||||
switchMap(songId => this.fileService.read$(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[] => {
|
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.showService.update$(show?.id, {order: [...show.order, newId ?? '']});
|
||||||
await this.router.navigateByUrl('/shows/' + show.id);
|
await this.router.navigateByUrl('/shows/' + show.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public songCount$ = () =>
|
|
||||||
combineLatest([this.user$, this.song$]).pipe(
|
|
||||||
map(([user, song]) => {
|
|
||||||
return user.songUsage[song.id];
|
|
||||||
}),
|
|
||||||
distinctUntilChanged()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,21 @@ import {Injectable} from '@angular/core';
|
|||||||
import {DbService} from './db.service';
|
import {DbService} from './db.service';
|
||||||
import {firstValueFrom, Observable} from 'rxjs';
|
import {firstValueFrom, Observable} from 'rxjs';
|
||||||
import {Config} from './config';
|
import {Config} from './config';
|
||||||
|
import {shareReplay} from 'rxjs/operators';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
|
private readonly config$ = this.db.doc$<Config>('global/config').pipe(
|
||||||
|
shareReplay({
|
||||||
|
bufferSize: 1,
|
||||||
|
refCount: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
public constructor(private db: DbService) {}
|
public constructor(private db: DbService) {}
|
||||||
|
|
||||||
public get$ = (): Observable<Config | null> => this.db.doc$<Config>('global/config');
|
public get$ = (): Observable<Config | null> => this.config$;
|
||||||
public get = (): Promise<Config | null> => firstValueFrom(this.get$());
|
public get = (): Promise<Config | null> => firstValueFrom(this.get$());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ import {shareReplay} from 'rxjs/operators';
|
|||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class GlobalSettingsService {
|
export class GlobalSettingsService {
|
||||||
public constructor(private db: DbService) {}
|
private readonly settings$ = this.db.doc$<GlobalSettings>('global/static').pipe(
|
||||||
|
|
||||||
public get get$(): Observable<GlobalSettings | null> {
|
|
||||||
return this.db.doc$<GlobalSettings>('global/static').pipe(
|
|
||||||
shareReplay({
|
shareReplay({
|
||||||
bufferSize: 1,
|
bufferSize: 1,
|
||||||
refCount: true,
|
refCount: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public constructor(private db: DbService) {}
|
||||||
|
|
||||||
|
public get get$(): Observable<GlobalSettings | null> {
|
||||||
|
return this.settings$;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async set(data: Partial<GlobalSettings>): Promise<void> {
|
public async set(data: Partial<GlobalSettings>): Promise<void> {
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import {Injectable} from '@angular/core';
|
import {Injectable} from '@angular/core';
|
||||||
import {AngularFireAuth} from '@angular/fire/compat/auth';
|
import {AngularFireAuth} from '@angular/fire/compat/auth';
|
||||||
import {BehaviorSubject, firstValueFrom, Observable} from 'rxjs';
|
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 {User} from './user';
|
||||||
import {DbService} from '../db.service';
|
import {DbService} from '../db.service';
|
||||||
import {environment} from '../../../environments/environment';
|
import {environment} from '../../../environments/environment';
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {ShowDataService} from '../../modules/shows/services/show-data.service';
|
import {ShowDataService} from '../../modules/shows/services/show-data.service';
|
||||||
import {ShowSongDataService} from '../../modules/shows/services/show-song-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({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -51,6 +58,7 @@ export class UserService {
|
|||||||
const aUser = await this.afAuth.signInWithEmailAndPassword(user, password);
|
const aUser = await this.afAuth.signInWithEmailAndPassword(user, password);
|
||||||
if (!aUser.user) return null;
|
if (!aUser.user) return null;
|
||||||
const dUser = await this.readUser(aUser.user.uid);
|
const dUser = await this.readUser(aUser.user.uid);
|
||||||
|
if (!dUser) return null;
|
||||||
await this.initSongUsage(dUser);
|
await this.initSongUsage(dUser);
|
||||||
this.iUser$.next(dUser);
|
this.iUser$.next(dUser);
|
||||||
this.iUserId$.next(aUser.user.uid);
|
this.iUserId$.next(aUser.user.uid);
|
||||||
@@ -81,8 +89,9 @@ export class UserService {
|
|||||||
const aUser = await this.afAuth.createUserWithEmailAndPassword(user, password);
|
const aUser = await this.afAuth.createUserWithEmailAndPassword(user, password);
|
||||||
if (!aUser.user) return;
|
if (!aUser.user) return;
|
||||||
const userId = aUser.user.uid;
|
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);
|
const dUser = await this.readUser(aUser.user.uid);
|
||||||
|
if (!dUser) return;
|
||||||
this.iUser$.next(dUser);
|
this.iUser$.next(dUser);
|
||||||
await this.router.navigateByUrl('/brand/new-user');
|
await this.router.navigateByUrl('/brand/new-user');
|
||||||
}
|
}
|
||||||
@@ -90,35 +99,71 @@ export class UserService {
|
|||||||
public incSongCount = (songId: string) => this.updateSongUsage(songId, 1);
|
public incSongCount = (songId: string) => this.updateSongUsage(songId, 1);
|
||||||
public decSongCount = (songId: string) => this.updateSongUsage(songId, -1);
|
public decSongCount = (songId: string) => this.updateSongUsage(songId, -1);
|
||||||
|
|
||||||
|
public async rebuildSongUsage(): Promise<SongUsageMigrationResult> {
|
||||||
|
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<string, Record<string, number>> = {};
|
||||||
|
|
||||||
|
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) {
|
private async updateSongUsage(songId: string, direction: number) {
|
||||||
const user = await firstValueFrom(this.user$);
|
const user = await firstValueFrom(this.user$);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const songUsage = user?.songUsage ?? {};
|
await this.db.doc<User>('users/' + user.id).update({
|
||||||
let currentSongCount = songUsage[songId];
|
[`songUsage.${songId}`]: firebase.firestore.FieldValue.increment(direction),
|
||||||
if (currentSongCount === null || currentSongCount === undefined) currentSongCount = 0;
|
});
|
||||||
else currentSongCount = currentSongCount + direction;
|
|
||||||
songUsage[songId] = Math.max(0, currentSongCount);
|
|
||||||
|
|
||||||
await this.update$(user.id, {songUsage});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initSongUsage(user: User) {
|
private async initSongUsage(user: User) {
|
||||||
if (user.songUsage) return;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.update$(user.id, {songUsage});
|
private hasAdminRole(role: string | null | undefined): boolean {
|
||||||
return;
|
if (!role) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return role.split(';').includes('admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
private readUser$ = (uid: string) => this.db.doc$<User>('users/' + uid);
|
private readUser$ = (uid: string) => this.db.doc$<User>('users/' + uid);
|
||||||
|
|||||||
18
src/main.ts
18
src/main.ts
@@ -16,6 +16,15 @@ import {FontAwesomeModule} from '@fortawesome/angular-fontawesome';
|
|||||||
import {AppComponent} from './app/app.component';
|
import {AppComponent} from './app/app.component';
|
||||||
import {provideFirebaseApp, initializeApp} from '@angular/fire/app';
|
import {provideFirebaseApp, initializeApp} from '@angular/fire/app';
|
||||||
import {provideFirestore, getFirestore} from '@angular/fire/firestore';
|
import {provideFirestore, getFirestore} from '@angular/fire/firestore';
|
||||||
|
import {UserService} from './app/services/user/user.service';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
wgeneratorAdmin?: {
|
||||||
|
rebuildSongUsage(): Promise<unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (environment.production) {
|
if (environment.production) {
|
||||||
enableProdMode();
|
enableProdMode();
|
||||||
@@ -42,4 +51,11 @@ bootstrapApplication(AppComponent, {
|
|||||||
{provide: MAT_DATE_LOCALE, useValue: 'de-DE'},
|
{provide: MAT_DATE_LOCALE, useValue: 'de-DE'},
|
||||||
provideAnimations(),
|
provideAnimations(),
|
||||||
],
|
],
|
||||||
}).catch(err => console.error(err));
|
})
|
||||||
|
.then(appRef => {
|
||||||
|
const userService = appRef.injector.get(UserService);
|
||||||
|
window.wgeneratorAdmin = {
|
||||||
|
rebuildSongUsage: () => userService.rebuildSongUsage(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(err => console.error(err));
|
||||||
|
|||||||
Reference in New Issue
Block a user