Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ NUXT_PUBLIC_SITE_URL=http://localhost:3000

# ─── Optional: Demo Mode ────────────────────
# Set to an org slug to make that org read-only (blocks mutations)
# DEMO_ORG_SLUG=demo
# Default seeded slug: applirank-demo
# DEMO_ORG_SLUG=applirank-demo

# ─── Optional: Trusted Proxy ────────────────
# IP address of the reverse proxy (Railway/Cloudflare). When set,
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com). Categories: **Add

- **Dependency security remediation** — resolved all `npm audit --audit-level=high` findings by upgrading `@aws-sdk/client-s3` (pulling patched `@aws-sdk/xml-builder`) and regenerating lockfile resolution
- **Transitive vulnerability pinning** — added npm `overrides` for `fast-xml-parser`, `minimatch`, `tar`, and `readdir-glob` to keep vulnerable transitive ranges out of the install graph
- **Demo write-protection enforcement** — hardened server demo guard so `POST`/`PATCH`/`PUT`/`DELETE` requests are consistently blocked for the configured demo organization and no longer silently fail open when demo org lookup fails
- **Dashboard preview UX** — write attempts in preview mode now trigger a dedicated upsell modal instead of only inline/API errors, while keeping action buttons visible

### Changed

- **Lockfile hygiene** — refreshed dependency graph with `npm install` + `npm dedupe` to remove stale vulnerable transitive entries
- **Demo env guidance** — `.env.example` demo slug example now matches seeded demo organization slug (`applirank-demo`) to reduce configuration drift

---

Expand Down
13 changes: 9 additions & 4 deletions app/components/ApplyCandidateModal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Search, X, UserPlus } from 'lucide-vue-next'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

const props = defineProps<{
jobId: string
Expand Down Expand Up @@ -32,6 +33,7 @@
})

const candidates = computed(() => candidateData.value?.data ?? [])
const { withPreviewReadOnly, handlePreviewReadOnlyError } = usePreviewReadOnly()

