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
37 changes: 35 additions & 2 deletions app/components/CandidateDetailSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import {
X, User, Calendar, Clock, Hash, MessageSquare, FileText,
ExternalLink, Mail, Phone, Upload, Download, Eye, Trash2,
ArrowLeft, AlertTriangle, Brain, History,
ArrowLeft, AlertTriangle, Brain, History, RefreshCw,
} from 'lucide-vue-next'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

Expand Down Expand Up @@ -176,6 +176,7 @@ const isUploading = ref(false)
const uploadError = ref<string | null>(null)
const showDocDeleteConfirm = ref<string | null>(null)
const isDeletingDoc = ref(false)
const reparsingDocId = ref<string | null>(null)

const showPreview = ref(false)
const previewUrl = ref<string | null>(null)
Expand Down Expand Up @@ -216,6 +217,26 @@ async function handleFileSelected(event: Event) {
}
}

async function handleReparse(docId: string) {
reparsingDocId.value = docId
try {
await $fetch(`/api/documents/${docId}/parse`, {
method: 'POST',
headers: useRequestHeaders(['cookie']),
})
toast.add({ title: 'Resume parsed successfully', type: 'success' })
await refreshCandidate()
} catch (err: any) {
toast.add({
title: 'Parse failed',
message: err?.data?.statusMessage ?? 'Could not extract text from this document.',
type: 'error',
})
} finally {
reparsingDocId.value = null
}
}

