Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -836,3 +836,32 @@ GitHub Actions workflow (`.github/workflows/ci.yml`) triggers on push/PR to `mai
## API Documentation

Swagger UI is available at `http://localhost:8080/swagger-ui.html` when the app is running.


### Completed interview counts

`interviewCount` belongs to each application and stores the confirmed number of completed interviews.
Status changes and `interviewScheduled` do not increment it. Existing totals are preserved; notes and
legacy status events are not interpreted as proof of attendance. Scheduled, cancelled or missed
interviews and asynchronous technical tests must not be included.

Set an absolute total through the authenticated API:

```http
PATCH /api/v1/applications/{id}
Content-Type: application/json

{"interviewCount": 2}
```

The MCP `Patch-Application` tool accepts the same optional `interviewCount` parameter. It must be a
non-negative integer. Omit it to keep the total, or set it to zero to correct it. Repeating the same
total does not add another interview. Updating this number does not award interview XP; gamification
remains independent of the business count. A status can be patched together with the total without
adding an implicit increment.

The React form asks whether an interview was actually completed when saving a change to an interview
funnel status, including moving between interview stages. Yes adds one to the form total; No saves
without adding; closing the popup cancels saving. The total can also be corrected manually in the form.
Dashboard and MCP analytics sum stored completed counts. Analytics `interviewRate` is the percentage
of applications with at least one completed interview, rather than interviews divided by applications.
44 changes: 44 additions & 0 deletions docs/superpowers/plans/2026-10-23-interview-confirmation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Interview Confirmation Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.

**Goal:** Contar apenas entrevistas confirmadas e expor ajuste da contagem no MCP.
**Architecture:** Preservar contagem absoluta por candidatura; status gera somente histórico. React confirma incremento no salvamento. Analytics usa contagem armazenada.
**Tech Stack:** Spring Boot, Java 21, React, TypeScript, JUnit, Playwright.
**Spec:** docs/superpowers/specs/2026-10-23-interview-confirmation-design.md

## Global Constraints
Preservar dados legados, não conceder XP ao ajustar contagem, validar inteiro não negativo, manter autorização por proprietário.

## Review Focus
- Reenvio do mesmo total não incrementa novamente.
- Status junto com total explícito não soma implicitamente.
- Fechar popup não salva; não confirma mantém contagem.
- Erro de rede e nova tentativa não somam novamente.
- Múltiplas entrevistas não fazem taxa ultrapassar 100%.

### Task 1: Backend e MCP
Files: InterviewMetricsService.java, McpApplicationTools.java, McpAnalyticsTools.java e testes correspondentes.
Interfaces: PATCH /api/v1/applications/{id} com interviewCount; Patch-Application com Integer interviewCount opcional antes de archived.
- [x] Escrever testes de transição preservando contagem e analytics com 2+2+1 entrevistas em 3 candidaturas.
- [x] Executar e observar falhas; remover incremento implícito, expor campo e corrigir analytics.
- [x] Cobrir validação, omissão, reenvio e propriedade pelo REST; executar testes.

### Task 2: Confirmação na interface
Files: React src/pages/applications/ApplicationForm.tsx, src/lib/interviews.ts, tests/interview-confirmation.spec.ts.
Interfaces: requiresInterviewConfirmation(previousStatus, nextStatus) => boolean.
- [x] Testar mudança para etapa, mesma etapa, rascunho e transição entre etapas.
- [x] Adicionar diálogo no envio com Sim/Não e cancelamento, usando total absoluto.
- [x] Testar fluxo via Playwright e executar build/lint.

### Task 3: Verificação
- [x] Documentar uso REST/MCP e comportamento de confirmação.
- [x] Executar suíte backend e checks frontend; revisar diff e registrar limitações.


## Verification results
- Targeted final backend run: 66 tests passed, including ApplicationControllerIT and isolated McpAuthIT.
- Full backend run: 434 tests, 0 assertion failures, 4 setup errors in McpAuthIT due to WebAuthn challenge foreign keys left by prior classes. McpAuthIT passes in isolation; its existing cleanup does not remove passkey challenges.
- Browser suite: 86/87 passed initially; the failed retry test incorrectly selected the seeded current status. Corrected that scenario and verified all nine interview confirmation cases, including two added during review. The new-application case needed the exact required-field accessible label in its test selector.
- Frontend production build, ESLint, existing Node tests, and git diff whitespace checks passed.
- Static independent review found no blocking issues; review-requested count-only, create/replace, zero and negative cases were added.
11 changes: 11 additions & 0 deletions docs/superpowers/specs/2026-10-23-interview-confirmation-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Contagem confirmada de entrevistas — issue #99

