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
13 changes: 12 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,20 @@ S3_FORCE_PATH_STYLE=true
# leave unset/false to guarantee NO automatic deletion. Set to true only after
# you have reviewed your retention policy and want the sweep to run.
GDPR_CLEANUP_ENABLED=false
# Optional: authenticates external schedulers calling /api/admin/retention-cleanup.
# Optional: authenticates external schedulers calling /api/admin/retention-cleanup
# and /api/admin/notification-dispatch.
CRON_SECRET=

# ─── Recruiter notifications ────────────────────────────────────────────────
# Email notifications to recruiters (new applicant, candidate reply, interview response
# finished) via a durable outbox drained by a scheduled worker. Fail-safe
# default = true (activation driver, safe to send). Set to false to pause all
# notification sending + digests instance-wide.
NOTIFICATIONS_ENABLED=true
# Delivery status + hard-bounce/complaint suppression reuse the existing
# RESEND_WEBHOOK_SECRET below. External schedulers can trigger the worker at
# /api/admin/notification-dispatch using CRON_SECRET (as above).

# ─── SEO ─────────────────────────────────────────────────────────────────────
# Used by @nuxtjs/seo for sitemaps, canonical URLs, and OG tags
NUXT_PUBLIC_SITE_URL=http://localhost:3000
Expand Down
60 changes: 57 additions & 3 deletions app/components/ApplicationDetailDrawer.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { X, ExternalLink, User, Briefcase, Calendar, Clock, Hash, FileText, MessageSquare } from 'lucide-vue-next'
import { X, ExternalLink, User, Briefcase, Calendar, Clock, Hash, FileText, MessageSquare, Trash2 } from 'lucide-vue-next'
import { APPLICATION_STATUS_TRANSITIONS } from '~~/shared/status-transitions'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

