diff --git a/src/app/modules/shows/services/show.service.ts b/src/app/modules/shows/services/show.service.ts index 9123d81..76c2b6c 100644 --- a/src/app/modules/shows/services/show.service.ts +++ b/src/app/modules/shows/services/show.service.ts @@ -1,7 +1,7 @@ import {Injectable, inject} from '@angular/core'; import {ShowDataService} from './show-data.service'; import {Show} from './show'; -import {Observable} from 'rxjs'; +import {firstValueFrom, Observable} from 'rxjs'; import {UserService} from '../../../services/user/user.service'; import {map, switchMap} from 'rxjs/operators'; import {User} from '../../../services/user/user'; @@ -16,13 +16,6 @@ export class ShowService { public static SHOW_TYPE = ['service-worship', 'service-praise', 'home-group-big', 'home-group', 'prayer-group', 'teens-group', 'kids-group', 'misc-public', 'misc-private']; public static SHOW_TYPE_PUBLIC = ['service-worship', 'service-praise', 'home-group-big', 'teens-group', 'kids-group', 'misc-public']; public static SHOW_TYPE_PRIVATE = ['home-group', 'prayer-group', 'misc-private']; - private user: User | null = null; - - public constructor() { - const userService = this.userService; - - userService.user$.subscribe(_ => (this.user = _)); - } public read$ = (showId: string): Observable => this.showDataService.read$(showId); public listPublicSince$ = (lastMonths: number): Observable => this.showDataService.listPublicSince$(lastMonths); @@ -40,10 +33,11 @@ export class ShowService { public update$ = async (showId: string, data: Partial): Promise => this.showDataService.update(showId, data); public async new$(data: Partial): Promise { - if (!data.showType || !this.user) return null; + const user = await firstValueFrom(this.userService.user$); + if (!data.showType || !user) return null; const calculatedData: Partial = { ...data, - owner: this.user.id, + owner: user.id, order: [], public: ShowService.SHOW_TYPE_PUBLIC.indexOf(data.showType) !== -1, }; diff --git a/src/app/services/user/user-session.service.ts b/src/app/services/user/user-session.service.ts new file mode 100644 index 0000000..fe2f408 --- /dev/null +++ b/src/app/services/user/user-session.service.ts @@ -0,0 +1,99 @@ +import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@angular/core'; +import {Auth, authState, createUserWithEmailAndPassword, sendPasswordResetEmail, signInWithEmailAndPassword, signOut} from '@angular/fire/auth'; +import {User as AuthUser} from 'firebase/auth'; +import {firstValueFrom, Observable, of} from 'rxjs'; +import {map, shareReplay, switchMap} from 'rxjs/operators'; +import {DbService} from '../db.service'; +import {environment} from '../../../environments/environment'; +import {Router} from '@angular/router'; +import {User} from './user'; + +@Injectable({ + providedIn: 'root', +}) +export class UserSessionService { + private auth = inject(Auth); + private db = inject(DbService); + private router = inject(Router); + private environmentInjector = inject(EnvironmentInjector); + + private authStateChanges$ = this.createAuthState$().pipe(shareReplay({bufferSize: 1, refCount: true})); + public users$ = this.db.col$('users').pipe(shareReplay({bufferSize: 1, refCount: true})); + private userByIdCache = new Map>(); + private iUserId$ = this.authStateChanges$.pipe( + map(auth => auth?.uid ?? null), + shareReplay({bufferSize: 1, refCount: true}) + ); + private iUser$ = this.iUserId$.pipe( + switchMap(uid => (uid ? this.readUser$(uid) : of(null))), + shareReplay({bufferSize: 1, refCount: true}) + ); + + public get userId$(): Observable { + return this.iUserId$; + } + + public get user$(): Observable { + return this.iUser$; + } + + public currentUser = async (): Promise => firstValueFrom(this.user$); + public loggedIn$ = (): Observable => this.authStateChanges$.pipe(map(auth => !!auth)); + public list$ = (): Observable => this.users$; + + public getUserbyId = (userId: string): Promise => firstValueFrom(this.getUserbyId$(userId)); + public getUserbyId$ = (userId: string): Observable => { + const cached = this.userByIdCache.get(userId); + if (cached) { + return cached; + } + + const user$ = this.db.doc$(`users/${userId}`).pipe(shareReplay({bufferSize: 1, refCount: true})); + this.userByIdCache.set(userId, user$); + return user$; + }; + + public async login(user: string, password: string): Promise { + const aUser = await this.runInFirebaseContext(() => signInWithEmailAndPassword(this.auth, user, password)); + if (!aUser.user) return null; + const dUser = await this.readUser(aUser.user.uid); + if (!dUser) return null; + await this.initSongUsage(dUser); + + return aUser.user.uid; + } + + public async logout(): Promise { + await this.runInFirebaseContext(() => signOut(this.auth)); + } + + public async update$(uid: string, data: Partial): Promise { + await this.db.doc('users/' + uid).update(data); + } + + public async changePassword(user: string): Promise { + await this.runInFirebaseContext(() => sendPasswordResetEmail(this.auth, user, {url: environment.url})); + } + + public async createNewUser(user: string, name: string, password: string): Promise { + const aUser = await this.runInFirebaseContext(() => createUserWithEmailAndPassword(this.auth, user, password)); + if (!aUser.user) return; + const userId = aUser.user.uid; + await this.db.doc('users/' + userId).set({name, chordMode: 'onlyFirst', songUsage: {}}); + const dUser = await this.readUser(aUser.user.uid); + if (!dUser) return; + await this.router.navigateByUrl('/brand/new-user'); + } + + private async initSongUsage(user: User) { + if (user.songUsage) return; + await this.update$(user.id, {songUsage: {}}); + } + + private runInFirebaseContext = (factory: () => T): T => runInInjectionContext(this.environmentInjector, factory); + private readUser$ = (uid: string) => this.db.doc$('users/' + uid); + private readUser = (uid: string): Promise => firstValueFrom(this.readUser$(uid)); + private createAuthState$(): Observable { + return runInInjectionContext(this.environmentInjector, () => authState(this.auth)); + } +} diff --git a/src/app/services/user/user-song-usage.service.ts b/src/app/services/user/user-song-usage.service.ts new file mode 100644 index 0000000..e869665 --- /dev/null +++ b/src/app/services/user/user-song-usage.service.ts @@ -0,0 +1,89 @@ +import {Injectable, inject} from '@angular/core'; +import {firstValueFrom} from 'rxjs'; +import {take} from 'rxjs/operators'; +import {increment} from '@angular/fire/firestore'; +import {DbService} from '../db.service'; +import {ShowDataService} from '../../modules/shows/services/show-data.service'; +import {ShowSongDataService} from '../../modules/shows/services/show-song-data.service'; +import {UserSessionService} from './user-session.service'; + +export interface SongUsageMigrationResult { + usersProcessed: number; + showsProcessed: number; + showSongsProcessed: number; +} + +@Injectable({ + providedIn: 'root', +}) +export class UserSongUsageService { + private db = inject(DbService); + private session = inject(UserSessionService); + private showDataService = inject(ShowDataService); + private showSongDataService = inject(ShowSongDataService); + + 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.session.user$.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.session.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.session.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.session.user$); + if (!user) return null; + + await this.db.doc('users/' + user.id).update({ + [`songUsage.${songId}`]: increment(direction), + }); + } + + private hasAdminRole(role: string | null | undefined): boolean { + if (!role) { + return false; + } + + return role.split(';').includes('admin'); + } +} diff --git a/src/app/services/user/user.service.ts b/src/app/services/user/user.service.ts index 543f75a..3c31ea2 100644 --- a/src/app/services/user/user.service.ts +++ b/src/app/services/user/user.service.ts @@ -1,184 +1,37 @@ -import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@angular/core'; -import {Auth, authState, createUserWithEmailAndPassword, sendPasswordResetEmail, signInWithEmailAndPassword, signOut} from '@angular/fire/auth'; -import {BehaviorSubject, firstValueFrom, Observable} from 'rxjs'; -import {filter, map, shareReplay, switchMap, take, tap} from 'rxjs/operators'; +import {Injectable, inject} from '@angular/core'; +import {Observable} from 'rxjs'; 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 {increment} from '@angular/fire/firestore'; - -export interface SongUsageMigrationResult { - usersProcessed: number; - showsProcessed: number; - showSongsProcessed: number; -} +import {SongUsageMigrationResult, UserSongUsageService} from './user-song-usage.service'; +import {UserSessionService} from './user-session.service'; @Injectable({ providedIn: 'root', }) export class UserService { - private auth = inject(Auth); - private db = inject(DbService); - private router = inject(Router); - private showDataService = inject(ShowDataService); - private showSongDataService = inject(ShowSongDataService); - private environmentInjector = inject(EnvironmentInjector); + private session = inject(UserSessionService); + private songUsage = inject(UserSongUsageService); - public users$ = this.db.col$('users').pipe(shareReplay({bufferSize: 1, refCount: true})); - private iUserId$ = new BehaviorSubject(null); - private iUser$ = new BehaviorSubject(null); - private userByIdCache = new Map>(); - - public constructor() { - this.authState$() - .pipe( - filter(auth => !!auth), - map(auth => auth?.uid ?? ''), - tap(uid => this.iUserId$.next(uid)), - switchMap(uid => this.readUser$(uid)) - ) - .subscribe(_ => this.iUser$.next(_)); - } + public users$ = this.session.users$; public get userId$(): Observable { - return this.iUserId$.asObservable(); + return this.session.userId$; } public get user$(): Observable { - return this.iUser$.pipe(filter(_ => !!_)); + return this.session.user$; } - public currentUser = async (): Promise => firstValueFrom(this.user$); - - public getUserbyId = (userId: string): Promise => firstValueFrom(this.getUserbyId$(userId)); - public getUserbyId$ = (userId: string): Observable => { - const cached = this.userByIdCache.get(userId); - if (cached) { - return cached; - } - - const user$ = this.db.doc$(`users/${userId}`).pipe(shareReplay({bufferSize: 1, refCount: true})); - this.userByIdCache.set(userId, user$); - return user$; - }; - - public async login(user: string, password: string): Promise { - const aUser = await this.runInFirebaseContext(() => signInWithEmailAndPassword(this.auth, 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); - - return aUser.user.uid; - } - - public loggedIn$: () => Observable = () => this.authState$().pipe(map(_ => !!_)); - - public list$: () => Observable = (): Observable => this.users$; - - public async logout(): Promise { - await this.runInFirebaseContext(() => signOut(this.auth)); - this.iUser$.next(null); - this.iUserId$.next(null); - } - - public async update$(uid: string, data: Partial): Promise { - await this.db.doc('users/' + uid).update(data); - } - - public async changePassword(user: string): Promise { - const url = environment.url; - await this.runInFirebaseContext(() => sendPasswordResetEmail(this.auth, user, {url})); - } - - public async createNewUser(user: string, name: string, password: string): Promise { - const aUser = await this.runInFirebaseContext(() => createUserWithEmailAndPassword(this.auth, user, password)); - if (!aUser.user) return; - const userId = aUser.user.uid; - 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'); - } - - 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; - - await this.db.doc('users/' + user.id).update({ - [`songUsage.${songId}`]: increment(direction), - }); - } - - private async initSongUsage(user: User) { - if (user.songUsage) return; - await this.update$(user.id, {songUsage: {}}); - } - - private hasAdminRole(role: string | null | undefined): boolean { - if (!role) { - return false; - } - - return role.split(';').includes('admin'); - } - - private authState$ = () => runInInjectionContext(this.environmentInjector, () => authState(this.auth)); - private runInFirebaseContext = (factory: () => T): T => runInInjectionContext(this.environmentInjector, factory); - private readUser$ = (uid: string) => this.db.doc$('users/' + uid); - private readUser: (uid: string) => Promise = (uid: string) => firstValueFrom(this.readUser$(uid)); + public currentUser = (): Promise => this.session.currentUser(); + public getUserbyId = (userId: string): Promise => this.session.getUserbyId(userId); + public getUserbyId$ = (userId: string): Observable => this.session.getUserbyId$(userId); + public login = (user: string, password: string): Promise => this.session.login(user, password); + public loggedIn$ = (): Observable => this.session.loggedIn$(); + public list$ = (): Observable => this.session.list$(); + public logout = (): Promise => this.session.logout(); + public update$ = (uid: string, data: Partial): Promise => this.session.update$(uid, data); + public changePassword = (user: string): Promise => this.session.changePassword(user); + public createNewUser = (user: string, name: string, password: string): Promise => this.session.createNewUser(user, name, password); + public incSongCount = (songId: string): Promise => this.songUsage.incSongCount(songId); + public decSongCount = (songId: string): Promise => this.songUsage.decSongCount(songId); + public rebuildSongUsage = (): Promise => this.songUsage.rebuildSongUsage(); }