fix user serivce

This commit is contained in:
2026-03-09 23:46:04 +01:00
parent 0d0873730a
commit db6a230696
4 changed files with 214 additions and 179 deletions

View File

@@ -1,7 +1,7 @@
import {Injectable, inject} from '@angular/core'; import {Injectable, inject} from '@angular/core';
import {ShowDataService} from './show-data.service'; import {ShowDataService} from './show-data.service';
import {Show} from './show'; import {Show} from './show';
import {Observable} from 'rxjs'; import {firstValueFrom, Observable} from 'rxjs';
import {UserService} from '../../../services/user/user.service'; import {UserService} from '../../../services/user/user.service';
import {map, switchMap} from 'rxjs/operators'; import {map, switchMap} from 'rxjs/operators';
import {User} from '../../../services/user/user'; 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 = ['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_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']; 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<Show | null> => this.showDataService.read$(showId); public read$ = (showId: string): Observable<Show | null> => this.showDataService.read$(showId);
public listPublicSince$ = (lastMonths: number): Observable<Show[]> => this.showDataService.listPublicSince$(lastMonths); public listPublicSince$ = (lastMonths: number): Observable<Show[]> => this.showDataService.listPublicSince$(lastMonths);
@@ -40,10 +33,11 @@ export class ShowService {
public update$ = async (showId: string, data: Partial<Show>): Promise<void> => this.showDataService.update(showId, data); public update$ = async (showId: string, data: Partial<Show>): Promise<void> => this.showDataService.update(showId, data);
public async new$(data: Partial<Show>): Promise<string | null> { public async new$(data: Partial<Show>): Promise<string | null> {
if (!data.showType || !this.user) return null; const user = await firstValueFrom(this.userService.user$);
if (!data.showType || !user) return null;
const calculatedData: Partial<Show> = { const calculatedData: Partial<Show> = {
...data, ...data,
owner: this.user.id, owner: user.id,
order: [], order: [],
public: ShowService.SHOW_TYPE_PUBLIC.indexOf(data.showType) !== -1, public: ShowService.SHOW_TYPE_PUBLIC.indexOf(data.showType) !== -1,
}; };

View File

@@ -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$<User>('users').pipe(shareReplay({bufferSize: 1, refCount: true}));
private userByIdCache = new Map<string, Observable<User | null>>();
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<string | null> {
return this.iUserId$;
}
public get user$(): Observable<User | null> {
return this.iUser$;
}
public currentUser = async (): Promise<User | null> => firstValueFrom(this.user$);
public loggedIn$ = (): Observable<boolean> => this.authStateChanges$.pipe(map(auth => !!auth));
public list$ = (): Observable<User[]> => this.users$;
public getUserbyId = (userId: string): Promise<User | null> => firstValueFrom(this.getUserbyId$(userId));
public getUserbyId$ = (userId: string): Observable<User | null> => {
const cached = this.userByIdCache.get(userId);
if (cached) {
return cached;
}
const user$ = this.db.doc$<User>(`users/${userId}`).pipe(shareReplay({bufferSize: 1, refCount: true}));
this.userByIdCache.set(userId, user$);
return user$;
};
public async login(user: string, password: string): Promise<string | null> {
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<void> {
await this.runInFirebaseContext(() => signOut(this.auth));
}
public async update$(uid: string, data: Partial<User>): Promise<void> {
await this.db.doc<User>('users/' + uid).update(data);
}
public async changePassword(user: string): Promise<void> {
await this.runInFirebaseContext(() => sendPasswordResetEmail(this.auth, user, {url: environment.url}));
}
public async createNewUser(user: string, name: string, password: string): Promise<void> {
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 = <T>(factory: () => T): T => runInInjectionContext(this.environmentInjector, factory);
private readUser$ = (uid: string) => this.db.doc$<User>('users/' + uid);
private readUser = (uid: string): Promise<User | null> => firstValueFrom(this.readUser$(uid));
private createAuthState$(): Observable<AuthUser | null> {
return runInInjectionContext(this.environmentInjector, () => authState(this.auth));
}
}

View File

@@ -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<SongUsageMigrationResult> {
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<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.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');
}
}

View File

@@ -1,184 +1,37 @@
import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@angular/core'; import {Injectable, inject} from '@angular/core';
import {Auth, authState, createUserWithEmailAndPassword, sendPasswordResetEmail, signInWithEmailAndPassword, signOut} from '@angular/fire/auth'; import {Observable} from 'rxjs';
import {BehaviorSubject, firstValueFrom, Observable} from 'rxjs';
import {filter, map, shareReplay, switchMap, take, tap} from 'rxjs/operators';
import {User} from './user'; import {User} from './user';
import {DbService} from '../db.service'; import {SongUsageMigrationResult, UserSongUsageService} from './user-song-usage.service';
import {environment} from '../../../environments/environment'; import {UserSessionService} from './user-session.service';
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;
}
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class UserService { export class UserService {
private auth = inject(Auth); private session = inject(UserSessionService);
private db = inject(DbService); private songUsage = inject(UserSongUsageService);
private router = inject(Router);
private showDataService = inject(ShowDataService);
private showSongDataService = inject(ShowSongDataService);
private environmentInjector = inject(EnvironmentInjector);
public users$ = this.db.col$<User>('users').pipe(shareReplay({bufferSize: 1, refCount: true})); public users$ = this.session.users$;
private iUserId$ = new BehaviorSubject<string | null>(null);
private iUser$ = new BehaviorSubject<User | null>(null);
private userByIdCache = new Map<string, Observable<User | null>>();
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 get userId$(): Observable<string | null> { public get userId$(): Observable<string | null> {
return this.iUserId$.asObservable(); return this.session.userId$;
} }
public get user$(): Observable<User | null> { public get user$(): Observable<User | null> {
return this.iUser$.pipe(filter(_ => !!_)); return this.session.user$;
} }
public currentUser = async (): Promise<User | null> => firstValueFrom(this.user$); public currentUser = (): Promise<User | null> => this.session.currentUser();
public getUserbyId = (userId: string): Promise<User | null> => this.session.getUserbyId(userId);
public getUserbyId = (userId: string): Promise<User | null> => firstValueFrom(this.getUserbyId$(userId)); public getUserbyId$ = (userId: string): Observable<User | null> => this.session.getUserbyId$(userId);
public getUserbyId$ = (userId: string): Observable<User | null> => { public login = (user: string, password: string): Promise<string | null> => this.session.login(user, password);
const cached = this.userByIdCache.get(userId); public loggedIn$ = (): Observable<boolean> => this.session.loggedIn$();
if (cached) { public list$ = (): Observable<User[]> => this.session.list$();
return cached; public logout = (): Promise<void> => this.session.logout();
} public update$ = (uid: string, data: Partial<User>): Promise<void> => this.session.update$(uid, data);
public changePassword = (user: string): Promise<void> => this.session.changePassword(user);
const user$ = this.db.doc$<User>(`users/${userId}`).pipe(shareReplay({bufferSize: 1, refCount: true})); public createNewUser = (user: string, name: string, password: string): Promise<void> => this.session.createNewUser(user, name, password);
this.userByIdCache.set(userId, user$); public incSongCount = (songId: string): Promise<void | null> => this.songUsage.incSongCount(songId);
return user$; public decSongCount = (songId: string): Promise<void | null> => this.songUsage.decSongCount(songId);
}; public rebuildSongUsage = (): Promise<SongUsageMigrationResult> => this.songUsage.rebuildSongUsage();
public async login(user: string, password: string): Promise<string | null> {
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<boolean> = () => this.authState$().pipe(map(_ => !!_));
public list$: () => Observable<User[]> = (): Observable<User[]> => this.users$;
public async logout(): Promise<void> {
await this.runInFirebaseContext(() => signOut(this.auth));
this.iUser$.next(null);
this.iUserId$.next(null);
}
public async update$(uid: string, data: Partial<User>): Promise<void> {
await this.db.doc<User>('users/' + uid).update(data);
}
public async changePassword(user: string): Promise<void> {
const url = environment.url;
await this.runInFirebaseContext(() => sendPasswordResetEmail(this.auth, user, {url}));
}
public async createNewUser(user: string, name: string, password: string): Promise<void> {
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<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) {
const user = await firstValueFrom(this.user$);
if (!user) return null;
await this.db.doc<User>('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 = <T>(factory: () => T): T => runInInjectionContext(this.environmentInjector, factory);
private readUser$ = (uid: string) => this.db.doc$<User>('users/' + uid);
private readUser: (uid: string) => Promise<User | null> = (uid: string) => firstValueFrom(this.readUser$(uid));
} }