Expand All @@ -9,13 +9,14 @@ const props = defineProps<{

const emit = defineEmits<{
close: []
deleted: []
}>()

const localePath = useLocalePath()
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
const toast = useToast()

const { application, status: fetchStatus, error, refresh, updateApplication } = useApplication(() => props.applicationId)
const { application, status: fetchStatus, error, refresh, updateApplication, deleteApplication } = useApplication(() => props.applicationId)
const { formatCandidateName } = useOrgSettings()

// ─── Status transitions ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -54,6 +55,8 @@ const allowedTransitions = computed(() => {

const isTransitioning = ref(false)
const showInterviewSidebar = ref(false)
const showDeleteConfirm = ref(false)
const isDeleting = ref(false)

async function handleTransition(newStatus: string) {
isTransitioning.value = true
Expand All @@ -67,6 +70,21 @@ async function handleTransition(newStatus: string) {
}
}

async function handleDelete() {
isDeleting.value = true
try {
await deleteApplication()
emit('deleted')
}
catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
toast.error('Failed to delete application', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
}
finally {
isDeleting.value = false
}
}

// ─── Notes editing ────────────────────────────────────────────────────────────

const isEditingNotes = ref(false)
Expand Down Expand Up @@ -111,7 +129,9 @@ function formatResponseValue(value: unknown): string {
// ─── Body scroll lock + keyboard handling ─────────────────────────────────────

function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
if (e.key !== 'Escape') return
if (showDeleteConfirm.value) showDeleteConfirm.value = false
else emit('close')
}

onMounted(() => {
Expand Down Expand Up @@ -244,6 +264,13 @@ onUnmounted(() => {
<Calendar class="size-3.5" />
Schedule Interview
</button>
<button
class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-danger-300 dark:border-danger-700 bg-white/80 dark:bg-surface-900 px-3.5 py-1.5 text-sm font-medium text-danger-600 dark:text-danger-400 hover:bg-danger-50 dark:hover:bg-danger-950 transition-colors focus:outline-none focus:ring-2 focus:ring-danger-500/40"
@click="showDeleteConfirm = true"
>
<Trash2 class="size-3.5" />
Delete application
</button>
</div>
</div>

Expand Down Expand Up @@ -446,5 +473,32 @@ onUnmounted(() => {
@close="showInterviewSidebar = false"
@scheduled="showInterviewSidebar = false"
/>

<div v-if="showDeleteConfirm && application" class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/50" @click="showDeleteConfirm = false" />
<div class="relative w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-surface-900 mx-4" role="dialog" aria-modal="true" aria-labelledby="drawer-delete-application-title">
<h2 id="drawer-delete-application-title" class="mb-2 text-lg font-semibold text-surface-900 dark:text-surface-50">Delete Application</h2>
<p class="mb-4 text-sm text-surface-600 dark:text-surface-400">
Delete {{ formatCandidateName(application.candidate) }}'s application for
<strong>{{ application.job.title }}</strong>? Application responses, interviews, scores, and messages will be permanently deleted. The candidate remains in your candidate list.
</p>
<div class="flex justify-end gap-2">
<button
:disabled="isDeleting"
class="rounded-lg border border-surface-300 px-3 py-1.5 text-sm font-medium text-surface-700 hover:bg-surface-50 disabled:opacity-50 dark:border-surface-600 dark:text-surface-300 dark:hover:bg-surface-800"
@click="showDeleteConfirm = false"
>
Cancel
</button>
<button
:disabled="isDeleting"
class="rounded-lg bg-danger-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-danger-700 disabled:opacity-50"
@click="handleDelete"
>
{{ isDeleting ? 'Deleting…' : 'Delete application' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
4 changes: 2 additions & 2 deletions app/components/DemoUpsellBanner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ const collapsed = useState('demo-upsell-collapsed', () => false)

<div
v-else
class="relative overflow-hidden rounded-xl border border-surface-200 bg-white shadow-2xl backdrop-blur-xl dark:border-white/[0.08] dark:bg-gradient-to-br dark:from-surface-950 dark:via-surface-900 dark:to-brand-950/90"
class="relative overflow-hidden rounded-xl ring-1 ring-inset ring-surface-200 bg-white shadow-2xl backdrop-blur-xl dark:ring-white/[0.08] dark:bg-surface-950 dark:bg-gradient-to-br dark:from-surface-950 dark:via-surface-900 dark:to-brand-950/90"
>
<div class="h-[2px] bg-gradient-to-r from-brand-400 via-accent-400 to-brand-500" />
<div class="h-[2px] rounded-t-xl bg-gradient-to-r from-brand-400 via-accent-400 to-brand-500" />

<div class="relative p-4">
<div class="flex items-start gap-3">
Expand Down
8 changes: 7 additions & 1 deletion app/components/SettingsMobileNav.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import {
Building2, Users, UserCircle, ChevronLeft, Plug, Brain, ShieldCheck, Globe, Globe2, CreditCard,
Building2, Users, UserCircle, ChevronLeft, Plug, Brain, ShieldCheck, Globe, Globe2, CreditCard, Bell,
} from 'lucide-vue-next'

const route = useRoute()
Expand All @@ -19,6 +19,12 @@ const settingsNav = [
icon: Globe,
exact: true,
},
{
label: 'Notifications',
to: '/dashboard/settings/notifications',
icon: Bell,
exact: true,
},
{
label: 'Career Page',
to: '/dashboard/settings/career-page',
Expand Down
9 changes: 8 additions & 1 deletion app/components/SettingsSidebar.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Component } from 'vue'
import {
Building2, Users, UserCircle, ChevronLeft, Settings, Plug, Brain, ShieldCheck, Globe, Globe2, ShieldAlert, CreditCard, Lock,
Building2, Users, UserCircle, ChevronLeft, Settings, Plug, Brain, ShieldCheck, Globe, Globe2, ShieldAlert, CreditCard, Lock, Bell,
} from 'lucide-vue-next'
import type { PlanFeature } from '~~/shared/billing'

Expand Down Expand Up @@ -41,6 +41,13 @@ const settingsNav: SettingsNavItem[] = [
icon: Globe,
exact: true,
},
{
label: 'Notifications',
description: 'Email alerts & digest',
to: '/dashboard/settings/notifications',
icon: Bell,
exact: true,
},
{
label: 'Career Page',
description: 'Branded public page',
Expand Down
17 changes: 15 additions & 2 deletions app/composables/useApplication.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { MaybeRefOrGetter } from 'vue'

/**
* Composable for a single application detail with update mutation.
* Composable for a single application detail with update and delete mutations.
* Wraps `useFetch('/api/applications/:id')` with a reactive key.
*/
export function useApplication(id: MaybeRefOrGetter<string>) {
Expand Down Expand Up @@ -36,5 +36,18 @@ export function useApplication(id: MaybeRefOrGetter<string>) {
}
}

return { application, status, error, refresh, updateApplication }
/** Permanently delete the application while leaving its candidate intact. */
async function deleteApplication() {
try {
await $fetch(`/api/applications/${applicationId.value}`, { method: 'DELETE' })
}
catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
clearNuxtData(`application-${applicationId.value}`)
await refreshNuxtData('applications')
}

return { application, status, error, refresh, updateApplication, deleteApplication }
}
40 changes: 40 additions & 0 deletions app/composables/useNotificationPreferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { NotificationChannelMode, NotificationType } from '~~/shared/notifications'

export interface NotificationPreference {
type: NotificationType
channelMode: NotificationChannelMode
}

/**
* Composable for reading and updating the current user's recruiter notification
* preferences for the active organization.
*/
export async function useNotificationPreferences() {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()

const { data: preferences, status, error, refresh } = await useFetch<NotificationPreference[]>(
'/api/notification-preferences',
{
key: 'notification-preferences',
headers: useRequestHeaders(['cookie']),
default: () => [],
},
)

async function updatePreferences(updates: NotificationPreference[]) {
try {
const updated = await $fetch<NotificationPreference[]>('/api/notification-preferences', {
method: 'PATCH',
body: { preferences: updates },
})
preferences.value = updated
return updated
}
catch (err) {
handlePreviewReadOnlyError(err)
throw err
}
}

return { preferences, status, error, refresh, updatePreferences }
}
58 changes: 56 additions & 2 deletions app/pages/dashboard/applications/[id].vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ArrowLeft, User, Briefcase, Calendar, Clock, Hash, FileText, MessageSquare } from 'lucide-vue-next'
import { ArrowLeft, User, Briefcase, Calendar, Clock, Hash, FileText, MessageSquare, Trash2 } from 'lucide-vue-next'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

definePageMeta({
Expand All @@ -9,10 +9,11 @@ definePageMeta({

const route = useRoute()
const applicationId = route.params.id as string
const localePath = useLocalePath()
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
const toast = useToast()

const { application, status: fetchStatus, error, refresh, updateApplication } = useApplication(applicationId)
const { application, status: fetchStatus, error, refresh, updateApplication, deleteApplication } = useApplication(applicationId)

const { formatCandidateName } = useOrgSettings()

Expand Down Expand Up @@ -63,6 +64,8 @@ const allowedTransitions = computed(() => {

const isTransitioning = ref(false)
const showInterviewSidebar = ref(false)
const showDeleteConfirm = ref(false)
const isDeleting = ref(false)

async function handleTransition(newStatus: string) {
isTransitioning.value = true
Expand All @@ -76,6 +79,21 @@ async function handleTransition(newStatus: string) {
}
}

async function handleDelete() {
isDeleting.value = true
try {
await deleteApplication()
await navigateTo(localePath('/dashboard/applications'))
}
catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
toast.error('Failed to delete application', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
}
finally {
isDeleting.value = false
}
}

// ─────────────────────────────────────────────
// Notes editing
// ─────────────────────────────────────────────
Expand Down Expand Up @@ -204,6 +222,13 @@ function formatResponseValue(value: unknown): string {
<Calendar class="size-3.5" />
Schedule Interview
</button>
<button
class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-danger-300 dark:border-danger-700 bg-white/80 dark:bg-surface-900 px-3.5 py-1.5 text-sm font-medium text-danger-600 dark:text-danger-400 hover:bg-danger-50 dark:hover:bg-danger-950 transition-colors focus:outline-none focus:ring-2 focus:ring-danger-500/40"
@click="showDeleteConfirm = true"
>
<Trash2 class="size-3.5" />
Delete application
</button>
</div>
</div>

Expand Down Expand Up @@ -395,6 +420,35 @@ function formatResponseValue(value: unknown): string {
</template>
</div>

<Teleport to="body">
<div v-if="showDeleteConfirm && application" class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/50" @click="showDeleteConfirm = false" />
<div class="relative w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-surface-900 mx-4" role="dialog" aria-modal="true" aria-labelledby="delete-application-title">
<h2 id="delete-application-title" class="mb-2 text-lg font-semibold text-surface-900 dark:text-surface-50">Delete Application</h2>
<p class="mb-4 text-sm text-surface-600 dark:text-surface-400">
Delete {{ formatCandidateName(application.candidate) }}'s application for
<strong>{{ application.job.title }}</strong>? Application responses, interviews, scores, and messages will be permanently deleted. The candidate remains in your candidate list.
</p>
<div class="flex justify-end gap-2">
<button
:disabled="isDeleting"
class="rounded-lg border border-surface-300 px-3 py-1.5 text-sm font-medium text-surface-700 hover:bg-surface-50 disabled:opacity-50 dark:border-surface-600 dark:text-surface-300 dark:hover:bg-surface-800"
@click="showDeleteConfirm = false"
>
Cancel
</button>
<button
:disabled="isDeleting"
class="rounded-lg bg-danger-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-danger-700 disabled:opacity-50"
@click="handleDelete"
>
{{ isDeleting ? 'Deleting…' : 'Delete application' }}
</button>
</div>
</div>
</div>
</Teleport>

<!-- Interview Schedule Sidebar -->
<InterviewScheduleSidebar
v-if="showInterviewSidebar && application"
Expand Down
8 changes: 7 additions & 1 deletion app/pages/dashboard/applications/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,11 @@ function getPropertyValue(entity: { properties?: import('~~/shared/properties').

// ── Application detail drawer ─────────────────────────────────────────────────
const selectedApplicationId = ref<string | null>(null)

async function handleApplicationDeleted() {
selectedApplicationId.value = null
await refresh()
}
</script>

<template>
Expand Down Expand Up @@ -666,7 +671,7 @@ const selectedApplicationId = ref<string | null>(null)
<td class="px-4 py-3">
<button
type="button"
class="font-semibold text-surface-900 dark:text-surface-100 group-hover:text-brand-600 transition-colors whitespace-nowrap text-left"
class="font-semibold text-surface-900 dark:text-surface-100 group-hover:text-brand-600 transition-colors whitespace-nowrap text-left cursor-pointer"
@click.stop="selectedApplicationId = app.id"
>
{{ formatPersonName(app.candidateFirstName, app.candidateLastName) }}
Expand Down Expand Up @@ -777,5 +782,6 @@ const selectedApplicationId = ref<string | null>(null)
v-if="selectedApplicationId"
:application-id="selectedApplicationId"
@close="selectedApplicationId = null"
@deleted="handleApplicationDeleted"
/>
</template>
2 changes: 1 addition & 1 deletion app/pages/dashboard/candidates/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ const selectedCandidateId = ref<string | null>(null)
<td class="px-4 py-3">
<button
type="button"
class="font-semibold text-surface-900 dark:text-surface-100 group-hover:text-brand-600 transition-colors whitespace-nowrap text-left"
class="font-semibold text-surface-900 dark:text-surface-100 group-hover:text-brand-600 transition-colors whitespace-nowrap text-left cursor-pointer"
@click.stop="selectedCandidateId = c.id"
>
{{ formatCandidateName(c) }}
Expand Down
Loading
Loading