Escopo definido pelo usuário: manter a contagem por candidatura; perguntar na interface se a entrevista foi realizada antes de incrementar por mudança de status; permitir alterar a contagem via API e MCP.

A contagem absoluta `interviewCount` existente passa a ser a fonte de verdade. Transições continuam registradas em `interview_events`, mas não alteram o total. PUT/POST/PATCH aceitam totais explícitos; omissão preserva o total. Patch-Application expõe o mesmo campo opcional, com validação de inteiro não negativo. Reenvios do mesmo total são idempotentes e não concedem XP adicional por contagem.

A interface pergunta ao salvar uma mudança para um status do conjunto que antes incrementava automaticamente, inclusive mudanças entre etapas desse conjunto. Sim soma um ao total do formulário; não mantém o total. Fechar o popup cancela o salvamento. Repetir o salvamento após erro deve reutilizar o total absoluto. Agendamento isolado não dispara confirmação. A pergunta esclarece que testes assíncronos não contam.

Analytics soma entrevistas realizadas, mantendo a taxa como proporção de candidaturas com ao menos uma entrevista (máximo 100%). Preservar dados legados sem migração ou inferência de notas. Não criar registros de entrevista individuais: o usuário escolheu ajuste da contagem em vez da modelagem proposta anteriormente.

Alternativas: manter incremento automático com flag de confirmação exigiria novos contratos em todos os comandos; usar contagem absoluta já existente evita incremento duplicado e preserva o contrato REST.
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.UUID;

@Configuration
Expand Down Expand Up @@ -264,16 +265,22 @@ public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer(UserReposito
return;
}

var principal = context.getPrincipal();
if (principal == null) {
throw new IllegalStateException("An access token requires an authenticated principal");
}

LinkedHashSet<String> roles = new LinkedHashSet<>();
roles.add("ROLE_GPT_CLIENT");
context.getPrincipal().getAuthorities().stream()
principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.filter(Objects::nonNull)
.filter(authority -> authority.startsWith("ROLE_"))
.filter(authority -> !"ROLE_USER".equals(authority))
.forEach(roles::add);

