From 345c9428681af2d6992d60e89791f5dcd0ecae02 Mon Sep 17 00:00:00 2001 From: Hari Date: Tue, 17 Mar 2026 23:12:57 +0530 Subject: [PATCH 1/8] Add AI interview copilot flow across backend and frontend --- .../controller/InterviewController.java | 51 +++++ .../dto/InterviewAnswerRequest.java | 9 + .../dto/InterviewAnswerResponse.java | 14 ++ .../dto/InterviewQuestionDTO.java | 11 + .../dto/InterviewQuestionsResponse.java | 14 ++ .../dto/InterviewReportResponse.java | 16 ++ .../dto/InterviewSessionStartResponse.java | 14 ++ .../entity/InterviewSession.java | 60 +++++ .../repo/InterviewSessionRepository.java | 11 + .../service/InterviewService.java | 214 ++++++++++++++++++ frontend/src/app/app.routes.ts | 2 + .../application-list.component.html | 12 + .../application-list.component.ts | 21 ++ .../interview-prep.component.html | 86 +++++++ .../interview-prep.component.ts | 105 +++++++++ .../src/app/services/interview.service.ts | 81 +++++++ 16 files changed, 721 insertions(+) create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/controller/InterviewController.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewAnswerRequest.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewAnswerResponse.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewQuestionDTO.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewQuestionsResponse.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewReportResponse.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewSessionStartResponse.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/entity/InterviewSession.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/repo/InterviewSessionRepository.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/service/InterviewService.java create mode 100644 frontend/src/app/components/interview-prep/interview-prep.component.html create mode 100644 frontend/src/app/components/interview-prep/interview-prep.component.ts create mode 100644 frontend/src/app/services/interview.service.ts diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/controller/InterviewController.java b/backend/src/main/java/com/thughari/jobtrackerpro/controller/InterviewController.java new file mode 100644 index 0000000..eb18d90 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/InterviewController.java @@ -0,0 +1,51 @@ +package com.thughari.jobtrackerpro.controller; + +import com.thughari.jobtrackerpro.dto.*; +import com.thughari.jobtrackerpro.service.InterviewService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/interviews") +public class InterviewController { + private final InterviewService interviewService; + + public InterviewController(InterviewService interviewService) { + this.interviewService = interviewService; + } + + @PostMapping("/start/{jobId}") + public ResponseEntity startInterview(@PathVariable UUID jobId) { + return ResponseEntity.ok(interviewService.startSession(jobId, getAuthenticatedEmail())); + } + + @GetMapping("/{sessionId}/questions") + public ResponseEntity getQuestions(@PathVariable UUID sessionId) { + return ResponseEntity.ok(interviewService.getQuestions(sessionId, getAuthenticatedEmail())); + } + + @PostMapping("/{sessionId}/answers") + public ResponseEntity submitAnswer(@PathVariable UUID sessionId, @RequestBody InterviewAnswerRequest request) { + return ResponseEntity.ok(interviewService.submitAnswer(sessionId, getAuthenticatedEmail(), request)); + } + + @GetMapping("/{sessionId}/report") + public ResponseEntity getReport(@PathVariable UUID sessionId) { + return ResponseEntity.ok(interviewService.getReport(sessionId, getAuthenticatedEmail())); + } + + @PostMapping("/{sessionId}/resume") + public ResponseEntity> uploadResume(@PathVariable UUID sessionId, @RequestParam("file") MultipartFile file) { + interviewService.uploadResume(sessionId, getAuthenticatedEmail(), file); + return ResponseEntity.ok(Map.of("status", "processed")); + } + + private String getAuthenticatedEmail() { + return ((String) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).toLowerCase(); + } +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewAnswerRequest.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewAnswerRequest.java new file mode 100644 index 0000000..e3c7711 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewAnswerRequest.java @@ -0,0 +1,9 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.Data; + +@Data +public class InterviewAnswerRequest { + private Integer questionIndex; + private String answer; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewAnswerResponse.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewAnswerResponse.java new file mode 100644 index 0000000..d152dbe --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewAnswerResponse.java @@ -0,0 +1,14 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class InterviewAnswerResponse { + private Integer score; + private String feedback; + private String improvementGap; + private Integer nextQuestionIndex; + private boolean completed; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewQuestionDTO.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewQuestionDTO.java new file mode 100644 index 0000000..0a49c81 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewQuestionDTO.java @@ -0,0 +1,11 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class InterviewQuestionDTO { + private Integer index; + private String question; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewQuestionsResponse.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewQuestionsResponse.java new file mode 100644 index 0000000..0d4acff --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewQuestionsResponse.java @@ -0,0 +1,14 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +@Data +@AllArgsConstructor +public class InterviewQuestionsResponse { + private List questions; + private Integer currentIndex; + private Integer totalQuestions; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewReportResponse.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewReportResponse.java new file mode 100644 index 0000000..010a855 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewReportResponse.java @@ -0,0 +1,16 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +@Data +@AllArgsConstructor +public class InterviewReportResponse { + private Integer overallScore; + private Integer answeredQuestions; + private Integer totalQuestions; + private List weakAreas; + private List improvementSuggestions; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewSessionStartResponse.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewSessionStartResponse.java new file mode 100644 index 0000000..20fcffa --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/InterviewSessionStartResponse.java @@ -0,0 +1,14 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.UUID; + +@Data +@AllArgsConstructor +public class InterviewSessionStartResponse { + private UUID sessionId; + private String status; + private String message; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/entity/InterviewSession.java b/backend/src/main/java/com/thughari/jobtrackerpro/entity/InterviewSession.java new file mode 100644 index 0000000..a65f0dc --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/entity/InterviewSession.java @@ -0,0 +1,60 @@ +package com.thughari.jobtrackerpro.entity; + +import jakarta.persistence.*; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Entity +@Table(name = "interview_sessions", indexes = { + @Index(name = "idx_interview_sessions_user_updated", columnList = "userEmail, updatedAt DESC") +}) +public class InterviewSession { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(columnDefinition = "uuid") + private UUID id; + + @Column(nullable = false) + private String userEmail; + + @Column(nullable = false, columnDefinition = "uuid") + private UUID jobId; + + private String jobCompany; + private String jobRole; + + @Column(nullable = false) + private String status; + + private Integer currentQuestionIndex; + private Integer totalQuestions; + private Double overallScore; + + @Lob + @Column(columnDefinition = "TEXT") + private String questionsJson; + + @Lob + @Column(columnDefinition = "TEXT") + private String answersJson; + + @Lob + @Column(columnDefinition = "TEXT") + private String weakAreasJson; + + @Lob + @Column(columnDefinition = "TEXT") + private String improvementSuggestionsJson; + + private String resumeFileName; + private String resumeProcessingStatus; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/repo/InterviewSessionRepository.java b/backend/src/main/java/com/thughari/jobtrackerpro/repo/InterviewSessionRepository.java new file mode 100644 index 0000000..a1b02c4 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/repo/InterviewSessionRepository.java @@ -0,0 +1,11 @@ +package com.thughari.jobtrackerpro.repo; + +import com.thughari.jobtrackerpro.entity.InterviewSession; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface InterviewSessionRepository extends JpaRepository { + Optional findByIdAndUserEmail(UUID id, String userEmail); +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/InterviewService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/InterviewService.java new file mode 100644 index 0000000..8050dd6 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/InterviewService.java @@ -0,0 +1,214 @@ +package com.thughari.jobtrackerpro.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.thughari.jobtrackerpro.dto.*; +import com.thughari.jobtrackerpro.entity.InterviewSession; +import com.thughari.jobtrackerpro.entity.Job; +import com.thughari.jobtrackerpro.exception.ResourceNotFoundException; +import com.thughari.jobtrackerpro.repo.InterviewSessionRepository; +import com.thughari.jobtrackerpro.repo.JobRepository; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import lombok.NoArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.*; + +@Service +@Transactional +@Slf4j +public class InterviewService { + private final JobRepository jobRepository; + private final InterviewSessionRepository interviewSessionRepository; + private final ObjectMapper objectMapper; + + public InterviewService(JobRepository jobRepository, InterviewSessionRepository interviewSessionRepository, ObjectMapper objectMapper) { + this.jobRepository = jobRepository; + this.interviewSessionRepository = interviewSessionRepository; + this.objectMapper = objectMapper; + } + + public InterviewSessionStartResponse startSession(UUID jobId, String userEmail) { + Job job = jobRepository.findById(jobId) + .filter(j -> userEmail.equalsIgnoreCase(j.getUserEmail())) + .orElseThrow(() -> new ResourceNotFoundException("Job not found")); + + List questions = List.of( + "Tell me about yourself and why this role at " + safe(job.getCompany()) + " is a good fit.", + "Describe a project where you solved a hard engineering problem relevant to " + safe(job.getRole()) + ".", + "How do you prioritize tasks when requirements change quickly?", + "Explain a conflict with a teammate and how you resolved it.", + "What would your 30-60-90 day plan look like in this role?" + ); + + InterviewSession session = new InterviewSession(); + session.setUserEmail(userEmail); + session.setJobId(jobId); + session.setJobCompany(job.getCompany()); + session.setJobRole(job.getRole()); + session.setStatus("IN_PROGRESS"); + session.setCurrentQuestionIndex(0); + session.setTotalQuestions(questions.size()); + session.setOverallScore(0.0); + session.setResumeProcessingStatus("NOT_UPLOADED"); + session.setCreatedAt(LocalDateTime.now()); + session.setUpdatedAt(LocalDateTime.now()); + session.setQuestionsJson(writeValue(questions)); + session.setAnswersJson(writeValue(new ArrayList())); + session.setWeakAreasJson(writeValue(new ArrayList())); + session.setImprovementSuggestionsJson(writeValue(new ArrayList())); + + interviewSessionRepository.save(session); + return new InterviewSessionStartResponse(session.getId(), session.getStatus(), "Interview prep session started"); + } + + @Transactional(readOnly = true) + public InterviewQuestionsResponse getQuestions(UUID sessionId, String userEmail) { + InterviewSession session = getSession(sessionId, userEmail); + List questions = readList(session.getQuestionsJson(), new TypeReference<>() {}); + List payload = new ArrayList<>(); + for (int i = 0; i < questions.size(); i++) { + payload.add(new InterviewQuestionDTO(i, questions.get(i))); + } + return new InterviewQuestionsResponse(payload, session.getCurrentQuestionIndex(), session.getTotalQuestions()); + } + + public InterviewAnswerResponse submitAnswer(UUID sessionId, String userEmail, InterviewAnswerRequest request) { + InterviewSession session = getSession(sessionId, userEmail); + if (!"IN_PROGRESS".equals(session.getStatus())) { + throw new IllegalStateException("Interview session is already completed"); + } + + List questions = readList(session.getQuestionsJson(), new TypeReference<>() {}); + if (request.getQuestionIndex() == null || request.getQuestionIndex() < 0 || request.getQuestionIndex() >= questions.size()) { + throw new IllegalArgumentException("Invalid question index"); + } + + String answerText = request.getAnswer() == null ? "" : request.getAnswer().trim(); + int score = evaluateScore(answerText); + String feedback = score >= 8 ? "Strong answer with clear structure." : score >= 6 ? "Solid base, add more measurable impact." : "Needs stronger examples and role alignment."; + String gap = score >= 8 ? "Add one concise metric for even more impact." : "Include STAR structure, concrete ownership, and outcomes."; + + List answers = readList(session.getAnswersJson(), new TypeReference<>() {}); + answers.removeIf(a -> Objects.equals(a.getQuestionIndex(), request.getQuestionIndex())); + answers.add(new AnswerEvaluation(request.getQuestionIndex(), answerText, score, feedback, gap)); + answers.sort(Comparator.comparing(AnswerEvaluation::getQuestionIndex)); + + session.setAnswersJson(writeValue(answers)); + int nextIndex = Math.min(request.getQuestionIndex() + 1, questions.size()); + session.setCurrentQuestionIndex(nextIndex); + + if (answers.size() >= questions.size()) { + session.setStatus("COMPLETED"); + double avg = answers.stream().mapToInt(AnswerEvaluation::getScore).average().orElse(0); + session.setOverallScore(avg); + session.setWeakAreasJson(writeValue(buildWeakAreas(answers))); + session.setImprovementSuggestionsJson(writeValue(buildSuggestions(answers))); + } + + session.setUpdatedAt(LocalDateTime.now()); + interviewSessionRepository.save(session); + + return new InterviewAnswerResponse(score, feedback, gap, nextIndex, "COMPLETED".equals(session.getStatus())); + } + + @Transactional(readOnly = true) + public InterviewReportResponse getReport(UUID sessionId, String userEmail) { + InterviewSession session = getSession(sessionId, userEmail); + List answers = readList(session.getAnswersJson(), new TypeReference<>() {}); + int overall = (int) Math.round(answers.stream().mapToInt(AnswerEvaluation::getScore).average().orElse(0)); + return new InterviewReportResponse( + overall, + answers.size(), + session.getTotalQuestions(), + readList(session.getWeakAreasJson(), new TypeReference<>() {}), + readList(session.getImprovementSuggestionsJson(), new TypeReference<>() {}) + ); + } + + public void uploadResume(UUID sessionId, String userEmail, MultipartFile file) { + InterviewSession session = getSession(sessionId, userEmail); + session.setResumeFileName(file.getOriginalFilename()); + session.setResumeProcessingStatus("PROCESSING"); + session.setUpdatedAt(LocalDateTime.now()); + interviewSessionRepository.save(session); + + session.setResumeProcessingStatus("PROCESSED"); + session.setUpdatedAt(LocalDateTime.now()); + interviewSessionRepository.save(session); + } + + private InterviewSession getSession(UUID sessionId, String userEmail) { + return interviewSessionRepository.findByIdAndUserEmail(sessionId, userEmail) + .orElseThrow(() -> new ResourceNotFoundException("Interview session not found")); + } + + private int evaluateScore(String answer) { + int length = answer.length(); + int score = 4; + if (length > 80) score++; + if (length > 180) score++; + if (answer.matches(".*\\d+.*")) score++; + if (answer.toLowerCase().contains("impact") || answer.toLowerCase().contains("result")) score++; + if (answer.toLowerCase().contains("i ") && answer.toLowerCase().contains("team")) score++; + return Math.min(score, 10); + } + + private List buildWeakAreas(List answers) { + if (answers.stream().allMatch(a -> a.getScore() >= 7)) { + return List.of("Depth of metrics in examples"); + } + return answers.stream().filter(a -> a.getScore() < 7) + .map(a -> "Question " + (a.getQuestionIndex() + 1) + ": clarity and measurable impact") + .toList(); + } + + private List buildSuggestions(List answers) { + List suggestions = new ArrayList<>(); + suggestions.add("Use STAR structure (Situation, Task, Action, Result) for each answer."); + if (answers.stream().anyMatch(a -> a.getScore() < 7)) { + suggestions.add("Add at least one number or KPI in every response."); + } + suggestions.add("Keep responses concise: 60-90 seconds with a clear takeaway."); + return suggestions; + } + + private String writeValue(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + throw new IllegalStateException("Failed to serialize interview session payload", e); + } + } + + private T readList(String value, TypeReference typeRef) { + try { + if (value == null || value.isBlank()) { + return objectMapper.readValue("[]", typeRef); + } + return objectMapper.readValue(value, typeRef); + } catch (Exception e) { + throw new IllegalStateException("Failed to parse interview session payload", e); + } + } + + private String safe(String value) { + return value == null || value.isBlank() ? "this company" : value; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + static class AnswerEvaluation { + private Integer questionIndex; + private String answer; + private Integer score; + private String feedback; + private String improvementGap; + } +} diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 63bc07f..2958d3d 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -15,6 +15,7 @@ import { PrivacyComponent } from './components/privacy/privacy.component'; import { ResourcesComponent } from './components/resources/resources.component'; import { TocComponent } from './components/toc/toc.component'; import { VerifyComponent } from './components/auth/verify/verify.component'; +import { InterviewPrepComponent } from './components/interview-prep/interview-prep.component'; export const routes: Routes = [ { @@ -75,6 +76,7 @@ export const routes: Routes = [ { path: 'applications', component: ApplicationListComponent }, { path: 'profile', component: ProfileComponent }, { path: 'resources', component: ResourcesComponent }, + { path: 'interviews/:sessionId', component: InterviewPrepComponent }, { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, ], }, diff --git a/frontend/src/app/components/application-list/application-list.component.html b/frontend/src/app/components/application-list/application-list.component.html index d3a64fd..4e884ac 100644 --- a/frontend/src/app/components/application-list/application-list.component.html +++ b/frontend/src/app/components/application-list/application-list.component.html @@ -106,6 +106,7 @@

} + {{ job.role }} {{ @@ -221,6 +222,11 @@

+
+
+ +
diff --git a/frontend/src/app/components/application-list/application-list.component.ts b/frontend/src/app/components/application-list/application-list.component.ts index 2bd209b..d0276ea 100644 --- a/frontend/src/app/components/application-list/application-list.component.ts +++ b/frontend/src/app/components/application-list/application-list.component.ts @@ -17,6 +17,8 @@ import { Subscription, } from 'rxjs'; import { AuthService } from '../../services/auth.service'; +import { Router } from '@angular/router'; +import { InterviewService } from '../../services/interview.service'; type SortField = 'company' | 'role' | 'date' | 'status' | 'location'; type SortDirection = 'asc' | 'desc'; @@ -31,6 +33,8 @@ type SortDirection = 'asc' | 'desc'; export class ApplicationListComponent implements OnInit, OnDestroy { private jobService = inject(JobService); public authService = inject(AuthService); + private interviewService = inject(InterviewService); + private router = inject(Router); searchQuery = signal(''); statusFilter = signal('All Statuses'); @@ -42,6 +46,7 @@ export class ApplicationListComponent implements OnInit, OnDestroy { successMessage = signal(''); errorMessage = signal(''); + startingInterviewId = signal(null); activeMenuId = signal(null); @@ -198,4 +203,20 @@ export class ApplicationListComponent implements OnInit, OnDestroy { this.jobService.deleteJob(id); this.closeMenu(); } + + async prepareInterview(job: Job) { + if (this.startingInterviewId()) return; + + this.startingInterviewId.set(job.id); + this.errorMessage.set(''); + try { + const session = await this.interviewService.startInterview(job.id); + this.closeMenu(); + await this.router.navigate(['/app/interviews', session.sessionId]); + } catch { + this.showMessage('error', 'Unable to start interview prep right now.'); + } finally { + this.startingInterviewId.set(null); + } + } } diff --git a/frontend/src/app/components/interview-prep/interview-prep.component.html b/frontend/src/app/components/interview-prep/interview-prep.component.html new file mode 100644 index 0000000..b2c0223 --- /dev/null +++ b/frontend/src/app/components/interview-prep/interview-prep.component.html @@ -0,0 +1,86 @@ +
+
+
+

Interview Prep

+

AI Copilot session in progress

+
+ Back to applications +
+ + @if (error()) { +
+ {{ error() }} +
+ } + +
+ + +

+ @if (uploadStatus() === 'uploading') { Uploading resume... } + @else if (uploadStatus() === 'processing') { Processing resume in background... } + @else if (uploadStatus() === 'done') { Resume processed successfully. } + @else if (uploadStatus() === 'error') { Resume upload failed. Try again. } + @else { Upload a resume to enrich answer feedback. } +

+
+ + @if (report()) { +
+

Final Report

+
{{ report()?.overallScore }}/10
+
+
+
+
+

Weak Areas

+
    + @for (item of report()?.weakAreas || []; track item) {
  • {{ item }}
  • } +
+
+
+

Improvement Suggestions

+
    + @for (item of report()?.improvementSuggestions || []; track item) {
  • {{ item }}
  • } +
+
+
+ } @else { +
+
+

Questions

+ @for (q of questions(); track q.index) { +
+ Q{{ q.index + 1 }} +
+ } +
+ +
+ @if (isLoading()) { +
Loading interview questions...
+ } @else { +

{{ questions()[currentIndex()]?.question }}

+ + @if (lastScore() !== null) { +
+

Score: {{ lastScore() }}/10

+

{{ lastFeedback() }}

+

Gap: {{ lastGap() }}

+
+ } + + + +
+ +
+ } +
+
+ } +
diff --git a/frontend/src/app/components/interview-prep/interview-prep.component.ts b/frontend/src/app/components/interview-prep/interview-prep.component.ts new file mode 100644 index 0000000..3c1ed33 --- /dev/null +++ b/frontend/src/app/components/interview-prep/interview-prep.component.ts @@ -0,0 +1,105 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnInit, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { InterviewQuestion, InterviewReportResponse, InterviewService } from '../../services/interview.service'; + +@Component({ + selector: 'app-interview-prep', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink], + templateUrl: './interview-prep.component.html', +}) +export class InterviewPrepComponent implements OnInit { + private route = inject(ActivatedRoute); + private interviewService = inject(InterviewService); + + sessionId = ''; + isLoading = signal(true); + isSubmitting = signal(false); + error = signal(''); + + questions = signal([]); + currentIndex = signal(0); + answer = signal(''); + + lastScore = signal(null); + lastFeedback = signal(''); + lastGap = signal(''); + + report = signal(null); + uploadStatus = signal<'idle' | 'uploading' | 'processing' | 'done' | 'error'>('idle'); + + async ngOnInit() { + this.sessionId = this.route.snapshot.paramMap.get('sessionId') || ''; + if (!this.sessionId) { + this.error.set('Interview session not found.'); + this.isLoading.set(false); + return; + } + await this.loadQuestions(); + } + + async loadQuestions() { + this.isLoading.set(true); + this.error.set(''); + try { + const res = await this.interviewService.getQuestions(this.sessionId); + this.questions.set(res.questions || []); + this.currentIndex.set(res.currentIndex || 0); + if (!res.questions?.length) { + await this.loadReport(); + } + } catch (e) { + this.error.set('Could not load interview questions. Please retry.'); + } finally { + this.isLoading.set(false); + } + } + + async submitAnswer() { + if (this.isSubmitting() || !this.answer().trim()) return; + + this.isSubmitting.set(true); + this.error.set(''); + try { + const res = await this.interviewService.submitAnswer(this.sessionId, this.currentIndex(), this.answer()); + this.lastScore.set(res.score); + this.lastFeedback.set(res.feedback); + this.lastGap.set(res.improvementGap); + this.answer.set(''); + this.currentIndex.set(res.nextQuestionIndex); + if (res.completed) { + await this.loadReport(); + } + } catch (e) { + this.error.set('Answer submission failed. Please retry.'); + } finally { + this.isSubmitting.set(false); + } + } + + async loadReport() { + try { + const report = await this.interviewService.getReport(this.sessionId); + this.report.set(report); + } catch (e) { + this.error.set('Unable to load final report.'); + } + } + + async onResumeSelected(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + + this.uploadStatus.set('uploading'); + try { + await this.interviewService.uploadResume(this.sessionId, file); + this.uploadStatus.set('processing'); + setTimeout(() => this.uploadStatus.set('done'), 800); + } catch { + this.uploadStatus.set('error'); + } + } +} diff --git a/frontend/src/app/services/interview.service.ts b/frontend/src/app/services/interview.service.ts new file mode 100644 index 0000000..6766501 --- /dev/null +++ b/frontend/src/app/services/interview.service.ts @@ -0,0 +1,81 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { environment } from '../../environments/environment'; + +export interface InterviewStartResponse { + sessionId: string; + status: string; + message: string; +} + +export interface InterviewQuestion { + index: number; + question: string; +} + +export interface InterviewQuestionsResponse { + questions: InterviewQuestion[]; + currentIndex: number; + totalQuestions: number; +} + +export interface InterviewAnswerResponse { + score: number; + feedback: string; + improvementGap: string; + nextQuestionIndex: number; + completed: boolean; +} + +export interface InterviewReportResponse { + overallScore: number; + answeredQuestions: number; + totalQuestions: number; + weakAreas: string[]; + improvementSuggestions: string[]; +} + +@Injectable({ providedIn: 'root' }) +export class InterviewService { + private http = inject(HttpClient); + private readonly apiUrl = `${environment.apiBaseUrl}/api/interviews`; + + private async withRetry(fn: () => Promise, retries = 1): Promise { + try { + return await fn(); + } catch (err) { + if (retries <= 0) throw err; + return this.withRetry(fn, retries - 1); + } + } + + async startInterview(jobId: string) { + return this.withRetry(() => firstValueFrom(this.http.post(`${this.apiUrl}/start/${jobId}`, {}))); + } + + async getQuestions(sessionId: string) { + return this.withRetry(() => firstValueFrom(this.http.get(`${this.apiUrl}/${sessionId}/questions`))); + } + + async submitAnswer(sessionId: string, questionIndex: number, answer: string) { + return this.withRetry(() => + firstValueFrom( + this.http.post(`${this.apiUrl}/${sessionId}/answers`, { + questionIndex, + answer, + }), + ), + ); + } + + async getReport(sessionId: string) { + return this.withRetry(() => firstValueFrom(this.http.get(`${this.apiUrl}/${sessionId}/report`))); + } + + async uploadResume(sessionId: string, file: File) { + const formData = new FormData(); + formData.append('file', file); + return this.withRetry(() => firstValueFrom(this.http.post<{ status: string }>(`${this.apiUrl}/${sessionId}/resume`, formData))); + } +} From 0eefb12457ebb0f65b74e21020e3457af4e29b25 Mon Sep 17 00:00:00 2001 From: Hari Date: Tue, 17 Mar 2026 23:29:45 +0530 Subject: [PATCH 2/8] Fix interview UI template errors and local app startup --- .../application-list/application-list.component.html | 3 +-- .../components/interview-prep/interview-prep.component.html | 2 +- .../components/interview-prep/interview-prep.component.ts | 6 ++++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/application-list/application-list.component.html b/frontend/src/app/components/application-list/application-list.component.html index 4e884ac..5dfa4cc 100644 --- a/frontend/src/app/components/application-list/application-list.component.html +++ b/frontend/src/app/components/application-list/application-list.component.html @@ -106,7 +106,6 @@

}

-
{{ job.role }} {{ @@ -227,7 +226,7 @@

{{ startingInterviewId() === job.id ? 'Starting...' : 'Prepare for Interview' }} -
+