// Apply candidate
const isApplying = ref(false)
Expand All @@ -41,12 +43,15 @@
isApplying.value = true
applyError.value = ''
try {
await $fetch('/api/applications', {
method: 'POST',
body: { candidateId, jobId: props.jobId },
})
await withPreviewReadOnly(() =>
$fetch('/api/applications', {

Check failure on line 47 in app/components/ApplyCandidateModal.vue

View workflow job for this annotation

GitHub Actions / Build, typecheck, and test

Excessive stack depth comparing types 'Exclude<{ key: "/sitemap.xml"; exact: false; score: `${R}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` ? RouteSegWithoutQuery extends "" ? `${RouteRest}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWi...' and '{ score: MaxTuple<((R extends "/api/__sitemap__/urls" ? { key: "/api/__sitemap__/urls"; exact: true; score: []; catchAll: false; } : { key: "/api/__sitemap__/urls"; exact: false; score: `${R}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` ? RouteSe...'.

Check failure on line 47 in app/components/ApplyCandidateModal.vue

View workflow job for this annotation

GitHub Actions / Build, typecheck, and test

Excessive stack depth comparing types 'Exclude<{ key: "/robots.txt"; exact: false; score: `${R}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` ? RouteSegWithoutQuery extends "" ? `${RouteRest}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWit...' and '{ score: MaxTuple<((R extends "/api/__sitemap__/urls" ? { key: "/api/__sitemap__/urls"; exact: true; score: []; catchAll: false; } : { key: "/api/__sitemap__/urls"; exact: false; score: `${R}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` ? RouteSe...'.

Check failure on line 47 in app/components/ApplyCandidateModal.vue

View workflow job for this annotation

GitHub Actions / Build, typecheck, and test

Excessive stack depth comparing types 'Exclude<{ key: "/__nuxt_error"; exact: false; score: `${R}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` ? RouteSegWithoutQuery extends "" ? `${RouteRest}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegW...' and '{ score: MaxTuple<((R extends "/api/__sitemap__/urls" ? { key: "/api/__sitemap__/urls"; exact: true; score: []; catchAll: false; } : { key: "/api/__sitemap__/urls"; exact: false; score: `${R}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` ? RouteSe...'.

Check failure on line 47 in app/components/ApplyCandidateModal.vue

View workflow job for this annotation

GitHub Actions / Build, typecheck, and test

Excessive stack depth comparing types 'Exclude<R extends "/api/__sitemap__/urls" ? { key: "/api/__sitemap__/urls"; exact: true; score: []; catchAll: false; } : { key: "/api/__sitemap__/urls"; exact: false; score: `${R}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` ? RouteSegWithoutQuer...' and '{ score: MaxTuple<((R extends "/api/__sitemap__/urls" ? { key: "/api/__sitemap__/urls"; exact: true; score: []; catchAll: false; } : { key: "/api/__sitemap__/urls"; exact: false; score: `${R}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` ? RouteSe...'.
method: 'POST',
body: { candidateId, jobId: props.jobId },
}),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
emit('created')
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
applyError.value = err.data?.statusMessage ?? 'Failed to apply candidate'
} finally {
isApplying.value = false
Expand Down
13 changes: 9 additions & 4 deletions app/components/ApplyToJobModal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { X, Briefcase } from 'lucide-vue-next'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

const props = defineProps<{
candidateId: string
Expand All @@ -18,6 +19,7 @@ const { data: jobData, status: jobFetchStatus } = useFetch('/api/jobs', {
})

const jobs = computed(() => jobData.value?.data ?? [])
const { withPreviewReadOnly, handlePreviewReadOnlyError } = usePreviewReadOnly()

// Apply to job
const isApplying = ref(false)
Expand All @@ -27,12 +29,15 @@ async function applyToJob(jobId: string) {
isApplying.value = true
applyError.value = ''
try {
await $fetch('/api/applications', {
method: 'POST',
body: { candidateId: props.candidateId, jobId },
})
await withPreviewReadOnly(() =>
$fetch('/api/applications', {
method: 'POST',
body: { candidateId: props.candidateId, jobId },
}),
)
emit('created')
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
applyError.value = err.data?.statusMessage ?? 'Failed to apply to job'
} finally {
isApplying.value = false
Expand Down
28 changes: 19 additions & 9 deletions app/components/CandidateDetailSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ExternalLink, Mail, Phone, Upload, Download, Eye, Trash2,
ArrowLeft, AlertTriangle,
} from 'lucide-vue-next'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

const props = defineProps<{
applicationId: string
Expand All @@ -15,6 +16,8 @@ const emit = defineEmits<{
(e: 'updated'): void
}>()

const { withPreviewReadOnly, handlePreviewReadOnlyError } = usePreviewReadOnly()

// ─────────────────────────────────────────────
// Tabs
// ─────────────────────────────────────────────
Expand Down Expand Up @@ -107,13 +110,16 @@ const isTransitioning = ref(false)
async function handleTransition(newStatus: string) {
isTransitioning.value = true
try {
await $fetch(`/api/applications/${props.applicationId}`, {
method: 'PATCH',
body: { status: newStatus },
})
await withPreviewReadOnly(() =>
$fetch(`/api/applications/${props.applicationId}`, {
method: 'PATCH',
body: { status: newStatus },
}),
)
await refresh()
emit('updated')
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
alert(err.data?.statusMessage ?? 'Failed to update status')
} finally {
isTransitioning.value = false
Expand All @@ -136,14 +142,17 @@ function startEditNotes() {
async function saveNotes() {
isSavingNotes.value = true
try {
await $fetch(`/api/applications/${props.applicationId}`, {
method: 'PATCH',
body: { notes: notesInput.value || null },
})
await withPreviewReadOnly(() =>
$fetch(`/api/applications/${props.applicationId}`, {
method: 'PATCH',
body: { notes: notesInput.value || null },
}),
)
await refresh()
emit('updated')
isEditingNotes.value = false
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
alert(err.data?.statusMessage ?? 'Failed to save notes')
} finally {
isSavingNotes.value = false
Expand Down Expand Up @@ -243,10 +252,11 @@ async function handleDeleteDoc(docId: string) {
if (!candidateId.value) return
isDeletingDoc.value = true
try {
await deleteDocument(docId, candidateId.value)
await withPreviewReadOnly(() => deleteDocument(docId, candidateId.value!))
await refreshCandidate()
showDocDeleteConfirm.value = null
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
alert(err.data?.statusMessage ?? 'Failed to delete document')
} finally {
isDeletingDoc.value = false
Expand Down
66 changes: 66 additions & 0 deletions app/components/PreviewUpsellModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { Crown, X, Rocket } from 'lucide-vue-next'

const emit = defineEmits<{
(e: 'close'): void
}>()

const { message } = usePreviewReadOnly()

function closeModal() {
emit('close')
}
</script>

<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="closeModal" />

<div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
<div class="flex items-center gap-2">
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
</div>

<button
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
@click="closeModal"
>
<X class="size-5" />
</button>
</div>

<div class="space-y-4 px-5 py-5">
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ message }}
</p>

<p class="text-sm text-surface-500 dark:text-surface-400">
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
</p>

<div class="flex flex-wrap items-center gap-2">
<a
href="mailto:sales@applirank.com?subject=Applirank%20Hosted%20Plan"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
>
<Rocket class="size-4" />
Contact sales
</a>

<a
href="https://github.com/applirank/applirank"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
>
Deploy your own
</a>
</div>
</div>
</div>
</div>
Comment on lines +18 to +67
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

Non-navigable modal — missing ARIA roles, keyboard handling, and focus management

The modal is inaccessible to keyboard and screen-reader users. The following gaps all need addressing:

Gap Fix
No role="dialog" / aria-modal="true" on the container Add to the inner div (line 20)
No aria-labelledby linking the title Give <h3> an id, reference it
No Escape key listener @keydown.esc.window="closeModal"
No focus trap Tab cycles outside the modal
Close button has no accessible name Add aria-label="Close dialog"
Focus not moved into modal on open, not restored on close Programmatic focus management via templateRef + watch/onMounted

WAI-ARIA Authoring Practices require: closing on ESC, toggling ARIA attributes, and trapping/restoring focus. A modal dialog should have both role="dialog" and aria-modal="true" so screen readers treat it as a modal.

♿ Proposed minimal fix
+<script setup lang="ts">
 import { Crown, X, Rocket } from 'lucide-vue-next'
+import { onMounted, useTemplateRef } from 'vue'

 const emit = defineEmits<{
   (e: 'close'): void
 }>()

 const { message } = usePreviewReadOnly()
+const dialogRef = useTemplateRef<HTMLDivElement>('dialog')

 function closeModal() {
   emit('close')
 }
+
+function onKeydown(e: KeyboardEvent) {
+  if (e.key === 'Escape') closeModal()
+}
+
+onMounted(() => {
+  dialogRef.value?.focus()
+})
+</script>

 <template>
   <Teleport to="body">
-    <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
+    <div class="fixed inset-0 z-50 flex items-center justify-center p-4" `@keydown`="onKeydown">
       <div class="absolute inset-0 bg-black/50" `@click`="closeModal" />

-      <div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
+      <div
+        ref="dialog"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="upsell-title"
+        tabindex="-1"
+        class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900 focus:outline-none"
+      >
         <div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
           <div class="flex items-center gap-2">
             <Crown class="size-5 text-brand-600 dark:text-brand-400" />
-            <h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
+            <h3 id="upsell-title" class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
           </div>

           <button
+            aria-label="Close dialog"
             class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
             `@click`="closeModal"
           >
📝 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
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="closeModal" />
<div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
<div class="flex items-center gap-2">
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
</div>
<button
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
@click="closeModal"
>
<X class="size-5" />
</button>
</div>
<div class="space-y-4 px-5 py-5">
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ message }}
</p>
<p class="text-sm text-surface-500 dark:text-surface-400">
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
</p>
<div class="flex flex-wrap items-center gap-2">
<a
href="mailto:sales@applirank.com?subject=Applirank%20Hosted%20Plan"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
>
<Rocket class="size-4" />
Contact sales
</a>
<a
href="https://github.com/applirank/applirank"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
>
Deploy your own
</a>
</div>
</div>
</div>
</div>
<script setup lang="ts">
import { Crown, X, Rocket } from 'lucide-vue-next'
import { onMounted, useTemplateRef } from 'vue'
const emit = defineEmits<{
(e: 'close'): void
}>()
const { message } = usePreviewReadOnly()
const dialogRef = useTemplateRef<HTMLDivElement>('dialog')
function closeModal() {
emit('close')
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeModal()
}
onMounted(() => {
dialogRef.value?.focus()
})
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center p-4" `@keydown`="onKeydown">
<div class="absolute inset-0 bg-black/50" `@click`="closeModal" />
<div
ref="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="upsell-title"
tabindex="-1"
class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900 focus:outline-none"
>
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
<div class="flex items-center gap-2">
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
<h3 id="upsell-title" class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
</div>
<button
aria-label="Close dialog"
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
`@click`="closeModal"
>
<X class="size-5" />
</button>
</div>
<div class="space-y-4 px-5 py-5">
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ message }}
</p>
<p class="text-sm text-surface-500 dark:text-surface-400">
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
</p>
<div class="flex flex-wrap items-center gap-2">
<a
href="mailto:sales@applirank.com?subject=Applirank%20Hosted%20Plan"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
>
<Rocket class="size-4" />
Contact sales
</a>
<a
href="https://github.com/applirank/applirank"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
>
Deploy your own
</a>
</div>
</div>
</div>
</div>
</Teleport>
</template>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/PreviewUpsellModal.vue` around lines 17 - 64, The modal lacks
ARIA roles, keyboard handling, and focus management: add role="dialog" and
aria-modal="true" to the inner modal container element, give the <h3> a unique
id and reference it via aria-labelledby on that container, add
`@keydown.esc.window`="closeModal" to capture Escape, add aria-label="Close
dialog" to the close <button>, and implement focus management by adding a
template ref (e.g., modalRef) and code that, when the modal opens, saves
document.activeElement, moves focus into the first focusable element inside the
modal, traps Tab/Shift+Tab inside the modal, and restores focus on close using
the existing closeModal method; you can implement the trap with a small utility
in setup() or use a11y helper lib, and wire the watch/onMounted hooks to run
this behavior when the modal becomes visible.

</Teleport>
</template>
11 changes: 7 additions & 4 deletions app/composables/useApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { MaybeRefOrGetter } from 'vue'
* Wraps `useFetch('/api/applications/:id')` with a reactive key.
*/
export function useApplication(id: MaybeRefOrGetter<string>) {
const { withPreviewReadOnly } = usePreviewReadOnly()
const applicationId = computed(() => toValue(id))

const { data: application, status, error, refresh } = useFetch(
Expand All @@ -21,10 +22,12 @@ export function useApplication(id: MaybeRefOrGetter<string>) {
notes: string | null
score: number | null
}>) {
const updated = await $fetch(`/api/applications/${applicationId.value}`, {
method: 'PATCH',
body: payload,
})
const updated = await withPreviewReadOnly(() =>
$fetch(`/api/applications/${applicationId.value}`, {
method: 'PATCH',
body: payload,
}),
)
await refresh()
await refreshNuxtData('applications')
return updated
Expand Down
20 changes: 14 additions & 6 deletions app/composables/useApplications.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Ref } from 'vue'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

/**
* Composable for managing the applications list with filtering, pagination, and mutations.
Expand All @@ -9,6 +10,8 @@ export function useApplications(options?: {
candidateId?: Ref<string | undefined> | string
status?: Ref<string | undefined> | string
}) {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()

const query = computed(() => ({
...(toValue(options?.jobId) && { jobId: toValue(options?.jobId) }),
...(toValue(options?.candidateId) && { candidateId: toValue(options?.candidateId) }),
Expand All @@ -30,12 +33,17 @@ export function useApplications(options?: {
jobId: string
notes?: string
}) {
const created = await $fetch('/api/applications', {
method: 'POST',
body: payload,
})
await refresh()
return created
try {
const created = await $fetch('/api/applications', {
method: 'POST',
body: payload,
})
await refresh()
return created
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
}

return {
Expand Down
28 changes: 20 additions & 8 deletions app/composables/useCandidate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { MaybeRefOrGetter } from 'vue'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

/**
* Composable for a single candidate detail with update and delete mutations.
* Wraps `useFetch('/api/candidates/:id')` with a reactive key.
*/
export function useCandidate(id: MaybeRefOrGetter<string>) {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
const candidateId = computed(() => toValue(id))

const { data: candidate, status, error, refresh } = useFetch(
Expand All @@ -22,18 +24,28 @@ export function useCandidate(id: MaybeRefOrGetter<string>) {
email: string
phone: string | null
}>) {
const updated = await $fetch(`/api/candidates/${candidateId.value}`, {
method: 'PATCH',
body: payload,
})
await refresh()
await refreshNuxtData('candidates')
return updated
try {
const updated = await $fetch(`/api/candidates/${candidateId.value}`, {
method: 'PATCH',
body: payload,
})
await refresh()
await refreshNuxtData('candidates')
return updated
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
}

/** Delete this candidate and navigate back to the list */
async function deleteCandidate() {
await $fetch(`/api/candidates/${candidateId.value}`, { method: 'DELETE' })
try {
await $fetch(`/api/candidates/${candidateId.value}`, { method: 'DELETE' })
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
await refreshNuxtData('candidates')
await navigateTo('/dashboard/candidates')
}
Expand Down
27 changes: 20 additions & 7 deletions app/composables/useCandidates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Ref } from 'vue'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

/**
* Composable for managing the candidates list with search, pagination, and mutations.
Expand All @@ -7,6 +8,8 @@ import type { Ref } from 'vue'
export function useCandidates(options?: {
search?: Ref<string | undefined> | string
}) {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()

const query = computed(() => ({
...(toValue(options?.search) && { search: toValue(options?.search) }),
}))
Expand All @@ -27,17 +30,27 @@ export function useCandidates(options?: {
email: string
phone?: string
}) {
const created = await $fetch('/api/candidates', {
method: 'POST',
body: payload,
})
await refresh()
return created
try {
const created = await $fetch('/api/candidates', {
method: 'POST',
body: payload,
})
await refresh()
return created
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
}

/** Delete a candidate by ID and refresh the list */
async function deleteCandidate(id: string) {
await $fetch(`/api/candidates/${id}`, { method: 'DELETE' })
try {
await $fetch(`/api/candidates/${id}`, { method: 'DELETE' })
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
await refresh()
}

Expand Down
Loading
Loading