context.getClaims().claim("roles", roles);
userRepository.findByEmail(context.getPrincipal().getName())
userRepository.findByEmail(principal.getName())
.ifPresent(user -> context.getClaims().claim("user_id", user.getId().toString()));
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public RegisteredClient findById(String id) {

@Override
public RegisteredClient findByClientId(String clientId) {
if (clientId != null && clientId.startsWith("https://")) {
if (clientId.startsWith("https://")) {
return resolveCimdClient(clientId);
}
return delegate.findByClientId(clientId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public OAuthAuthorizationServerMetadataController(
produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> authorizationServerMetadata(HttpServletRequest request) {
String issuer = settings.getIssuer();
if (issuer == null || issuer.isBlank()) {
throw new IllegalStateException("OAuth issuer must be configured for discovery metadata");
}

Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("issuer", issuer);
Expand Down Expand Up @@ -73,6 +76,6 @@ public Map<String, Object> authorizationServerMetadata(HttpServletRequest reques

private String registrationPath() {
String path = settings.getOidcClientRegistrationEndpoint();
return (path != null && !path.isBlank()) ? path : "/connect/register";
return (!path.isBlank()) ? path : "/connect/register";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public OAuthProtectedResourceMetadataController(
produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> protectedResourceMetadata(HttpServletRequest request) {
String issuer = authorizationServerSettings.getIssuer();
if (issuer == null || issuer.isBlank()) {
throw new IllegalStateException("OAuth issuer must be configured for discovery metadata");
}
String requestPath = request.getRequestURI();

// Derive the resource path suffix after the well-known prefix.
Expand Down Expand Up @@ -80,7 +83,7 @@ public Map<String, Object> protectedResourceMetadata(HttpServletRequest request)
// "/connect/register") rather than hardcoded, so it tracks any issuer/path reconfiguration.
private String registrationEndpoint(String issuer) {
String path = authorizationServerSettings.getOidcClientRegistrationEndpoint();
if (path == null || path.isBlank()) {
if (path.isBlank()) {
path = "/connect/register";
}
return issuer + path;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ public void setInterviewCount(Integer interviewCount) {
if (interviewCount == null) {
return;
}
if (interviewCount < 0) {
throw new com.jobtracker.exception.BadRequestException("interviewCount must be zero or greater");
}
this.interviewCount = interviewCount;
providedFields.add("interviewCount");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public record DashboardSummaryResponse(
long waitingResponses,
@Schema(description = "Applications with an interview scheduled", example = "3")
long interviewsScheduled,
@Schema(description = "Cumulative count of interview status entries", example = "7")
@Schema(description = "Total confirmed completed interviews across active applications", example = "7")
long interviewCount,
@Schema(description = "Applications with overdue follow-up dates", example = "2")
long overdueFollowUps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ - the record is a duplicate, test, invalid, or obsolete entry;
Restoration preserves the current status and all other data.
- Use `Patch-Application` for any partial change. It updates only the fields you send, so never resend unrelated
fields or boolean flags to satisfy a schema.
- Interview counts belong to each application. Changing status or scheduling an interview does not increment them.
- After confirming attendance with the user, use `Patch-Application` with `interviewCount` set to the absolute
total of completed interviews (integer >= 0). Omit it to preserve the count. Repeating a total is idempotent.
- Scheduled, cancelled or missed interviews and asynchronous technical tests are not completed interviews.
- Do not infer counts from free-text notes or use gamification events to record interviews.
- Permanent deletion is allowed only when the user explicitly requests permanent deletion.
- Do not delete and recreate an application to change its archive state without explicit user approval.

Expand Down
8 changes: 5 additions & 3 deletions src/main/java/com/jobtracker/mcp/tools/McpAnalyticsTools.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ public McpAnalyticsTools(ApplicationRepository applicationRepository,
@McpTool(
name = "Get-Analytics",
title = "Get Analytics",
description = "Returns aggregate analytics for the user's job applications: totals, rates, status and platform breakdown, average days to response.",
description = "Returns aggregate analytics for the user's job applications: totals, rates, status and platform breakdown, average days to response. "
+ "interviewCount is the sum of completed interviews; interviewRate is the percentage of applications with at least one completed interview.",
annotations = @McpAnnotations(
title = "Get Analytics",
readOnlyHint = true,
Expand Down Expand Up @@ -80,13 +81,14 @@ public AnalyticsResponse getAnalytics(
}

int total = apps.size();
int interviewCount = (int) apps.stream().filter(JobApplication::isInterviewScheduled).count();
int interviewCount = apps.stream().mapToInt(JobApplication::getInterviewCount).sum();
long applicationsWithInterviews = apps.stream().filter(a -> a.getInterviewCount() > 0).count();
int rejectionCount = (int) apps.stream().filter(a ->
"REJEITADO".equals(a.getStatus()) || "Rejected".equals(a.getStatus())).count();
int ghostingCount = (int) apps.stream().filter(a ->
"GHOSTING".equals(a.getStatus()) || "Ghosting".equals(a.getStatus())).count();

double interviewRate = total > 0 ? Math.round(interviewCount * 1000.0 / total) / 10.0 : 0.0;
double interviewRate = total > 0 ? Math.round(applicationsWithInterviews * 1000.0 / total) / 10.0 : 0.0;
double rejectionRate = total > 0 ? Math.round(rejectionCount * 1000.0 / total) / 10.0 : 0.0;
double ghostingRate = total > 0 ? Math.round(ghostingCount * 1000.0 / total) / 10.0 : 0.0;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ public ApplicationResponse updateApplication(
+ "Prefer this tool over Update-Application: never resend unrelated fields or boolean flags just to satisfy a schema. "
+ "Set archived to false to restore an archived application to the active list, or to true to archive it. "
+ "Restoring and archiving preserve the current status, and changing status never archives or restores a record. "
+ "Set interviewCount to the absolute number of completed interviews for this application (zero or greater), after user confirmation. "
+ "Status changes do not increment this count. Scheduled interviews and asynchronous tests do not count. "
+ "IMPORTANT: Call List-Statuses first whenever you send status. Never use a status value from memory.",
annotations = @McpAnnotations(
title = "Patch Application",
Expand All @@ -243,6 +245,7 @@ public ApplicationResponse patchApplication(
@McpToolParam(required = false, description = "Whether a DM reminder to the recruiter is enabled") Boolean recruiterDmReminderEnabled,
@McpToolParam(required = false, description = "Personal notes about this application") String note,
@McpToolParam(required = false, description = "Platform or job board where the vacancy was found, e.g. LinkedIn, Gupy, Indeed, Catho") String platform,
@McpToolParam(required = false, description = "Absolute number of completed interviews for this application, integer >= 0. Omit to preserve. Repeating the same total is idempotent; this does not award XP.") Integer interviewCount,
@McpToolParam(required = false, description = "true to archive the application, false to restore it to the active list. Independent of status.") Boolean archived) {
ApplicationPatchRequest request = new ApplicationPatchRequest();
if (vacancyName != null) request.setVacancyName(vacancyName);
Expand All @@ -257,6 +260,7 @@ public ApplicationResponse patchApplication(
if (recruiterDmReminderEnabled != null) request.setRecruiterDmReminderEnabled(recruiterDmReminderEnabled);
if (note != null) request.setNote(note);
if (platform != null) request.setPlatform(platform);
if (interviewCount != null) request.setInterviewCount(interviewCount);
if (archived != null) request.setArchived(archived);
return applicationService.patch(UUID.fromString(id), request);
}
Expand Down
19 changes: 10 additions & 9 deletions src/main/java/com/jobtracker/service/GoogleDriveService.java
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ public GoogleDriveBaseResumeResponse addBaseResume(GoogleDriveBaseResumeRequest
resume.setDocumentName(file.name());
resume.setWebViewLink(resolveDocumentLink(file));
resume.setLanguage(request.language());
resume.setTemplate(isReadOnly ? false : request.template());
resume.setTemplate(!isReadOnly && request.template());
resume.setReadOnly(isReadOnly);
try {
GoogleDriveBaseResume saved = baseResumeRepository.save(resume);
Expand Down Expand Up @@ -286,13 +286,13 @@ private BaseResumeResponse toBaseResumeListingResponse(GoogleDriveBaseResume res

private String buildVacancyFolderName(JobApplication application) {
String suffix = " - APP-" + application.getId().toString();
String rawBase = firstNonBlank(application.getVacancyName(), application.getOrganization(), "Application");
String rawBase = applicationDisplayName(application);
String truncatedBase = truncateFileName(sanitizeFileName(rawBase), 180 - suffix.length());
return truncatedBase + suffix;
}

private String buildCopiedDocumentName(JobApplication application, String baseResumeName) {
String vacancyName = firstNonBlank(application.getVacancyName(), application.getOrganization(), "Application");
String vacancyName = applicationDisplayName(application);
String prefix = "APP-" + application.getId() + " - " + vacancyName;
return truncateFileName(sanitizeFileName(prefix + " - " + baseResumeName), 220);
}
Expand All @@ -305,13 +305,14 @@ private String truncateFileName(String value, int maxLength) {
return value.length() <= maxLength ? value : value.substring(0, maxLength).trim();
}

private String firstNonBlank(String... values) {
for (String value : values) {
if (StringUtils.hasText(value)) {
return value.trim();
}
private String applicationDisplayName(JobApplication application) {
if (StringUtils.hasText(application.getVacancyName())) {
return application.getVacancyName().trim();
}
if (StringUtils.hasText(application.getOrganization())) {
return application.getOrganization().trim();
}
return null;
return "Application";
}

private String resolveDocumentLink(GoogleDriveApiClient.DriveFileMetadata file) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ public void recordStatusTransition(JobApplication application, String oldStatus,
return;
}

application.setInterviewCount(application.getInterviewCount() + 1);
// Status is funnel history, not evidence that an interview was completed.
// The confirmed absolute count is supplied explicitly by the caller.

InterviewEvent event = new InterviewEvent();
event.setUser(application.getUser());
Expand Down
Loading
Loading