add changes

This commit is contained in:
AD2025
2025-11-14 21:48:47 +02:00
parent 6f23890407
commit 37b4d565b1
72 changed files with 17104 additions and 246 deletions

View File

@@ -0,0 +1,815 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, tap, map } from 'rxjs/operators';
import { Router } from '@angular/router';
import { environment } from '../../../environments/environment';
import {
AdminStatistics,
AdminStatisticsResponse,
AdminCacheEntry,
DateRangeFilter,
GuestAnalytics,
GuestAnalyticsResponse,
GuestSettings,
GuestSettingsResponse,
AdminUser,
AdminUserListResponse,
UserListParams,
AdminUserDetail,
AdminUserDetailResponse
} from '../models/admin.model';
import { Question, QuestionFormData } from '../models/question.model';
import { ToastService } from './toast.service';
/**
* AdminService
*
* Handles all admin-related API operations including:
* - System-wide statistics
* - User analytics
* - Guest analytics
* - User management
* - Question management
* - Settings management
*
* Features:
* - Signal-based state management
* - 5-minute caching for statistics
* - Automatic authorization error handling
* - Admin role verification
*/
@Injectable({
providedIn: 'root'
})
export class AdminService {
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly toastService = inject(ToastService);
private readonly apiUrl = `${environment.apiUrl}/admin`;
// Cache storage for admin data
private readonly cache = new Map<string, AdminCacheEntry<any>>();
private readonly STATS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
private readonly ANALYTICS_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
// State signals - Statistics
readonly adminStatsState = signal<AdminStatistics | null>(null);
readonly isLoadingStats = signal<boolean>(false);
readonly statsError = signal<string | null>(null);
// State signals - Guest Analytics
readonly guestAnalyticsState = signal<GuestAnalytics | null>(null);
readonly isLoadingAnalytics = signal<boolean>(false);
readonly analyticsError = signal<string | null>(null);
// State signals - Guest Settings
readonly guestSettingsState = signal<GuestSettings | null>(null);
readonly isLoadingSettings = signal<boolean>(false);
readonly settingsError = signal<string | null>(null);
// State signals - User Management
readonly adminUsersState = signal<AdminUser[]>([]);
readonly isLoadingUsers = signal<boolean>(false);
readonly usersError = signal<string | null>(null);
readonly usersPagination = signal<{
currentPage: number;
totalPages: number;
totalUsers: number;
limit: number;
hasNext: boolean;
hasPrev: boolean;
} | null>(null);
readonly currentUserFilters = signal<UserListParams>({});
// State signals - User Detail
readonly selectedUserDetail = signal<AdminUserDetail | null>(null);
readonly isLoadingUserDetail = signal<boolean>(false);
readonly userDetailError = signal<string | null>(null);
// Date range filter
readonly dateRangeFilter = signal<DateRangeFilter>({
startDate: null,
endDate: null
});
// Computed signals - Statistics
readonly hasStats = computed(() => this.adminStatsState() !== null);
readonly totalUsers = computed(() => this.adminStatsState()?.totalUsers ?? 0);
readonly activeUsers = computed(() => this.adminStatsState()?.activeUsers ?? 0);
readonly totalQuizSessions = computed(() => this.adminStatsState()?.totalQuizSessions ?? 0);
readonly totalQuestions = computed(() => this.adminStatsState()?.totalQuestions ?? 0);
readonly averageScore = computed(() => this.adminStatsState()?.averageQuizScore ?? 0);
// Computed signals - Guest Analytics
readonly hasAnalytics = computed(() => this.guestAnalyticsState() !== null);
readonly totalGuestSessions = computed(() => this.guestAnalyticsState()?.totalGuestSessions ?? 0);
readonly activeGuestSessions = computed(() => this.guestAnalyticsState()?.activeGuestSessions ?? 0);
readonly conversionRate = computed(() => this.guestAnalyticsState()?.conversionRate ?? 0);
readonly avgQuizzesPerGuest = computed(() => this.guestAnalyticsState()?.averageQuizzesPerGuest ?? 0);
// Computed signals - Guest Settings
readonly hasSettings = computed(() => this.guestSettingsState() !== null);
readonly isGuestAccessEnabled = computed(() => this.guestSettingsState()?.guestAccessEnabled ?? false);
readonly maxQuizzesPerDay = computed(() => this.guestSettingsState()?.maxQuizzesPerDay ?? 0);
readonly maxQuestionsPerQuiz = computed(() => this.guestSettingsState()?.maxQuestionsPerQuiz ?? 0);
// Computed signals - User Management
readonly hasUsers = computed(() => this.adminUsersState().length > 0);
readonly totalUsersCount = computed(() => this.usersPagination()?.totalUsers ?? 0);
readonly currentPage = computed(() => this.usersPagination()?.currentPage ?? 1);
readonly totalPages = computed(() => this.usersPagination()?.totalPages ?? 1);
// Computed signals - User Detail
readonly hasUserDetail = computed(() => this.selectedUserDetail() !== null);
readonly userFullName = computed(() => {
const user = this.selectedUserDetail();
return user ? user.username : '';
});
readonly userTotalQuizzes = computed(() => this.selectedUserDetail()?.statistics.totalQuizzes ?? 0);
readonly userAverageScore = computed(() => this.selectedUserDetail()?.statistics.averageScore ?? 0);
readonly userAccuracy = computed(() => this.selectedUserDetail()?.statistics.accuracy ?? 0);
/**
* Get system-wide statistics
* Implements 5-minute caching
*/
getStatistics(forceRefresh: boolean = false): Observable<AdminStatistics> {
const cacheKey = 'admin-statistics';
// Check cache first
if (!forceRefresh) {
const cached = this.getFromCache<AdminStatistics>(cacheKey);
if (cached) {
this.adminStatsState.set(cached);
return new Observable(observer => {
observer.next(cached);
observer.complete();
});
}
}
this.isLoadingStats.set(true);
this.statsError.set(null);
return this.http.get<AdminStatisticsResponse>(`${this.apiUrl}/statistics`).pipe(
map(response => response.data),
tap(data => {
this.adminStatsState.set(data);
this.setCache(cacheKey, data);
this.isLoadingStats.set(false);
}),
catchError(error => {
this.isLoadingStats.set(false);
return this.handleError(error, 'Failed to load statistics');
})
);
}
/**
* Get statistics with date range filter
*/
getStatisticsWithDateRange(startDate: Date, endDate: Date): Observable<AdminStatistics> {
this.isLoadingStats.set(true);
this.statsError.set(null);
const params = {
startDate: startDate.toISOString(),
endDate: endDate.toISOString()
};
return this.http.get<AdminStatisticsResponse>(`${this.apiUrl}/statistics`, { params }).pipe(
map(response => response.data),
tap(data => {
this.adminStatsState.set(data);
this.isLoadingStats.set(false);
this.dateRangeFilter.set({ startDate, endDate });
}),
catchError(error => {
this.isLoadingStats.set(false);
return this.handleError(error, 'Failed to load filtered statistics');
})
);
}
/**
* Clear date range filter and reload all-time statistics
*/
clearDateFilter(): void {
this.dateRangeFilter.set({ startDate: null, endDate: null });
this.getStatistics(true).subscribe();
}
/**
* Refresh statistics (force cache invalidation)
*/
refreshStatistics(): Observable<AdminStatistics> {
this.invalidateCache('admin-statistics');
return this.getStatistics(true);
}
/**
* Get guest user analytics
* Implements 10-minute caching
*/
getGuestAnalytics(forceRefresh: boolean = false): Observable<GuestAnalytics> {
const cacheKey = 'guest-analytics';
// Check cache first
if (!forceRefresh) {
const cached = this.getFromCache<GuestAnalytics>(cacheKey);
if (cached) {
this.guestAnalyticsState.set(cached);
return new Observable(observer => {
observer.next(cached);
observer.complete();
});
}
}
this.isLoadingAnalytics.set(true);
this.analyticsError.set(null);
return this.http.get<GuestAnalyticsResponse>(`${this.apiUrl}/guest-analytics`).pipe(
map(response => response.data),
tap(data => {
this.guestAnalyticsState.set(data);
this.setCache(cacheKey, data, this.ANALYTICS_CACHE_TTL);
this.isLoadingAnalytics.set(false);
}),
catchError(error => {
this.isLoadingAnalytics.set(false);
return this.handleError(error, 'Failed to load guest analytics');
})
);
}
/**
* Refresh guest analytics (force cache invalidation)
*/
refreshGuestAnalytics(): Observable<GuestAnalytics> {
this.invalidateCache('guest-analytics');
return this.getGuestAnalytics(true);
}
/**
* Get data from cache if not expired
*/
private getFromCache<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
const now = Date.now();
if (now > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
/**
* Store data in cache with TTL
*/
private setCache<T>(key: string, data: T, ttl: number = this.STATS_CACHE_TTL): void {
const now = Date.now();
const entry: AdminCacheEntry<T> = {
data,
timestamp: now,
expiresAt: now + ttl
};
this.cache.set(key, entry);
}
/**
* Invalidate specific cache entry
*/
private invalidateCache(key: string): void {
this.cache.delete(key);
}
/**
* Clear all cache entries
*/
clearCache(): void {
this.cache.clear();
}
/**
* Handle HTTP errors with proper messaging
*/
private handleError(error: HttpErrorResponse, defaultMessage: string): Observable<never> {
let errorMessage = defaultMessage;
if (error.status === 401) {
errorMessage = 'Unauthorized. Please login again.';
this.toastService.error(errorMessage);
this.router.navigate(['/login']);
} else if (error.status === 403) {
errorMessage = 'Access denied. Admin privileges required.';
this.toastService.error(errorMessage);
this.router.navigate(['/dashboard']);
} else if (error.status === 404) {
errorMessage = 'Resource not found.';
this.toastService.error(errorMessage);
} else if (error.status === 500) {
errorMessage = 'Server error. Please try again later.';
this.toastService.error(errorMessage);
} else if (error.error?.message) {
errorMessage = error.error.message;
this.toastService.error(errorMessage);
} else {
this.toastService.error(errorMessage);
}
this.statsError.set(errorMessage);
return throwError(() => new Error(errorMessage));
}
/**
* Get guest access settings
* Implements 10-minute caching
*/
getGuestSettings(forceRefresh: boolean = false): Observable<GuestSettings> {
const cacheKey = 'guest-settings';
// Check cache first
if (!forceRefresh) {
const cached = this.getFromCache<GuestSettings>(cacheKey);
if (cached) {
this.guestSettingsState.set(cached);
return new Observable(observer => {
observer.next(cached);
observer.complete();
});
}
}
this.isLoadingSettings.set(true);
this.settingsError.set(null);
return this.http.get<GuestSettingsResponse>(`${this.apiUrl}/guest-settings`).pipe(
map(response => response.data),
tap(data => {
this.guestSettingsState.set(data);
this.setCache(cacheKey, data, this.ANALYTICS_CACHE_TTL);
this.isLoadingSettings.set(false);
}),
catchError(error => {
this.isLoadingSettings.set(false);
return this.handleSettingsError(error, 'Failed to load guest settings');
})
);
}
/**
* Refresh guest settings (force reload)
*/
refreshGuestSettings(): Observable<GuestSettings> {
this.invalidateCache('guest-settings');
return this.getGuestSettings(true);
}
/**
* Update guest access settings
* Invalidates cache and updates state
*/
updateGuestSettings(data: Partial<GuestSettings>): Observable<GuestSettings> {
this.isLoadingSettings.set(true);
this.settingsError.set(null);
return this.http.put<GuestSettingsResponse>(`${this.apiUrl}/guest-settings`, data).pipe(
map(response => response.data),
tap(updatedSettings => {
this.guestSettingsState.set(updatedSettings);
this.invalidateCache('guest-settings');
this.isLoadingSettings.set(false);
this.toastService.success('Guest settings updated successfully');
}),
catchError(error => {
this.isLoadingSettings.set(false);
return this.handleSettingsError(error, 'Failed to update guest settings');
})
);
}
/**
* Handle HTTP errors for guest settings
*/
private handleSettingsError(error: HttpErrorResponse, defaultMessage: string): Observable<never> {
let errorMessage = defaultMessage;
if (error.status === 401) {
errorMessage = 'Unauthorized. Please login again.';
this.toastService.error(errorMessage);
this.router.navigate(['/login']);
} else if (error.status === 403) {
errorMessage = 'Access denied. Admin privileges required.';
this.toastService.error(errorMessage);
this.router.navigate(['/dashboard']);
} else if (error.status === 404) {
errorMessage = 'Settings not found.';
this.toastService.error(errorMessage);
} else if (error.error?.message) {
errorMessage = error.error.message;
this.toastService.error(errorMessage);
} else {
this.toastService.error(errorMessage);
}
this.settingsError.set(errorMessage);
return throwError(() => new Error(errorMessage));
}
/**
* Get users with pagination, filtering, and sorting
*/
getUsers(params: UserListParams = {}): Observable<AdminUserListResponse> {
this.isLoadingUsers.set(true);
this.usersError.set(null);
// Build query parameters
const queryParams: any = {
page: params.page ?? 1,
limit: params.limit ?? 10
};
if (params.role && params.role !== 'all') {
queryParams.role = params.role;
}
if (params.isActive && params.isActive !== 'all') {
queryParams.isActive = params.isActive === 'active';
}
if (params.sortBy) {
queryParams.sortBy = params.sortBy;
queryParams.sortOrder = params.sortOrder ?? 'asc';
}
if (params.search) {
queryParams.search = params.search;
}
return this.http.get<AdminUserListResponse>(`${this.apiUrl}/users`, { params: queryParams }).pipe(
tap(response => {
this.adminUsersState.set(response.data.users);
this.usersPagination.set(response.data.pagination);
this.currentUserFilters.set(params);
this.isLoadingUsers.set(false);
}),
catchError(error => {
this.isLoadingUsers.set(false);
return this.handleUsersError(error, 'Failed to load users');
})
);
}
/**
* Refresh users list with current filters
*/
refreshUsers(): Observable<AdminUserListResponse> {
const currentFilters = this.currentUserFilters();
return this.getUsers(currentFilters);
}
/**
* Get detailed user profile by ID
* Fetches comprehensive user data including statistics, quiz history, and activity timeline
*/
getUserDetails(userId: string): Observable<AdminUserDetail> {
this.isLoadingUserDetail.set(true);
this.userDetailError.set(null);
return this.http.get<AdminUserDetailResponse>(`${this.apiUrl}/users/${userId}`).pipe(
map(response => response.data),
tap(data => {
this.selectedUserDetail.set(data);
this.isLoadingUserDetail.set(false);
}),
catchError(error => {
this.isLoadingUserDetail.set(false);
return this.handleUserDetailError(error, 'Failed to load user details');
})
);
}
/**
* Clear selected user detail
*/
clearUserDetail(): void {
this.selectedUserDetail.set(null);
this.userDetailError.set(null);
}
/**
* Update user role (User <-> Admin)
* Updates the role in both the users list and detail view if loaded
*/
updateUserRole(userId: string, role: 'user' | 'admin'): Observable<{ success: boolean; message: string; data: AdminUser }> {
return this.http.put<{ success: boolean; message: string; data: AdminUser }>(`${this.apiUrl}/users/${userId}/role`, { role }).pipe(
tap(response => {
// Update user in the users list if present
const currentUsers = this.adminUsersState();
const updatedUsers = currentUsers.map(user =>
user.id === userId ? { ...user, role } : user
);
this.adminUsersState.set(updatedUsers);
// Update user detail if currently viewing this user
const currentDetail = this.selectedUserDetail();
if (currentDetail && currentDetail.id === userId) {
this.selectedUserDetail.set({ ...currentDetail, role });
}
this.toastService.success(response.message || 'User role updated successfully');
}),
catchError(error => {
let errorMessage = 'Failed to update user role';
if (error.status === 401) {
errorMessage = 'Unauthorized. Please login again.';
this.toastService.error(errorMessage);
this.router.navigate(['/login']);
} else if (error.status === 403) {
errorMessage = 'Access denied. Admin privileges required.';
this.toastService.error(errorMessage);
} else if (error.status === 404) {
errorMessage = 'User not found.';
this.toastService.error(errorMessage);
} else if (error.status === 400 && error.error?.message) {
errorMessage = error.error.message;
this.toastService.error(errorMessage);
} else if (error.error?.message) {
errorMessage = error.error.message;
this.toastService.error(errorMessage);
} else {
this.toastService.error(errorMessage);
}
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Activate user account
* Updates the user status in both the users list and detail view if loaded
*/
activateUser(userId: string): Observable<{ success: boolean; message: string; data: AdminUser }> {
return this.http.put<{ success: boolean; message: string; data: AdminUser }>(`${this.apiUrl}/users/${userId}/activate`, {}).pipe(
tap(response => {
// Update user in the users list if present
const currentUsers = this.adminUsersState();
const updatedUsers = currentUsers.map(user =>
user.id === userId ? { ...user, isActive: true } : user
);
this.adminUsersState.set(updatedUsers);
// Update user detail if currently viewing this user
const currentDetail = this.selectedUserDetail();
if (currentDetail && currentDetail.id === userId) {
this.selectedUserDetail.set({ ...currentDetail, isActive: true });
}
this.toastService.success(response.message || 'User activated successfully');
}),
catchError(error => {
let errorMessage = 'Failed to activate user';
if (error.status === 401) {
errorMessage = 'Unauthorized. Please login again.';
this.toastService.error(errorMessage);
this.router.navigate(['/login']);
} else if (error.status === 403) {
errorMessage = 'Access denied. Admin privileges required.';
this.toastService.error(errorMessage);
} else if (error.status === 404) {
errorMessage = 'User not found.';
this.toastService.error(errorMessage);
} else if (error.error?.message) {
errorMessage = error.error.message;
this.toastService.error(errorMessage);
} else {
this.toastService.error(errorMessage);
}
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Deactivate user account (soft delete)
* Updates the user status in both the users list and detail view if loaded
*/
deactivateUser(userId: string): Observable<{ success: boolean; message: string; data: AdminUser }> {
return this.http.delete<{ success: boolean; message: string; data: AdminUser }>(`${this.apiUrl}/users/${userId}`).pipe(
tap(response => {
// Update user in the users list if present
const currentUsers = this.adminUsersState();
const updatedUsers = currentUsers.map(user =>
user.id === userId ? { ...user, isActive: false } : user
);
this.adminUsersState.set(updatedUsers);
// Update user detail if currently viewing this user
const currentDetail = this.selectedUserDetail();
if (currentDetail && currentDetail.id === userId) {
this.selectedUserDetail.set({ ...currentDetail, isActive: false });
}
this.toastService.success(response.message || 'User deactivated successfully');
}),
catchError(error => {
let errorMessage = 'Failed to deactivate user';
if (error.status === 401) {
errorMessage = 'Unauthorized. Please login again.';
this.toastService.error(errorMessage);
this.router.navigate(['/login']);
} else if (error.status === 403) {
errorMessage = 'Access denied. Admin privileges required.';
this.toastService.error(errorMessage);
} else if (error.status === 404) {
errorMessage = 'User not found.';
this.toastService.error(errorMessage);
} else if (error.error?.message) {
errorMessage = error.error.message;
this.toastService.error(errorMessage);
} else {
this.toastService.error(errorMessage);
}
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Handle HTTP errors for user detail
*/
private handleUserDetailError(error: HttpErrorResponse, defaultMessage: string): Observable<never> {
let errorMessage = defaultMessage;
if (error.status === 401) {
errorMessage = 'Unauthorized. Please login again.';
this.toastService.error(errorMessage);
this.router.navigate(['/login']);
} else if (error.status === 403) {
errorMessage = 'Access denied. Admin privileges required.';
this.toastService.error(errorMessage);
this.router.navigate(['/dashboard']);
} else if (error.status === 404) {
errorMessage = 'User not found.';
this.toastService.error(errorMessage);
} else if (error.error?.message) {
errorMessage = error.error.message;
this.toastService.error(errorMessage);
} else {
this.toastService.error(errorMessage);
}
this.userDetailError.set(errorMessage);
return throwError(() => new Error(errorMessage));
}
/**
* Handle HTTP errors for user management
*/
private handleUsersError(error: HttpErrorResponse, defaultMessage: string): Observable<never> {
let errorMessage = defaultMessage;
if (error.status === 401) {
errorMessage = 'Unauthorized. Please login again.';
this.toastService.error(errorMessage);
this.router.navigate(['/login']);
} else if (error.status === 403) {
errorMessage = 'Access denied. Admin privileges required.';
this.toastService.error(errorMessage);
this.router.navigate(['/dashboard']);
} else if (error.status === 404) {
errorMessage = 'Users not found.';
this.toastService.error(errorMessage);
} else if (error.error?.message) {
errorMessage = error.error.message;
this.toastService.error(errorMessage);
} else {
this.toastService.error(errorMessage);
}
this.usersError.set(errorMessage);
return throwError(() => new Error(errorMessage));
}
// ===========================
// Question Management Methods
// ===========================
/**
* Get question by ID
*/
getQuestion(id: string): Observable<{ success: boolean; data: Question; message?: string }> {
return this.http.get<{ success: boolean; data: Question; message?: string }>(
`${this.apiUrl}/questions/${id}`
).pipe(
catchError((error: HttpErrorResponse) => this.handleQuestionError(error, 'Failed to load question'))
);
}
/**
* Create new question
*/
createQuestion(data: QuestionFormData): Observable<{ success: boolean; data: Question; message?: string }> {
return this.http.post<{ success: boolean; data: Question; message?: string }>(
`${this.apiUrl}/questions`,
data
).pipe(
tap((response) => {
this.toastService.success('Question created successfully');
}),
catchError((error: HttpErrorResponse) => this.handleQuestionError(error, 'Failed to create question'))
);
}
/**
* Update existing question
*/
updateQuestion(id: string, data: QuestionFormData): Observable<{ success: boolean; data: Question; message?: string }> {
return this.http.put<{ success: boolean; data: Question; message?: string }>(
`${this.apiUrl}/questions/${id}`,
data
).pipe(
tap((response) => {
this.toastService.success('Question updated successfully');
}),
catchError((error: HttpErrorResponse) => this.handleQuestionError(error, 'Failed to update question'))
);
}
/**
* Delete question (soft delete)
*/
deleteQuestion(id: string): Observable<{ success: boolean; message?: string }> {
return this.http.delete<{ success: boolean; message?: string }>(
`${this.apiUrl}/questions/${id}`
).pipe(
tap((response) => {
this.toastService.success('Question deleted successfully');
}),
catchError((error: HttpErrorResponse) => this.handleQuestionError(error, 'Failed to delete question'))
);
}
/**
* Handle question-related errors
*/
private handleQuestionError(error: HttpErrorResponse, defaultMessage: string): Observable<never> {
let errorMessage = defaultMessage;
if (error.status === 401) {
errorMessage = 'Unauthorized. Please login again.';
this.toastService.error(errorMessage);
this.router.navigate(['/login']);
} else if (error.status === 403) {
errorMessage = 'Access denied. Admin privileges required.';
this.toastService.error(errorMessage);
this.router.navigate(['/dashboard']);
} else if (error.status === 400) {
errorMessage = error.error?.message || 'Invalid question data. Please check all fields.';
this.toastService.error(errorMessage);
} else if (error.error?.message) {
errorMessage = error.error.message;
this.toastService.error(errorMessage);
} else {
this.toastService.error(errorMessage);
}
return throwError(() => new Error(errorMessage));
}
/**
* Reset all admin state
*/
resetState(): void {
this.adminStatsState.set(null);
this.isLoadingStats.set(false);
this.statsError.set(null);
this.guestAnalyticsState.set(null);
this.isLoadingAnalytics.set(false);
this.analyticsError.set(null);
this.guestSettingsState.set(null);
this.isLoadingSettings.set(false);
this.settingsError.set(null);
this.adminUsersState.set([]);
this.isLoadingUsers.set(false);
this.usersError.set(null);
this.usersPagination.set(null);
this.currentUserFilters.set({});
this.selectedUserDetail.set(null);
this.isLoadingUserDetail.set(false);
this.userDetailError.set(null);
this.dateRangeFilter.set({ startDate: null, endDate: null });
this.clearCache();
}
}