import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http'; import { environment } from '../../environments/environment'; import { BehaviorSubject, Observable, of, throwError, timer, EMPTY } from 'rxjs'; import { AutheliaAuthService } from './authelia-auth.service'; import { map, catchError, retry, switchMap, tap, delay, timeout } from 'rxjs/operators'; import { N8nSyncData, SyncStatus, SyncError, CVAnalysisRequest, CVAnalysisResponse, QuestionBankResponse, SessionSyncRequest, SessionSyncResponse, AnalyticsRequest, AnalyticsResponse, AutheliaAuthToken, N8nApiConfig, N8nDataMapper } from '../models/n8n-sync.interface'; import { CVProfile } from '../models/cv-profile.interface'; import { QuestionBank } from '../models/question-bank.interface'; import { InterviewSession, SessionSummary, SessionAnalytics } from '../models/interview-session.interface'; import { DataSanitizer } from '../models/validation'; @Injectable({ providedIn: 'root' }) export class N8nSyncService implements N8nDataMapper { private readonly apiConfig: N8nApiConfig = { baseUrl: environment.n8nWebhookUrl.replace('/webhook/cv-analysis', ''), authToken: '', timeout: 30000, retryAttempts: 3 }; private authTokenSubject = new BehaviorSubject(null); private syncStatusSubject = new BehaviorSubject(SyncStatus.PENDING); private lastSyncSubject = new BehaviorSubject(null); private isOnlineSubject = new BehaviorSubject(navigator.onLine); public authToken$ = this.authTokenSubject.asObservable(); public syncStatus$ = this.syncStatusSubject.asObservable(); public lastSync$ = this.lastSyncSubject.asObservable(); public isOnline$ = this.isOnlineSubject.asObservable(); private pendingSyncs = new Map(); private syncQueue: string[] = []; constructor( private http: HttpClient, private autheliaAuth: AutheliaAuthService ) { this.setupNetworkStatusMonitoring(); this.initializeAuthToken(); this.startSyncQueueProcessor(); } // Authentication Methods public async authenticateWithAuthelia(username: string, password: string): Promise { try { const authEndpoint = `${this.apiConfig.baseUrl}/api/verify`; const response = await this.http.post(authEndpoint, { username, password }, { headers: new HttpHeaders({ 'Content-Type': 'application/json' }), withCredentials: true }).toPromise(); if (response && response.token) { const authToken: AutheliaAuthToken = { token: response.token, expiresAt: new Date(Date.now() + (response.expires_in || 3600) * 1000), refreshToken: response.refresh_token }; this.authTokenSubject.next(authToken); this.apiConfig.authToken = response.token; this.storeAuthToken(authToken); return true; } return false; } catch (error) { console.error('Authelia authentication failed:', error); return false; } } public async refreshAuthToken(): Promise { const currentToken = this.authTokenSubject.value; if (!currentToken?.refreshToken) { return false; } try { const refreshEndpoint = `${this.apiConfig.baseUrl}/api/refresh`; const response = await this.http.post(refreshEndpoint, { refresh_token: currentToken.refreshToken }, { headers: new HttpHeaders({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${currentToken.token}` }) }).toPromise(); if (response && response.token) { const newAuthToken: AutheliaAuthToken = { token: response.token, expiresAt: new Date(Date.now() + (response.expires_in || 3600) * 1000), refreshToken: response.refresh_token || currentToken.refreshToken }; this.authTokenSubject.next(newAuthToken); this.apiConfig.authToken = response.token; this.storeAuthToken(newAuthToken); return true; } return false; } catch (error) { console.error('Token refresh failed:', error); this.clearAuthToken(); return false; } } // CV Analysis Methods public submitCVForAnalysis(cvProfile: CVProfile): Observable { this.syncStatusSubject.next(SyncStatus.IN_PROGRESS); const request = this.mapCVProfileToRequest(cvProfile); const endpoint = environment.apiEndpoints.cvAnalysis; console.log('🚀 Submitting CV for analysis with N8N API key auth'); // Use direct HTTP with API key authentication return this.http.post(endpoint, request, { headers: this.getN8nApiHeaders() }).pipe( retry(this.apiConfig.retryAttempts), map(response => { // Handle empty response from N8N (when no workflow is configured) if (!response || typeof response !== 'object') { console.log('⚠️ N8N returned empty response, generating fallback'); return this.generateFallbackResponse(cvProfile); } // Handle proper N8N workflow response if (response.analysisId) { console.log('✅ CV analysis submitted:', response.analysisId); return response as CVAnalysisResponse; } // Handle unexpected response format console.log('⚠️ N8N response missing analysisId, generating fallback'); return this.generateFallbackResponse(cvProfile); }), tap(response => { this.syncStatusSubject.next(SyncStatus.SUCCESS); }), catchError(error => { console.error('❌ CV analysis failed:', error); this.syncStatusSubject.next(SyncStatus.FAILED); return throwError(() => error); }) ); } public pollAnalysisStatus(analysisId: string): Observable { const statusEndpoint = `${this.apiConfig.baseUrl}/webhook/cv-analysis/${analysisId}/status`; return timer(0, 5000).pipe( // Poll every 5 seconds switchMap(() => this.http.get(statusEndpoint, { headers: this.getN8nApiHeaders() }).pipe( catchError(error => { console.warn('Status polling error:', error); return of({ status: 'failed' } as CVAnalysisResponse); }) ) ), tap(response => { if (response.status === 'completed' || response.status === 'failed') { this.syncStatusSubject.next( response.status === 'completed' ? SyncStatus.SUCCESS : SyncStatus.FAILED ); } }) ); } public getGeneratedQuestionBank(questionBankId: string): Observable { const endpoint = `${this.apiConfig.baseUrl}/webhook/question-bank/${questionBankId}`; return this.http.get(endpoint, { headers: this.getN8nApiHeaders() }).pipe( map(response => this.mapResponseToQuestionBank(response)), retry(this.apiConfig.retryAttempts), catchError(this.handleHttpError.bind(this)) ); } // Session Synchronization Methods public syncInterviewSession(session: InterviewSession): Observable { this.syncStatusSubject.next(SyncStatus.IN_PROGRESS); const request = this.mapSessionToSyncRequest(session); const endpoint = `${this.apiConfig.baseUrl}/webhook/session-sync`; return this.http.post(endpoint, request, { headers: this.getN8nApiHeaders(), timeout: this.apiConfig.timeout }).pipe( retry(this.apiConfig.retryAttempts), tap(response => { this.syncStatusSubject.next( response.status === 'success' ? SyncStatus.SUCCESS : SyncStatus.FAILED ); this.lastSyncSubject.next(new Date()); if (response.status === 'success') { this.removePendingSync(session.id); } }), catchError(error => { console.error('❌ Session sync failed:', error); this.syncStatusSubject.next(SyncStatus.FAILED); return this.handleHttpError(error); }) ); } public syncSessionAnalytics(sessionId: string, analytics: SessionAnalytics, improvements: string[]): Observable { const request: AnalyticsRequest = { sessionId, analytics, improvements }; const endpoint = `${this.apiConfig.baseUrl}/webhook/analytics`; return this.http.post(endpoint, request, { headers: this.getN8nApiHeaders() }).pipe( retry(this.apiConfig.retryAttempts), catchError(this.handleHttpError.bind(this)) ); } // Bulk Synchronization Methods public syncAllPendingData(): Observable { if (!this.isAuthenticated() || this.pendingSyncs.size === 0) { return of(void 0); } this.syncStatusSubject.next(SyncStatus.IN_PROGRESS); const syncObservables: Observable[] = []; this.pendingSyncs.forEach(syncData => { if (syncData.dataSnapshot.sessionSummary) { // Reconstruct session from snapshot const session: InterviewSession = { id: syncData.sessionId, cvProfileId: syncData.dataSnapshot.cvProfile.id, questionBankId: '', // Will be filled from session summary startTime: new Date(), endTime: undefined, duration: 0, detectedQuestions: [], providedAnswers: [], questionsAnswered: [], manualComments: [], speechHints: [], analytics: syncData.dataSnapshot.analytics, status: 'completed' as any }; syncObservables.push(this.syncInterviewSession(session)); } }); if (syncObservables.length === 0) { this.syncStatusSubject.next(SyncStatus.SUCCESS); return of(void 0); } return new Observable(observer => { Promise.all(syncObservables.map(obs => obs.toPromise())) .then(() => { this.syncStatusSubject.next(SyncStatus.SUCCESS); observer.next(); observer.complete(); }) .catch(error => { this.syncStatusSubject.next(SyncStatus.FAILED); observer.error(error); }); }); } // Data Mapping Methods (N8nDataMapper implementation) public mapCVProfileToRequest(profile: CVProfile): CVAnalysisRequest { return { cvProfileId: profile.id, personalInfo: profile.personalInfo, experience: profile.experience, education: profile.education, skills: profile.skills, certifications: profile.certifications, parsedText: profile.extractedText || '' }; } public mapResponseToQuestionBank(response: QuestionBankResponse): QuestionBank { // Convert the response questions to our internal format const mappedQuestions = response.questions.map(q => ({ ...q, category: q.category as any, // Type assertion for enum conversion difficulty: q.difficulty as any, // Type assertion for enum conversion })); return { id: response.questionBankId, cvProfileId: response.cvProfileId, generatedDate: new Date(response.generatedDate), lastUsed: new Date(), questions: mappedQuestions, accuracy: 0.9, // Default accuracy metadata: { ...response.metadata, categoriesDistribution: response.metadata.categoriesDistribution || {} } }; } public mapSessionToSyncRequest(session: InterviewSession): SessionSyncRequest { return { sessionId: session.id, cvProfileId: session.cvProfileId, startTime: session.startTime.toISOString(), endTime: session.endTime?.toISOString(), detectedQuestions: session.detectedQuestions, providedAnswers: session.providedAnswers, manualComments: session.manualComments, analytics: session.analytics }; } public mapSyncResponseToSession(response: SessionSyncResponse, session: InterviewSession): InterviewSession { return { ...session, // Update questions based on server response detectedQuestions: session.detectedQuestions.map(dq => { const updatedQuestion = response.updatedQuestions.find(uq => uq.id === dq.questionBankMatch); if (updatedQuestion) { return { ...dq, confidence: Math.max(dq.confidence, updatedQuestion.confidence) }; } return dq; }) }; } // Queue Management Methods private queueSyncForLater(session: InterviewSession): Observable { const syncData: N8nSyncData = { id: DataSanitizer.generateUUID(), sessionId: session.id, lastSyncTime: new Date(), syncStatus: SyncStatus.PENDING, dataSnapshot: { cvProfile: {} as CVProfile, // Would need to get from service questionBank: {} as QuestionBank, // Would need to get from service sessionSummary: this.createSessionSummary(session), analytics: session.analytics } }; this.pendingSyncs.set(session.id, syncData); this.syncQueue.push(session.id); // Return a pending response return of({ syncId: syncData.id, status: 'partial', updatedQuestions: [], recommendations: ['Sync queued for when connection is available'], errors: [] }); } private createSessionSummary(session: InterviewSession): SessionSummary { const duration = session.endTime && session.startTime ? (session.endTime.getTime() - session.startTime.getTime()) / (1000 * 60) : 0; return { sessionId: session.id, duration, questionsAnswered: session.providedAnswers.length, successRate: session.analytics.accuracyRate, improvements: [], newQuestions: [] }; } private removePendingSync(sessionId: string): void { this.pendingSyncs.delete(sessionId); const index = this.syncQueue.indexOf(sessionId); if (index > -1) { this.syncQueue.splice(index, 1); } } private startSyncQueueProcessor(): void { // Process sync queue when online this.isOnline$.subscribe(isOnline => { if (isOnline && this.syncQueue.length > 0 && this.isAuthenticated()) { this.processSyncQueue(); } }); } private processSyncQueue(): void { if (this.syncQueue.length === 0) return; const sessionId = this.syncQueue.shift(); if (!sessionId) return; const syncData = this.pendingSyncs.get(sessionId); if (!syncData) return; // Process this sync item setTimeout(() => { if (this.isAuthenticated() && this.isOnlineSubject.value) { // Try to sync this item // This would need the actual session data console.log('Processing queued sync for session:', sessionId); } }, 1000); } // Utility Methods private isAuthenticated(): boolean { const token = this.authTokenSubject.value; return !!(token && token.expiresAt > new Date()); } private getAuthHeaders(): HttpHeaders { const token = this.authTokenSubject.value; return new HttpHeaders({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${token?.token || ''}` }); } private getN8nApiHeaders(): HttpHeaders { return new HttpHeaders({ 'Content-Type': 'application/json', 'X-N8N-API-KEY': environment.n8n.apiKey, 'Accept': 'application/json' }); } private handleHttpError(error: HttpErrorResponse): Observable { let errorMessage = 'An error occurred'; if (error.error instanceof ErrorEvent) { // Client-side error errorMessage = `Error: ${error.error.message}`; } else { // Server-side error switch (error.status) { case 401: errorMessage = 'Authentication failed'; this.clearAuthToken(); break; case 403: errorMessage = 'Access forbidden'; break; case 404: errorMessage = 'Service not found'; break; case 500: errorMessage = 'Server error'; break; case 503: errorMessage = 'Service unavailable'; break; default: errorMessage = `Error ${error.status}: ${error.message}`; } } console.error('N8n API Error:', errorMessage, error); return throwError(errorMessage); } private setupNetworkStatusMonitoring(): void { window.addEventListener('online', () => { this.isOnlineSubject.next(true); console.log('Network connection restored'); }); window.addEventListener('offline', () => { this.isOnlineSubject.next(false); console.log('Network connection lost'); }); } private initializeAuthToken(): void { const storedToken = this.getStoredAuthToken(); if (storedToken && storedToken.expiresAt > new Date()) { this.authTokenSubject.next(storedToken); this.apiConfig.authToken = storedToken.token; } } private storeAuthToken(token: AutheliaAuthToken): void { try { localStorage.setItem('n8n_auth_token', JSON.stringify({ ...token, expiresAt: token.expiresAt.toISOString() })); } catch (error) { console.error('Failed to store auth token:', error); } } private getStoredAuthToken(): AutheliaAuthToken | null { try { const stored = localStorage.getItem('n8n_auth_token'); if (stored) { const parsed = JSON.parse(stored); return { ...parsed, expiresAt: new Date(parsed.expiresAt) }; } } catch (error) { console.error('Failed to retrieve stored auth token:', error); } return null; } private clearAuthToken(): void { this.authTokenSubject.next(null); this.apiConfig.authToken = ''; localStorage.removeItem('n8n_auth_token'); } private generateFallbackResponse(cvProfile: CVProfile): CVAnalysisResponse { const analysisId = `fallback_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const questionBankId = `qb_fallback_${Date.now()}`; console.log('🔄 Generating fallback response for CV analysis'); return { status: 'completed', analysisId: analysisId, questionBankId: questionBankId, questionsGenerated: 3, candidateName: cvProfile.personalInfo?.fullName || 'Candidate', questions: this.generateFallbackQuestions(cvProfile), metadata: { skillsAnalyzed: cvProfile.skills?.length || 0, experienceYears: Math.max(...(cvProfile.skills?.map(s => s.yearsOfExperience || 0) || [0])), processingTime: new Date().toISOString(), fallbackMode: true, message: 'Questions generated locally (N8N workflow not configured)' } }; } private generateFallbackQuestions(cvProfile: CVProfile): any[] { const skills = cvProfile.skills || []; const experience = cvProfile.experience || []; const technicalSkills = skills.filter(s => s.category === 'technical').map(s => s.name); const primarySkill = technicalSkills[0] || skills[0]?.name || 'your main technology'; return [ { id: 1, question: `Tell me about your experience with ${primarySkill} and how you've applied it in recent projects.`, category: 'technical', difficulty: 'medium', expectedSkills: technicalSkills.slice(0, 2), reasoning: 'Technical assessment based on CV skills', generatedAt: new Date().toISOString() }, { id: 2, question: 'Describe a challenging project you worked on and how you overcame the main obstacles.', category: 'behavioral', difficulty: 'medium', expectedSkills: ['Problem Solving', 'Communication', 'Critical Thinking'], reasoning: 'Behavioral assessment of problem-solving abilities', generatedAt: new Date().toISOString() }, { id: 3, question: experience.length > 0 ? `I see you worked at ${experience[0].company}. What was the most valuable thing you learned there?` : 'What motivates you in your professional career and where do you see yourself in 5 years?', category: experience.length > 0 ? 'experience' : 'career', difficulty: 'easy', expectedSkills: ['Self-awareness', 'Communication', 'Growth Mindset'], reasoning: experience.length > 0 ? 'Experience-specific question' : 'Career development assessment', generatedAt: new Date().toISOString() } ]; } // Health Check Methods public checkN8nHealth(): Observable { const healthEndpoint = `${this.apiConfig.baseUrl}/healthz`; return this.http.get(healthEndpoint, { timeout: 5000 }).pipe( map(() => true), catchError(() => of(false)) ); } public getApiStatus(): Observable { if (!this.isAuthenticated()) { return of({ authenticated: false, online: this.isOnlineSubject.value }); } const statusEndpoint = `${this.apiConfig.baseUrl}/api/status`; return this.http.get(statusEndpoint, { headers: this.getAuthHeaders() }).pipe( map(response => ({ authenticated: true, online: this.isOnlineSubject.value, api: response })), catchError(() => of({ authenticated: false, online: this.isOnlineSubject.value, error: 'Failed to get API status' })) ); } // Cleanup public destroy(): void { // Clear any ongoing timers or subscriptions this.pendingSyncs.clear(); this.syncQueue.length = 0; } }