optimize song usage
This commit is contained in:
@@ -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)) {
|
||||
<app-list-item
|
||||
[routerLink]="show.id"
|
||||
[show]="show"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {of} from 'rxjs';
|
||||
import {ShowDataService} from './show-data.service';
|
||||
import {ShowSongDataService} from './show-song-data.service';
|
||||
import {ShowSongIndexService} from './show-song-index.service';
|
||||
import {UserSessionService} from '../../../services/user/user-session.service';
|
||||
import {User} from '../../../services/user/user';
|
||||
|
||||
describe('ShowSongIndexService', () => {
|
||||
let service: ShowSongIndexService;
|
||||
let showDataServiceSpy: jasmine.SpyObj<ShowDataService>;
|
||||
let showSongDataServiceSpy: jasmine.SpyObj<ShowSongDataService>;
|
||||
let sessionSpy: jasmine.SpyObj<UserSessionService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('ShowDataService', ['listRaw$', 'update']);
|
||||
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['list$']);
|
||||
sessionSpy = jasmine.createSpyObj<UserSessionService>('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<ShowDataService['listRaw$']>);
|
||||
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.');
|
||||
});
|
||||
});
|
||||
65
src/app/modules/shows/services/show-song-index.service.ts
Normal file
65
src/app/modules/shows/services/show-song-index.service.ts
Normal file
@@ -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<ShowSongIndexMigrationResult> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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<User | null>({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'letters', songUsage: {}});
|
||||
user$ = new BehaviorSubject<User | null>({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'onlyFirst', songUsage: {}});
|
||||
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['add', 'read$', 'list$', 'delete', 'update$']);
|
||||
songDataServiceSpy = jasmine.createSpyObj<SongDataService>('SongDataService', ['read$']);
|
||||
userServiceSpy = jasmine.createSpyObj<UserService>('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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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<ShowSong | null> => this.showSongDataService.read$(showId, songId);
|
||||
@@ -38,14 +40,19 @@ export class ShowSongService {
|
||||
public list = (showId: string): Promise<ShowSong[]> => firstValueFrom(this.list$(showId));
|
||||
|
||||
public async delete$(showId: string, showSongId: string, index: number): Promise<void> {
|
||||
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<ShowSong>): Promise<void> => await this.showSongDataService.update$(showId, songId, data);
|
||||
|
||||
@@ -16,9 +16,11 @@ describe('ShowService', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
user$ = new BehaviorSubject<unknown>({id: 'user-1'});
|
||||
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('ShowDataService', ['read$', 'listPublicSince$', 'update', 'add'], {
|
||||
list$: of(shows),
|
||||
});
|
||||
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>(
|
||||
'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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Show {
|
||||
showType: string;
|
||||
date: Timestamp;
|
||||
owner: string;
|
||||
songIds?: string[];
|
||||
public: boolean;
|
||||
reported: boolean;
|
||||
published: boolean;
|
||||
|
||||
@@ -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<void> {
|
||||
if (!this.showId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Array<Promise<void | null>> = [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 {
|
||||
|
||||
@@ -40,7 +40,12 @@
|
||||
@if (song.origin) {
|
||||
<div>Quelle: {{ song.origin }}</div>
|
||||
}
|
||||
<div>Wie oft verwendet: {{ songCount$ | async }}</div>
|
||||
<div
|
||||
[matTooltip]="songUsageTooltip$ | async"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
Wie oft verwendet: {{ songCount$ | async }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (user$ | async; as user) {
|
||||
@@ -81,7 +86,7 @@
|
||||
Zu Veranstaltung hinzufügen
|
||||
</app-button>
|
||||
<mat-menu #menu="matMenu">
|
||||
@for (show of privateShows$|async; track show) {
|
||||
@for (show of privateShows$|async; track show.id) {
|
||||
<app-button (click)="addSongToShow(show, song)">
|
||||
{{ show.date.toDate() | date: "dd.MM.yyyy" }} {{ show.showType | showType }}
|
||||
</app-button>
|
||||
|
||||
@@ -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>('SongService', ['read$']);
|
||||
const fileDataServiceSpy = jasmine.createSpyObj<FileDataService>('FileDataService', ['read$']);
|
||||
const userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['incSongCount', 'decSongCount'], {
|
||||
user$: of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'onlyFirst', songUsage: {'4711': 2}}),
|
||||
});
|
||||
const showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['list$', 'update$']);
|
||||
const showSongServiceSpy = jasmine.createSpyObj<ShowSongService>('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();
|
||||
}));
|
||||
|
||||
|
||||
@@ -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<File[] | null> | null = null;
|
||||
public user$: Observable<User | null> | null = null;
|
||||
public songCount$: Observable<number> | null = null;
|
||||
public songUsageShows$: Observable<Show[]> | null = null;
|
||||
public songUsageTooltip$: Observable<string> | 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[] => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export class DbCollection<T> {
|
||||
) {}
|
||||
|
||||
public add(data: Partial<T>): Promise<DocumentReference<T>> {
|
||||
return addDoc(this.ref as CollectionReference<T>, data as WithFieldValue<T>);
|
||||
return runInInjectionContext(this.environmentInjector, () => addDoc(this.ref as CollectionReference<T>, data as WithFieldValue<T>));
|
||||
}
|
||||
|
||||
public valueChanges(options?: {idField?: string}): Observable<T[]> {
|
||||
@@ -38,7 +38,7 @@ export class DbCollection<T> {
|
||||
}
|
||||
|
||||
private get ref(): CollectionReference<DocumentData> {
|
||||
return collection(this.fs, this.path);
|
||||
return runInInjectionContext(this.environmentInjector, () => collection(this.fs, this.path));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,15 +50,15 @@ export class DbDocument<T> {
|
||||
) {}
|
||||
|
||||
public set(data: Partial<T>): Promise<void> {
|
||||
return setDoc(this.ref as DocumentReference<T>, data as WithFieldValue<T>);
|
||||
return runInInjectionContext(this.environmentInjector, () => setDoc(this.ref as DocumentReference<T>, data as WithFieldValue<T>));
|
||||
}
|
||||
|
||||
public update(data: Partial<T>): Promise<void> {
|
||||
return updateDoc(this.ref, data as Partial<DocumentData>);
|
||||
return runInInjectionContext(this.environmentInjector, () => updateDoc(this.ref, data as Partial<DocumentData>));
|
||||
}
|
||||
|
||||
public delete(): Promise<void> {
|
||||
return deleteDoc(this.ref);
|
||||
return runInInjectionContext(this.environmentInjector, () => deleteDoc(this.ref));
|
||||
}
|
||||
|
||||
public collection<U>(subPath: string): DbCollection<U> {
|
||||
@@ -73,7 +73,7 @@ export class DbDocument<T> {
|
||||
}
|
||||
|
||||
private get ref(): DocumentReference<DocumentData> {
|
||||
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<T[]>);
|
||||
return runInInjectionContext(this.environmentInjector, () => {
|
||||
const q = query(collection(this.fs, ref), ...queryConstraints);
|
||||
return collectionData(q, {idField: 'id'}) as Observable<T[]>;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<UserSessionService>;
|
||||
let songUsageSpy: jasmine.SpyObj<UserSongUsageService>;
|
||||
let showSongIndexSpy: jasmine.SpyObj<ShowSongIndexService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
sessionSpy = jasmine.createSpyObj<UserSessionService>(
|
||||
@@ -20,6 +22,7 @@ describe('UserService', () => {
|
||||
}
|
||||
);
|
||||
songUsageSpy = jasmine.createSpyObj<UserSongUsageService>('UserSongUsageService', ['incSongCount', 'decSongCount', 'rebuildSongUsage']);
|
||||
showSongIndexSpy = jasmine.createSpyObj<ShowSongIndexService>('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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void | null> => this.songUsage.incSongCount(songId);
|
||||
public decSongCount = (songId: string): Promise<void | null> => this.songUsage.decSongCount(songId);
|
||||
public rebuildSongUsage = (): Promise<SongUsageMigrationResult> => this.songUsage.rebuildSongUsage();
|
||||
public rebuildShowSongIds = (onProgress?: (progress: MigrationProgress) => void): Promise<ShowSongIndexMigrationResult> =>
|
||||
this.showSongIndex.rebuildShowSongIds(onProgress);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<mat-option>
|
||||
<ngx-mat-select-search [formControl]="filteredSongsControl"></ngx-mat-select-search>
|
||||
</mat-option>
|
||||
@for (song of filteredSongs(); track song) {
|
||||
@for (song of filteredSongs(); track song.id) {
|
||||
<mat-option [value]="song.id">{{ song.title }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
|
||||
15
src/main.ts
15
src/main.ts
@@ -18,6 +18,7 @@ declare global {
|
||||
interface Window {
|
||||
wgeneratorAdmin?: {
|
||||
rebuildSongUsage(): Promise<unknown>;
|
||||
rebuildShowSongIds(): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
20
src/types/qrcode.d.ts
vendored
Normal file
20
src/types/qrcode.d.ts
vendored
Normal file
@@ -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<string>;
|
||||
|
||||
const QRCode: {
|
||||
toDataURL: typeof toDataURL;
|
||||
};
|
||||
|
||||
export default QRCode;
|
||||
}
|
||||
Reference in New Issue
Block a user