async function handlePreview(docId: string, mimeType?: string) {
// Only PDFs can be previewed inline — for DOC/DOCX, download directly
if (mimeType && mimeType !== 'application/pdf') {
Expand Down Expand Up @@ -920,11 +941,23 @@ function formatInterviewDate(dateStr: string) {
<span class="text-xs text-surface-400">
{{ documentTypeLabels[doc.type] ?? doc.type }}
· {{ new Date(doc.createdAt).toLocaleDateString() }}
<template v-if="doc.mimeType === 'application/pdf'"> · <span class="text-brand-500 dark:text-brand-400">Click to preview</span></template>
<template v-if="doc.parsed === false">
· <span class="text-warning-500 dark:text-warning-400">Text extraction failed</span>
</template>
<template v-else-if="doc.mimeType === 'application/pdf'"> · <span class="text-brand-500 dark:text-brand-400">Click to preview</span></template>
</span>
</div>
</div>
<div class="flex items-center gap-1 shrink-0" @click.stop>
<button
v-if="doc.parsed === false"
:disabled="reparsingDocId === doc.id"
class="rounded-lg p-1.5 text-warning-500 hover:text-brand-600 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors disabled:opacity-50"
title="Retry text extraction"
@click="handleReparse(doc.id)"
>
<RefreshCw class="size-4" :class="{ 'animate-spin': reparsingDocId === doc.id }" />
</button>
<button
v-if="doc.mimeType === 'application/pdf'"
class="rounded-lg p-1.5 text-surface-400 hover:text-brand-600 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
Expand Down
43 changes: 41 additions & 2 deletions app/components/ScoreBreakdown.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Brain, Sparkles, AlertTriangle, ChevronDown, ChevronUp, Loader2, BarChart3 } from 'lucide-vue-next'
import { Brain, Sparkles, AlertTriangle, ChevronDown, ChevronUp, Loader2, BarChart3, RefreshCw } from 'lucide-vue-next'

const props = defineProps<{
applicationId: string
Expand All @@ -12,6 +12,8 @@ const emit = defineEmits<{
const { track } = useTrack()
const isAnalyzing = ref(false)
const analyzeError = ref<string | null>(null)
const parseFailedDocId = ref<string | null>(null)
const isRetryingParse = ref(false)
const expandedCriterion = ref<string | null>(null)

const { data: scoreData, status, refresh } = useFetch(
Expand Down Expand Up @@ -66,6 +68,7 @@ function toggleCriterion(key: string) {
async function runAnalysis() {
isAnalyzing.value = true
analyzeError.value = null
parseFailedDocId.value = null
try {
await $fetch(`/api/applications/${props.applicationId}/analyze`, {
method: 'POST',
Expand All @@ -75,11 +78,35 @@ async function runAnalysis() {
track('ai_analysis_run', { application_id: props.applicationId })
emit('scored')
} catch (err: any) {
const data = err?.data?.data
if (data?.code === 'PARSE_FAILED' && data?.documentId) {
parseFailedDocId.value = data.documentId
}
analyzeError.value = err?.data?.statusMessage ?? 'Analysis failed. Make sure AI is configured in settings.'
} finally {
isAnalyzing.value = false
}
}

async function retryParse() {
if (!parseFailedDocId.value) return
isRetryingParse.value = true
analyzeError.value = null
try {
await $fetch(`/api/documents/${parseFailedDocId.value}/parse`, {
method: 'POST',
headers: useRequestHeaders(['cookie']),
})
parseFailedDocId.value = null
// Automatically re-run analysis after successful parse
await runAnalysis()
} catch (err: any) {
analyzeError.value = err?.data?.statusMessage ?? 'Failed to re-parse the resume. The file may be corrupted or image-based.'
parseFailedDocId.value = null
} finally {
isRetryingParse.value = false
}
}
</script>

<template>
Expand Down Expand Up @@ -249,7 +276,19 @@ async function runAnalysis() {
<AlertTriangle class="size-4 shrink-0 mt-0.5" />
<div>
{{ analyzeError }}
<button class="ml-1 underline" @click="analyzeError = null">Dismiss</button>
<div class="mt-2 flex items-center gap-2">
<button
v-if="parseFailedDocId"
:disabled="isRetryingParse"
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-white bg-brand-600 rounded-md hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
@click="retryParse"
>
<Loader2 v-if="isRetryingParse" class="size-3 animate-spin" />
<RefreshCw v-else class="size-3" />
{{ isRetryingParse ? 'Re-parsing…' : 'Retry CV Parse' }}
</button>
<button class="underline" @click="analyzeError = null; parseFailedDocId = null">Dismiss</button>
</div>
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/pages/auth/sign-in.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ onMounted(() => track('signin_page_viewed'))

if (route.query.live === '1') {
email.value = config.public.liveDemoEmail
password.value = config.public.liveDemoSecret
password.value = config.public.liveDemoPasscode
}

async function handleSignIn() {
Expand Down
2 changes: 1 addition & 1 deletion app/pages/dashboard/jobs/new.vue
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ const questionActionError = ref<string | null>(null)
const nextQuestionId = ref(1)

// Check if AI provider is configured
const { data: aiConfigData } = useFetch('/api/ai-config', { key: 'ai-config-check' })
const { data: aiConfigData } = useFetch('/api/ai-config', { key: 'ai-config-check', headers: useRequestHeaders(['cookie']) })
const isAiConfigured = computed(() => {
return aiConfigData.value && aiConfigData.value.provider && aiConfigData.value.hasApiKey
})
Expand Down
58 changes: 56 additions & 2 deletions app/pages/dashboard/settings/ai.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import {
Brain, Save, AlertTriangle, ExternalLink, Loader2, Check,
Eye, EyeOff, Shield, DollarSign,
Eye, EyeOff, Shield, DollarSign, Zap,
} from 'lucide-vue-next'

definePageMeta({})
Expand Down Expand Up @@ -48,6 +48,8 @@ const form = ref({

const showApiKey = ref(false)
const isSaving = ref(false)
const isTesting = ref(false)
const testResult = ref<{ success: boolean; message?: string } | null>(null)

// Seed form from existing config
watch(currentConfig, (config) => {
Expand Down Expand Up @@ -116,6 +118,7 @@ async function handleSave() {

toast.success('AI configuration saved', 'Your provider and model settings have been updated.')
form.value.apiKey = '' // Clear after save
testResult.value = null
await refreshConfig()
} catch (err: any) {
const statusMessage = err?.data?.statusMessage ?? err?.message ?? 'An unexpected error occurred while saving.'
Expand All @@ -128,6 +131,28 @@ async function handleSave() {
isSaving.value = false
}
}

async function testConnection() {
isTesting.value = true
testResult.value = null

try {
await $fetch('/api/ai-config/test-connection', {
method: 'POST',
headers: useRequestHeaders(['cookie']),
})
testResult.value = { success: true }
toast.success('Connection successful', 'Your AI provider responded correctly.')
}
catch (err: any) {
const message = err?.data?.statusMessage ?? err?.message ?? 'Connection test failed.'
testResult.value = { success: false, message }
toast.error('Connection test failed', { message })
}
finally {
isTesting.value = false
}
}
</script>

<template>
Expand Down Expand Up @@ -258,7 +283,7 @@ async function handleSave() {
</p>
</div>

<!-- Save button & feedback -->
<!-- Save button & Test connection -->
<div class="flex items-center gap-3 pt-2">
<button
type="submit"
Expand All @@ -269,6 +294,35 @@ async function handleSave() {
<Save v-else class="size-4" />
{{ isSaving ? 'Saving…' : 'Save configuration' }}
</button>
<button
v-if="hasExistingKey"
type="button"
:disabled="isTesting"
class="inline-flex items-center gap-2 rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-4 py-2 text-sm font-medium text-surface-700 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="testConnection"
>
<Loader2 v-if="isTesting" class="size-4 animate-spin" />
<Zap v-else class="size-4" />
{{ isTesting ? 'Testing…' : 'Test connection' }}
</button>
</div>

<!-- Test result -->
<div v-if="testResult" class="mt-3">
<div
v-if="testResult.success"
class="flex items-center gap-2 text-xs text-success-600 dark:text-success-400 bg-success-50 dark:bg-success-950/40 border border-success-200 dark:border-success-900 rounded-lg px-3 py-2"
>
<Check class="size-3.5" />
AI provider is connected and responding.
</div>
<div
v-else
class="flex items-start gap-2 text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900 rounded-lg px-3 py-2"
>
<AlertTriangle class="size-3.5 shrink-0 mt-0.5" />
<span>{{ testResult.message }}</span>
</div>
</div>
</div>
</section>
Expand Down
4 changes: 2 additions & 2 deletions app/pages/onboarding/create-org.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ useSeoMeta({
robots: 'noindex, nofollow',
})

const { orgs, isOrgsLoading, switchOrg, createOrg } = useCurrentOrg()
const { orgs, isOrgsLoading, switchOrg, createOrg, activeOrg } = useCurrentOrg()
const { acceptInviteLink } = useInviteLinks()
const localePath = useLocalePath()
const { track } = useTrack()
Expand All @@ -38,7 +38,7 @@ const autoSwitched = ref(false)

watch([orgs, isOrgsLoading], async ([orgList, loading]) => {
if (loading || autoSwitched.value || viewMode.value !== 'picker') return
if (orgList.length === 1) {
if (orgList.length === 1 && !activeOrg.value) {
Comment on lines 39 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Auto-switch can run before active-org state is fully settled

On Line 39, the watcher only reacts to orgs and isOrgsLoading. If activeOrg is still unset during the immediate run, Line 41 can still pass and trigger switchOrg, even for users who already have an active org once auth state finishes hydrating.

Suggested hardening
- watch([orgs, isOrgsLoading], async ([orgList, loading]) => {
-   if (loading || autoSwitched.value || viewMode.value !== 'picker') return
-   if (orgList.length === 1 && !activeOrg.value) {
+ watch([orgs, isOrgsLoading, activeOrg], async ([orgList, loading, currentActiveOrg]) => {
+   if (loading || autoSwitched.value || viewMode.value !== 'picker') return
+   if (orgList.length === 1 && !currentActiveOrg) {
      const firstOrg = orgList[0]
      if (!firstOrg) return
      autoSwitched.value = true
      isLoading.value = true
      try {
        await switchOrg(firstOrg.id)
      }
      catch {
        isLoading.value = false
        autoSwitched.value = false
      }
    }
  }, { immediate: true })

If your composable exposes an explicit “active org loading/resolved” flag, include it in this guard as well.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
watch([orgs, isOrgsLoading], async ([orgList, loading]) => {
if (loading || autoSwitched.value || viewMode.value !== 'picker') return
if (orgList.length === 1) {
if (orgList.length === 1 && !activeOrg.value) {
watch([orgs, isOrgsLoading, activeOrg], async ([orgList, loading, currentActiveOrg]) => {
if (loading || autoSwitched.value || viewMode.value !== 'picker') return
if (orgList.length === 1 && !currentActiveOrg) {
const firstOrg = orgList[0]
if (!firstOrg) return
autoSwitched.value = true
isLoading.value = true
try {
await switchOrg(firstOrg.id)
}
catch {
isLoading.value = false
autoSwitched.value = false
}
}
}, { immediate: true })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/onboarding/create-org.vue` around lines 39 - 41, The watcher on
orgs/isOrgsLoading can fire before activeOrg is settled causing premature
auto-switch; update the guard in the watch([orgs, isOrgsLoading], ...) callback
to also check the active-org resolution flag (or the composable's "activeOrg
loading/resolved" state) before deciding to run switchOrg — i.e., include the
activeOrg-loading/resolved symbol (or a provided isActiveOrgResolved/isHydrated
flag) alongside orgs, isOrgsLoading, autoSwitched, and viewMode in the
conditional so switchOrg only runs when activeOrg is fully settled.

const firstOrg = orgList[0]
if (!firstOrg) return

Expand Down
2 changes: 1 addition & 1 deletion e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function generateTestAccount(workerId: number): TestAccount {
return {
name: `E2E Tester ${id}`,
email: `e2e-${id}@test.local`,
password: 'TestPassword123!',
password: process.env.E2E_TEST_PASSWORD || 'TestPassword123!',
orgName: `E2E Org ${id}`,
orgSlug: `e2e-org-${id}`,
}
Expand Down
4 changes: 2 additions & 2 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ export default defineNuxtConfig({
}
return email
})(),
/** Public live-demo secret used to prefill sign-in */
liveDemoSecret:
/** Public live-demo passcode used to prefill sign-in */
liveDemoPasscode:
process.env.LIVE_DEMO_SECRET
|| process.env.DEMO_PASSWORD
|| 'demo1234',
Expand Down
Loading
Loading