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>(); 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(null); readonly isLoadingStats = signal(false); readonly statsError = signal(null); // State signals - Guest Analytics readonly guestAnalyticsState = signal(null); readonly isLoadingAnalytics = signal(false); readonly analyticsError = signal(null); // State signals - Guest Settings readonly guestSettingsState = signal(null); readonly isLoadingSettings = signal(false); readonly settingsError = signal(null); // State signals - User Management readonly adminUsersState = signal([]); readonly isLoadingUsers = signal(false); readonly usersError = signal(null); readonly usersPagination = signal<{ currentPage: number; totalPages: number; totalItems: number; itemsPerPage: number; hasNextPage: boolean; hasPreviousPage: boolean; } | null>(null); readonly currentUserFilters = signal({}); // State signals - User Detail readonly selectedUserDetail = signal(null); readonly isLoadingUserDetail = signal(false); readonly userDetailError = signal(null); // Date range filter readonly dateRangeFilter = signal({ startDate: null, endDate: null }); // Computed signals - Statistics readonly hasStats = computed(() => this.adminStatsState() !== null); readonly totalUsers = computed(() => this.adminStatsState()?.users.total ?? 0); readonly activeUsers = computed(() => this.adminStatsState()?.users.active ?? 0); readonly totalQuizSessions = computed(() => this.adminStatsState()?.quizzes.totalSessions ?? 0); readonly totalQuestions = computed(() => this.adminStatsState()?.content.totalQuestions ?? 0); readonly averageScore = computed(() => this.adminStatsState()?.quizzes.averageScore ?? 0); // Computed signals - Guest Analytics readonly hasAnalytics = computed(() => this.guestAnalyticsState() !== null); readonly totalGuestSessions = computed(() => this.guestAnalyticsState()?.overview.totalGuestSessions ?? 0); readonly activeGuestSessions = computed(() => this.guestAnalyticsState()?.overview.activeGuestSessions ?? 0); readonly conversionRate = computed(() => this.guestAnalyticsState()?.overview.conversionRate ?? 0); readonly avgQuizzesPerGuest = computed(() => this.guestAnalyticsState()?.quizActivity.avgQuizzesPerGuest ?? 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()?.totalItems ?? 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 { const cacheKey = 'admin-statistics'; // Check cache first if (!forceRefresh) { const cached = this.getFromCache(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(`${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 { this.isLoadingStats.set(true); this.statsError.set(null); const params = { startDate: startDate.toISOString(), endDate: endDate.toISOString() }; return this.http.get(`${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 { this.invalidateCache('admin-statistics'); return this.getStatistics(true); } /** * Get guest user analytics * Implements 10-minute caching */ getGuestAnalytics(forceRefresh: boolean = false): Observable { const cacheKey = 'guest-analytics'; // Check cache first if (!forceRefresh) { const cached = this.getFromCache(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(`${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 { this.invalidateCache('guest-analytics'); return this.getGuestAnalytics(true); } /** * Get data from cache if not expired */ private getFromCache(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(key: string, data: T, ttl: number = this.STATS_CACHE_TTL): void { const now = Date.now(); const entry: AdminCacheEntry = { 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 { 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 { const cacheKey = 'guest-settings'; // Check cache first if (!forceRefresh) { const cached = this.getFromCache(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(`${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 { this.invalidateCache('guest-settings'); return this.getGuestSettings(true); } /** * Update guest access settings * Invalidates cache and updates state */ updateGuestSettings(data: Partial): Observable { this.isLoadingSettings.set(true); this.settingsError.set(null); return this.http.put(`${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 { 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 { 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(`${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 { 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 { this.isLoadingUserDetail.set(true); this.userDetailError.set(null); return this.http.get(`${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 { 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 { 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')) ); } /** * Get all questions with pagination, search, and filtering * Endpoint: GET /api/admin/questions */ getAllQuestions(params: { page?: number; limit?: number; search?: string; category?: string; difficulty?: string; sortBy?: string; order?: string; }): Observable<{ success: boolean; count: number; total: number; page: number; totalPages: number; limit: number; filters: any; data: Question[]; message: string; }> { let queryParams: any = {}; if (params.page) queryParams.page = params.page; if (params.limit) queryParams.limit = params.limit; if (params.search) queryParams.search = params.search; if (params.category && params.category !== 'all') queryParams.category = params.category; if (params.difficulty && params.difficulty !== 'all') queryParams.difficulty = params.difficulty; if (params.sortBy) queryParams.sortBy = params.sortBy; if (params.order) queryParams.order = params.order.toUpperCase(); return this.http.get(`${this.apiUrl}/questions`, { params: queryParams }).pipe( catchError((error: HttpErrorResponse) => this.handleQuestionError(error, 'Failed to load questions')) ); } /** * 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 { 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(); } }