diff --git a/.gitignore b/.gitignore index c4268efe..465b9996 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ blob-report/ playwright/.cache/ +*.code-workspace diff --git a/app/components/AppTopBar.vue b/app/components/AppTopBar.vue index 18eb8e22..085490bd 100644 --- a/app/components/AppTopBar.vue +++ b/app/components/AppTopBar.vue @@ -5,7 +5,7 @@ import { Sun, Moon, MessageSquarePlus, Settings, ChevronDown, Menu, X, Users, ChevronLeft, LayoutDashboard, Calendar, ArrowUpCircle, - Cloud, Server, Sparkles, Radio, + Cloud, Server, Sparkles, Radio, History, } from 'lucide-vue-next' const route = useRoute() @@ -125,6 +125,7 @@ const mainNav = [ { label: 'Candidates', to: '/dashboard/candidates', icon: Users, exact: false }, { label: 'Applications', to: '/dashboard/applications', icon: FileText, exact: false }, { label: 'Interviews', to: '/dashboard/interviews', icon: Calendar, exact: false }, + { label: 'Timeline', to: '/dashboard/timeline', icon: History, exact: true }, { label: 'Source Tracking', to: '/dashboard/source-tracking', icon: Radio, exact: true, comingSoon: true }, { label: 'AI Analysis', to: '/dashboard/ai-analysis', icon: Sparkles, exact: true }, { label: 'Settings', to: '/dashboard/settings', icon: Settings, exact: false }, diff --git a/app/components/CandidateDetailSidebar.vue b/app/components/CandidateDetailSidebar.vue index 73da7486..db6fc984 100644 --- a/app/components/CandidateDetailSidebar.vue +++ b/app/components/CandidateDetailSidebar.vue @@ -18,6 +18,7 @@ const emit = defineEmits<{ const { handlePreviewReadOnlyError } = usePreviewReadOnly() const toast = useToast() +const { track } = useTrack() // Detect if the job sub-nav bar is visible (adds 40px / 2.5rem) const route = useRoute() @@ -87,19 +88,19 @@ const transitionLabels: Record = { const transitionClasses: Record = { new: 'border border-surface-300 dark:border-surface-600 text-surface-600 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800', - screening: 'bg-info-600 text-white hover:bg-info-700', - interview: 'bg-warning-600 text-white hover:bg-warning-700', - offer: 'bg-success-600 text-white hover:bg-success-700', - hired: 'bg-success-700 text-white hover:bg-success-800', + screening: 'bg-violet-600 text-white hover:bg-violet-700', + interview: 'bg-amber-600 text-white hover:bg-amber-700', + offer: 'bg-teal-600 text-white hover:bg-teal-700', + hired: 'bg-green-700 text-white hover:bg-green-800', rejected: 'bg-danger-600 text-white hover:bg-danger-700', } const statusBadgeClasses: Record = { - new: 'bg-brand-50 text-brand-700 ring-brand-200 dark:bg-brand-950/50 dark:text-brand-300 dark:ring-brand-800', - screening: 'bg-info-50 text-info-700 ring-info-200 dark:bg-info-950/50 dark:text-info-300 dark:ring-info-800', - interview: 'bg-warning-50 text-warning-700 ring-warning-200 dark:bg-warning-950/50 dark:text-warning-300 dark:ring-warning-800', - offer: 'bg-success-50 text-success-700 ring-success-200 dark:bg-success-950/50 dark:text-success-300 dark:ring-success-800', - hired: 'bg-success-100 text-success-800 ring-success-300 dark:bg-success-900/50 dark:text-success-200 dark:ring-success-700', + new: 'bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-950/50 dark:text-blue-400 dark:ring-blue-800', + screening: 'bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-950/50 dark:text-violet-400 dark:ring-violet-800', + interview: 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-950/50 dark:text-amber-400 dark:ring-amber-800', + offer: 'bg-teal-50 text-teal-700 ring-teal-200 dark:bg-teal-950/50 dark:text-teal-400 dark:ring-teal-800', + hired: 'bg-green-50 text-green-700 ring-green-200 dark:bg-green-950/50 dark:text-green-400 dark:ring-green-800', rejected: 'bg-surface-100 text-surface-500 ring-surface-200 dark:bg-surface-800/50 dark:text-surface-400 dark:ring-surface-700', } @@ -117,6 +118,11 @@ async function handleTransition(newStatus: string) { method: 'PATCH', body: { status: newStatus }, }) + track('sidebar_status_changed', { + application_id: props.applicationId, + from_status: application.value?.status, + to_status: newStatus, + }) await refresh() emit('updated') } catch (err: any) { @@ -241,6 +247,7 @@ function closePreview() { async function handleDownload(docId: string) { try { + track('document_downloaded', { document_id: docId }) await downloadDocument(docId) } catch { toast.error('Failed to download document') diff --git a/app/components/PipelineCard.vue b/app/components/PipelineCard.vue index 476108a8..c3a97500 100644 --- a/app/components/PipelineCard.vue +++ b/app/components/PipelineCard.vue @@ -27,10 +27,10 @@ const transitionLabels: Record = { const transitionClasses: Record = { new: 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700', - screening: 'text-info-600 dark:text-info-400 hover:bg-info-50 dark:hover:bg-info-950', - interview: 'text-warning-600 dark:text-warning-400 hover:bg-warning-50 dark:hover:bg-warning-950', - offer: 'text-success-600 dark:text-success-400 hover:bg-success-50 dark:hover:bg-success-950', - hired: 'text-success-700 dark:text-success-300 hover:bg-success-100 dark:hover:bg-success-900', + screening: 'text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-950', + interview: 'text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-950', + offer: 'text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-950', + hired: 'text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900', rejected: 'text-danger-600 dark:text-danger-400 hover:bg-danger-50 dark:hover:bg-danger-950', } diff --git a/app/components/ScoreBreakdown.vue b/app/components/ScoreBreakdown.vue index 4976b57b..1304927c 100644 --- a/app/components/ScoreBreakdown.vue +++ b/app/components/ScoreBreakdown.vue @@ -9,6 +9,7 @@ const emit = defineEmits<{ (e: 'scored'): void }>() +const { track } = useTrack() const isAnalyzing = ref(false) const analyzeError = ref(null) const expandedCriterion = ref(null) @@ -71,6 +72,7 @@ async function runAnalysis() { headers: useRequestHeaders(['cookie']), }) await refresh() + track('ai_analysis_run', { application_id: props.applicationId }) emit('scored') } catch (err: any) { analyzeError.value = err?.data?.statusMessage ?? 'Analysis failed. Make sure AI is configured in settings.' diff --git a/app/components/TimelineDateLink.vue b/app/components/TimelineDateLink.vue new file mode 100644 index 00000000..f0ace1e6 --- /dev/null +++ b/app/components/TimelineDateLink.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/composables/useJobs.ts b/app/composables/useJobs.ts index b94af842..06afba72 100644 --- a/app/composables/useJobs.ts +++ b/app/composables/useJobs.ts @@ -28,6 +28,7 @@ export function useJobs(options?: { description?: string location?: string type?: 'full_time' | 'part_time' | 'contract' | 'internship' + remoteStatus?: 'remote' | 'hybrid' | 'onsite' requireResume?: boolean requireCoverLetter?: boolean autoScoreOnApply?: boolean diff --git a/app/composables/usePostHogIdentity.ts b/app/composables/usePostHogIdentity.ts index 7b8c1c81..76501b33 100644 --- a/app/composables/usePostHogIdentity.ts +++ b/app/composables/usePostHogIdentity.ts @@ -48,13 +48,23 @@ export async function usePostHogIdentity() { // Watch org AND consent for group analytics — same gating logic as above. watch( [() => activeOrgState.value?.data, hasConsented] as const, - ([org, consented]) => { + async ([org, consented]) => { if (consented) { if (org?.id) { - // Only org id and name are forwarded; slug is omitted to minimise data. - ;($posthogSetOrganization as (org: { id: string, name?: string }) => void)({ + // Fetch the current member's role to enrich group properties — useful + // for debugging permission issues without exposing personal data. + let memberRole: string | undefined + try { + const { data } = await authClient.organization.getActiveMemberRole() + memberRole = data?.role ?? undefined + } + catch { /* non-critical; role is just an enrichment property */ } + + // Only org id, name, and member role are forwarded; slug is omitted to minimise data. + ;($posthogSetOrganization as (org: { id: string, name?: string, member_role?: string }) => void)({ id: org.id, name: org.name || undefined, + member_role: memberRole, }) } else { diff --git a/app/composables/useTimeline.ts b/app/composables/useTimeline.ts new file mode 100644 index 00000000..42353e50 --- /dev/null +++ b/app/composables/useTimeline.ts @@ -0,0 +1,334 @@ +/** + * Composable for the Timeline page — fetches and manages paginated + * activity-log entries with cursor-based infinite scroll. + */ + +export interface TimelineItem { + id: string + action: string + resourceType: string + resourceId: string + metadata: Record | null + createdAt: string + actorId: string + actorName: string | null + actorEmail: string | null + actorImage: string | null + resourceName: string | null + resourceUrl: string | null + isUpcoming?: boolean + candidateId?: string + candidateName?: string + jobId?: string + jobName?: string +} + +export interface TimelineCandidateGroup { + candidateId: string + candidateName: string + candidateUrl: string | null + items: TimelineItem[] +} + +export interface TimelineSection { + type: 'job' | 'candidates' | 'team' | 'other' + label: string + jobId?: string + jobUrl?: string + directItems: TimelineItem[] + candidateGroups: TimelineCandidateGroup[] + items: TimelineItem[] +} + +export interface TimelineDayGroup { + date: string + label: string + isToday: boolean + isFuture: boolean + items: TimelineItem[] + sections: TimelineSection[] +} + +interface TimelineResponse { + items: TimelineItem[] + upcoming: TimelineItem[] + hasMore: boolean + oldestTimestamp: string | null + newestTimestamp: string | null +} + +export function useTimeline() { + const items = ref([]) + const upcoming = ref([]) + const isLoading = ref(false) + const isLoadingMore = ref(false) + const hasMore = ref(true) + const oldestTimestamp = ref(null) + const error = ref(null) + const activeFilter = ref(undefined) + + /** + * Load initial timeline data. + */ + async function loadInitial(resourceType?: string) { + isLoading.value = true + error.value = null + activeFilter.value = resourceType + + try { + const query: Record = { limit: 100 } + if (resourceType) query.resourceType = resourceType + + const result = await $fetch('/api/activity-log/timeline', { query }) + + items.value = result.items + upcoming.value = result.upcoming + hasMore.value = result.hasMore + oldestTimestamp.value = result.oldestTimestamp + } + catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to load timeline' + console.error('[Timeline] Failed to load:', err) + } + finally { + isLoading.value = false + } + } + + /** + * Load more (older) entries for infinite scroll. + */ + async function loadMore() { + if (isLoadingMore.value || !hasMore.value || !oldestTimestamp.value) return + + isLoadingMore.value = true + + try { + const query: Record = { + before: oldestTimestamp.value, + limit: 100, + } + if (activeFilter.value) query.resourceType = activeFilter.value + + const result = await $fetch('/api/activity-log/timeline', { query }) + + items.value.push(...result.items) + hasMore.value = result.hasMore + oldestTimestamp.value = result.oldestTimestamp + } + catch (err) { + console.error('[Timeline] Failed to load more:', err) + } + finally { + isLoadingMore.value = false + } + } + + /** + * Group timeline items by day, including upcoming events. + */ + const dayGroups = computed(() => { + const now = new Date() + const todayStr = formatDateKey(now) + + // Combine upcoming + past items + const allItems = [...upcoming.value, ...items.value] + + // Group by date + const groupMap = new Map() + for (const item of allItems) { + const dateKey = item.createdAt.slice(0, 10) + if (!groupMap.has(dateKey)) { + groupMap.set(dateKey, []) + } + groupMap.get(dateKey)!.push(item) + } + + // Sort dates descending (newest → oldest) but future dates first + const sortedDates = Array.from(groupMap.keys()).sort((a, b) => { + const aFuture = a > todayStr + const bFuture = b > todayStr + + // Future dates at top, sorted ascending (soonest first) + if (aFuture && bFuture) return a.localeCompare(b) + if (aFuture) return -1 + if (bFuture) return 1 + + // Past dates sorted descending (most recent first) + return b.localeCompare(a) + }) + + return sortedDates.map((date) => { + const dayItems = groupMap.get(date)!.sort((a, b) => { + // Within each day, sort by time descending + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + }) + + // Purpose-based grouping: cluster by job, then candidates, team, other + const jobClusters = new Map() + const candidateItems: TimelineItem[] = [] + const teamItems: TimelineItem[] = [] + const otherItems: TimelineItem[] = [] + + for (const item of dayItems) { + const jId = item.resourceType === 'job' ? item.resourceId : item.jobId + if (jId) { + if (!jobClusters.has(jId)) { + const jName = item.resourceType === 'job' + ? item.resourceName + : item.jobName + jobClusters.set(jId, { name: jName ?? 'Unknown job', items: [] }) + } + jobClusters.get(jId)!.items.push(item) + } else if (item.resourceType === 'candidate') { + candidateItems.push(item) + } else if (item.resourceType === 'member') { + teamItems.push(item) + } else { + otherItems.push(item) + } + } + + // Build sections with deep hierarchy: job → candidate → action type + const sections: TimelineSection[] = [] + + const sortedClusters = Array.from(jobClusters.entries()).sort((a, b) => { + const aLatest = Math.max(...a[1].items.map(i => new Date(i.createdAt).getTime())) + const bLatest = Math.max(...b[1].items.map(i => new Date(i.createdAt).getTime())) + return bLatest - aLatest + }) + for (const [jId, cluster] of sortedClusters) { + const directItems = cluster.items.filter(i => i.resourceType === 'job') + const candidateRelated = cluster.items.filter(i => i.resourceType !== 'job') + sections.push({ + type: 'job', + label: cluster.name, + jobId: jId, + jobUrl: `/dashboard/jobs/${jId}`, + directItems, + candidateGroups: buildCandidateGroups(candidateRelated), + items: cluster.items, + }) + } + + if (candidateItems.length) { + sections.push({ + type: 'candidates', + label: 'Candidates', + directItems: [], + candidateGroups: buildCandidateGroups(candidateItems), + items: candidateItems, + }) + } + if (teamItems.length) { + sections.push({ + type: 'team', + label: 'Team', + directItems: teamItems, + candidateGroups: [], + items: teamItems, + }) + } + if (otherItems.length) { + sections.push({ + type: 'other', + label: 'Other activity', + directItems: otherItems, + candidateGroups: [], + items: otherItems, + }) + } + + return { + date, + label: formatDayLabel(date, todayStr), + isToday: date === todayStr, + isFuture: date > todayStr, + items: dayItems, + sections, + } + }) + }) + + /** + * Get total event count. + */ + const totalEvents = computed(() => items.value.length + upcoming.value.length) + + return { + items, + upcoming, + dayGroups, + totalEvents, + isLoading, + isLoadingMore, + hasMore, + error, + activeFilter, + loadInitial, + loadMore, + } +} + +function buildCandidateGroups(items: TimelineItem[]): TimelineCandidateGroup[] { + const candidateMap = new Map() + + for (const item of items) { + const cId = item.candidateId ?? (item.resourceType === 'candidate' ? item.resourceId : null) + if (!cId) { + const fallback = '__uncategorized__' + if (!candidateMap.has(fallback)) { + candidateMap.set(fallback, { name: 'Other', items: [] }) + } + candidateMap.get(fallback)!.items.push(item) + continue + } + + if (!candidateMap.has(cId)) { + const cName = item.candidateName + ?? (item.resourceType === 'candidate' ? item.resourceName : null) + ?? 'Unknown candidate' + candidateMap.set(cId, { name: cName, items: [] }) + } + candidateMap.get(cId)!.items.push(item) + } + + return Array.from(candidateMap.entries()) + .sort((a, b) => { + const aLatest = Math.max(...a[1].items.map(i => new Date(i.createdAt).getTime())) + const bLatest = Math.max(...b[1].items.map(i => new Date(i.createdAt).getTime())) + return bLatest - aLatest + }) + .map(([cId, group]) => ({ + candidateId: cId, + candidateName: group.name, + candidateUrl: cId === '__uncategorized__' ? null : `/dashboard/candidates/${cId}`, + items: group.items, + })) +} + +function formatDateKey(date: Date): string { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` +} + +function formatDayLabel(dateStr: string, todayStr: string): string { + const date = new Date(dateStr + 'T00:00:00') + const today = new Date(todayStr + 'T00:00:00') + const diffDays = Math.round((date.getTime() - today.getTime()) / 86400000) + + if (diffDays === 0) return 'Today' + if (diffDays === 1) return 'Tomorrow' + if (diffDays === -1) return 'Yesterday' + if (diffDays > 1 && diffDays <= 7) return `In ${diffDays} days` + if (diffDays < -1 && diffDays >= -7) return `${Math.abs(diffDays)} days ago` + + return date.toLocaleDateString(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric', + year: date.getFullYear() !== today.getFullYear() ? 'numeric' : undefined, + }) +} diff --git a/app/composables/useTrack.ts b/app/composables/useTrack.ts index fd0ad23c..99090427 100644 --- a/app/composables/useTrack.ts +++ b/app/composables/useTrack.ts @@ -101,5 +101,21 @@ export function useTrack() { } } - return { track } + /** + * Report a caught error to PostHog's error tracking (consent-gated). + * Use for errors that are handled in catch blocks but still worth logging. + */ + function captureError(error: unknown, properties?: Record) { + if (!import.meta.client) return + const ph = getPostHog() + if (!ph || !ph.has_opted_in_capturing()) return + + ph.captureException(error instanceof Error ? error : new Error(String(error)), { + path: route.path, + viewport_width: window.innerWidth, + ...properties, + }) + } + + return { track, captureError } } diff --git a/app/pages/dashboard/applications/[id].vue b/app/pages/dashboard/applications/[id].vue index 208fded1..5f29880b 100644 --- a/app/pages/dashboard/applications/[id].vue +++ b/app/pages/dashboard/applications/[id].vue @@ -38,19 +38,19 @@ const transitionLabels: Record = { const transitionClasses: Record = { new: 'border border-surface-300 dark:border-surface-700 bg-white/80 dark:bg-surface-900 text-surface-700 dark:text-surface-300 hover:border-surface-400 dark:hover:border-surface-600 hover:bg-surface-50 dark:hover:bg-surface-800', - screening: 'bg-info-600 text-white shadow-sm shadow-info-900/20 hover:bg-info-700', - interview: 'bg-warning-600 text-white shadow-sm shadow-warning-900/20 hover:bg-warning-700', - offer: 'bg-success-600 text-white shadow-sm shadow-success-900/20 hover:bg-success-700', - hired: 'bg-success-700 text-white shadow-sm shadow-success-900/30 hover:bg-success-800', + screening: 'bg-violet-600 text-white shadow-sm shadow-violet-900/20 hover:bg-violet-700', + interview: 'bg-amber-600 text-white shadow-sm shadow-amber-900/20 hover:bg-amber-700', + offer: 'bg-teal-600 text-white shadow-sm shadow-teal-900/20 hover:bg-teal-700', + hired: 'bg-green-700 text-white shadow-sm shadow-green-900/30 hover:bg-green-800', rejected: 'bg-danger-600 text-white shadow-sm shadow-danger-900/20 hover:bg-danger-700', } const transitionDotClasses: Record = { new: 'bg-surface-400 dark:bg-surface-500', - screening: 'bg-info-200', - interview: 'bg-warning-200', - offer: 'bg-success-200', - hired: 'bg-success-100', + screening: 'bg-violet-200', + interview: 'bg-amber-200', + offer: 'bg-teal-200', + hired: 'bg-green-100', rejected: 'bg-danger-200', } @@ -105,11 +105,11 @@ async function saveNotes() { // ───────────────────────────────────────────── const statusBadgeClasses: Record = { - new: 'bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-400', - screening: 'bg-info-50 text-info-700 dark:bg-info-950 dark:text-info-400', - interview: 'bg-warning-50 text-warning-700 dark:bg-warning-950 dark:text-warning-400', - offer: 'bg-success-50 text-success-700 dark:bg-success-950 dark:text-success-400', - hired: 'bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-300', + new: 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-400', + screening: 'bg-violet-50 text-violet-700 dark:bg-violet-950 dark:text-violet-400', + interview: 'bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-400', + offer: 'bg-teal-50 text-teal-700 dark:bg-teal-950 dark:text-teal-400', + hired: 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-400', rejected: 'bg-surface-100 text-surface-500 dark:bg-surface-800 dark:text-surface-400', } @@ -171,9 +171,9 @@ function formatResponseValue(value: unknown): string { > {{ application.status }} - + Applied {{ new Date(application.createdAt).toLocaleDateString() }} - + @@ -287,7 +287,7 @@ function formatResponseValue(value: unknown): string { Applied
- {{ new Date(application.createdAt).toLocaleDateString() }} + {{ new Date(application.createdAt).toLocaleDateString() }}
@@ -296,7 +296,7 @@ function formatResponseValue(value: unknown): string { Updated
- {{ new Date(application.updatedAt).toLocaleDateString() }} + {{ new Date(application.updatedAt).toLocaleDateString() }}
diff --git a/app/pages/dashboard/applications/index.vue b/app/pages/dashboard/applications/index.vue index 623cf386..5754f8dc 100644 --- a/app/pages/dashboard/applications/index.vue +++ b/app/pages/dashboard/applications/index.vue @@ -173,20 +173,20 @@ function scoreClass(score: number) { } const statusBadgeClasses: Record = { - new: 'bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-400', - screening: 'bg-info-50 text-info-700 dark:bg-info-950 dark:text-info-400', - interview: 'bg-warning-50 text-warning-700 dark:bg-warning-950 dark:text-warning-400', - offer: 'bg-success-50 text-success-700 dark:bg-success-950 dark:text-success-400', - hired: 'bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-300', + new: 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-400', + screening: 'bg-violet-50 text-violet-700 dark:bg-violet-950 dark:text-violet-400', + interview: 'bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-400', + offer: 'bg-teal-50 text-teal-700 dark:bg-teal-950 dark:text-teal-400', + hired: 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-400', rejected: 'bg-surface-100 text-surface-500 dark:bg-surface-800 dark:text-surface-400', } const statusDotClasses: Record = { - new: 'bg-brand-500', - screening: 'bg-info-500', - interview: 'bg-warning-500', - offer: 'bg-success-500', - hired: 'bg-success-600', + new: 'bg-blue-500', + screening: 'bg-violet-500', + interview: 'bg-amber-500', + offer: 'bg-teal-500', + hired: 'bg-green-600', rejected: 'bg-surface-400 dark:bg-surface-500', } @@ -443,10 +443,10 @@ const statusLabels: Record = { - + {{ timeAgo(app.createdAt) }} - + diff --git a/app/pages/dashboard/candidates/[id].vue b/app/pages/dashboard/candidates/[id].vue index 7231d2cf..6457cec0 100644 --- a/app/pages/dashboard/candidates/[id].vue +++ b/app/pages/dashboard/candidates/[id].vue @@ -125,11 +125,11 @@ async function handleDelete() { // ───────────────────────────────────────────── const applicationStatusClasses: Record = { - new: 'bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-400', - screening: 'bg-info-50 text-info-700 dark:bg-info-950 dark:text-info-400', - interview: 'bg-warning-50 text-warning-700 dark:bg-warning-950 dark:text-warning-400', - offer: 'bg-success-50 text-success-700 dark:bg-success-950 dark:text-success-400', - hired: 'bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-300', + new: 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-400', + screening: 'bg-violet-50 text-violet-700 dark:bg-violet-950 dark:text-violet-400', + interview: 'bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-400', + offer: 'bg-teal-50 text-teal-700 dark:bg-teal-950 dark:text-teal-400', + hired: 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-400', rejected: 'bg-surface-100 text-surface-500 dark:bg-surface-800 dark:text-surface-400', } @@ -365,7 +365,7 @@ function formatFileSize(bytes: number | null | undefined): string { Created
- {{ new Date(candidate.createdAt).toLocaleDateString() }} + {{ new Date(candidate.createdAt).toLocaleDateString() }}
@@ -374,7 +374,7 @@ function formatFileSize(bytes: number | null | undefined): string { Updated
- {{ new Date(candidate.updatedAt).toLocaleDateString() }} + {{ new Date(candidate.updatedAt).toLocaleDateString() }}
@@ -439,7 +439,7 @@ function formatFileSize(bytes: number | null | undefined): string { {{ app.job.title }} - Applied {{ new Date(app.createdAt).toLocaleDateString() }} + Applied {{ new Date(app.createdAt).toLocaleDateString() }}
@@ -609,7 +609,7 @@ function formatFileSize(bytes: number | null | undefined): string {

{{ documentTypeLabels[doc.type] ?? doc.type }} - · {{ new Date(doc.createdAt).toLocaleDateString() }} + · {{ new Date(doc.createdAt).toLocaleDateString() }}
diff --git a/app/pages/dashboard/candidates/index.vue b/app/pages/dashboard/candidates/index.vue index 3ba5c669..6a969cae 100644 --- a/app/pages/dashboard/candidates/index.vue +++ b/app/pages/dashboard/candidates/index.vue @@ -227,7 +227,7 @@ const sortedCandidates = computed(() => { 0 - {{ new Date(c.createdAt).toLocaleDateString() }} + {{ new Date(c.createdAt).toLocaleDateString() }} diff --git a/app/pages/dashboard/interviews/[id].vue b/app/pages/dashboard/interviews/[id].vue index 252c6867..5cbdccd3 100644 --- a/app/pages/dashboard/interviews/[id].vue +++ b/app/pages/dashboard/interviews/[id].vue @@ -17,6 +17,7 @@ const interviewId = route.params.id as string const { handlePreviewReadOnlyError } = usePreviewReadOnly() const toast = useToast() const { activeOrg } = useCurrentOrg() +const { track } = useTrack() const { interview, status: fetchStatus, error, updateInterview, deleteInterview, refresh } = useInterview(interviewId) @@ -98,6 +99,11 @@ async function handleTransition(newStatus: InterviewStatus) { isTransitioning.value = true try { await updateInterview({ status: newStatus }) + track('interview_status_changed', { + interview_id: interviewId, + from_status: interview.value?.status, + to_status: newStatus, + }) } catch (err: any) { if (handlePreviewReadOnlyError(err)) return toast.error('Failed to update status', { message: err.data?.statusMessage, statusCode: err.data?.statusCode }) @@ -429,7 +435,7 @@ const localePath = useLocalePath() class="mt-3 flex items-center gap-1.5 text-xs text-success-600 dark:text-success-400" > - Invitation sent {{ formatDate(interview.invitationSentAt) }} + Invitation sent {{ formatDate(interview.invitationSentAt) }}
Date & Time
- {{ formatDateTime(interview.scheduledAt) }} + {{ formatDateTime(interview.scheduledAt) }}
@@ -747,11 +753,11 @@ const localePath = useLocalePath()
Created
-
{{ formatDate(interview.createdAt) }}
+
{{ formatDate(interview.createdAt) }}
Updated
-
{{ formatDate(interview.updatedAt) }}
+
{{ formatDate(interview.updatedAt) }}
diff --git a/app/pages/dashboard/interviews/index.vue b/app/pages/dashboard/interviews/index.vue index e9c95a1f..b341806a 100644 --- a/app/pages/dashboard/interviews/index.vue +++ b/app/pages/dashboard/interviews/index.vue @@ -485,10 +485,10 @@ const statusCounts = computed(() => {
- + {{ formatDateShort(interviewItem.scheduledAt) }} - + {{ formatTime(interviewItem.scheduledAt) }} · {{ interviewItem.duration }}min diff --git a/app/pages/dashboard/jobs/[id]/ai-analysis.vue b/app/pages/dashboard/jobs/[id]/ai-analysis.vue index fa37f25f..4f8c18bc 100644 --- a/app/pages/dashboard/jobs/[id]/ai-analysis.vue +++ b/app/pages/dashboard/jobs/[id]/ai-analysis.vue @@ -11,6 +11,7 @@ definePageMeta({ const route = useRoute() const jobId = route.params.id as string const toast = useToast() +const { track } = useTrack() const { job, status: jobFetchStatus, error: jobError, updateJob } = useJob(jobId) @@ -174,6 +175,7 @@ async function generateAiCriteria() { maxScore: c.maxScore ?? 10, weight: c.weight ?? 50, })) + track('ai_criteria_generated', { job_id: jobId, criteria_count: scoringCriteria.value.length }) toast.success('Criteria generated', `${scoringCriteria.value.length} scoring criteria created from job description.`) } catch (err: any) { const statusCode = err?.data?.statusCode ?? err?.statusCode @@ -265,6 +267,7 @@ async function saveCriteria() { }, }) hasUnsavedChanges.value = false + track('scoring_criteria_saved', { job_id: jobId, criteria_count: scoringCriteria.value.length }) toast.success('Criteria saved', `${scoringCriteria.value.length} scoring criteria updated.`) await refreshCriteria() } catch (err: any) { diff --git a/app/pages/dashboard/jobs/[id]/candidates.vue b/app/pages/dashboard/jobs/[id]/candidates.vue index cb74bed9..f6cd4f40 100644 --- a/app/pages/dashboard/jobs/[id]/candidates.vue +++ b/app/pages/dashboard/jobs/[id]/candidates.vue @@ -68,11 +68,11 @@ const total = computed(() => appData.value?.total ?? 0) // ───────────────────────────────────────────── const statusBadgeClasses: Record = { - new: 'bg-brand-50 text-brand-700 ring-brand-200 dark:bg-brand-950/50 dark:text-brand-300 dark:ring-brand-800', - screening: 'bg-info-50 text-info-700 ring-info-200 dark:bg-info-950/50 dark:text-info-300 dark:ring-info-800', - interview: 'bg-warning-50 text-warning-700 ring-warning-200 dark:bg-warning-950/50 dark:text-warning-300 dark:ring-warning-800', - offer: 'bg-success-50 text-success-700 ring-success-200 dark:bg-success-950/50 dark:text-success-300 dark:ring-success-800', - hired: 'bg-success-100 text-success-800 ring-success-300 dark:bg-success-900/50 dark:text-success-200 dark:ring-success-700', + new: 'bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-950/50 dark:text-blue-400 dark:ring-blue-800', + screening: 'bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-950/50 dark:text-violet-400 dark:ring-violet-800', + interview: 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-950/50 dark:text-amber-400 dark:ring-amber-800', + offer: 'bg-teal-50 text-teal-700 ring-teal-200 dark:bg-teal-950/50 dark:text-teal-400 dark:ring-teal-800', + hired: 'bg-green-50 text-green-700 ring-green-200 dark:bg-green-950/50 dark:text-green-400 dark:ring-green-800', rejected: 'bg-surface-100 text-surface-500 ring-surface-200 dark:bg-surface-800/50 dark:text-surface-400 dark:ring-surface-700', } @@ -523,7 +523,7 @@ const isLoading = computed(() => jobFetchStatus.value === 'pending' || appFetchS - {{ timeAgo(app.createdAt) }} + {{ timeAgo(app.createdAt) }} diff --git a/app/pages/dashboard/jobs/[id]/index.vue b/app/pages/dashboard/jobs/[id]/index.vue index bd8ae075..85bed7cc 100644 --- a/app/pages/dashboard/jobs/[id]/index.vue +++ b/app/pages/dashboard/jobs/[id]/index.vue @@ -378,11 +378,11 @@ useSeoMeta({ // ───────────────────────────────────────────── const statusBadgeClasses: Record = { - new: 'bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-400', - screening: 'bg-info-50 text-info-700 dark:bg-info-950 dark:text-info-400', - interview: 'bg-warning-50 text-warning-700 dark:bg-warning-950 dark:text-warning-400', - offer: 'bg-success-50 text-success-700 dark:bg-success-950 dark:text-success-400', - hired: 'bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-300', + new: 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-400', + screening: 'bg-violet-50 text-violet-700 dark:bg-violet-950 dark:text-violet-400', + interview: 'bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-400', + offer: 'bg-teal-50 text-teal-700 dark:bg-teal-950 dark:text-teal-400', + hired: 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-400', rejected: 'bg-surface-100 text-surface-500 dark:bg-surface-800 dark:text-surface-400', } @@ -397,10 +397,10 @@ const transitionLabels: Record = { const transitionClasses: Record = { new: 'border border-surface-300 dark:border-surface-600 text-surface-600 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800', - screening: 'bg-info-600 text-white hover:bg-info-700', - interview: 'bg-warning-600 text-white hover:bg-warning-700', - offer: 'bg-success-600 text-white hover:bg-success-700', - hired: 'bg-success-700 text-white hover:bg-success-800', + screening: 'bg-violet-600 text-white hover:bg-violet-700', + interview: 'bg-amber-600 text-white hover:bg-amber-700', + offer: 'bg-teal-600 text-white hover:bg-teal-700', + hired: 'bg-green-700 text-white hover:bg-green-800', rejected: 'bg-danger-600 text-white hover:bg-danger-700', } @@ -938,7 +938,9 @@ const isJobTransitioning = ref(false) async function handleJobTransition(newStatus: string) { isJobTransitioning.value = true try { + const fromStatus = jobData.value?.status await updateJob({ status: newStatus as any }) + track('job_status_changed', { job_id: jobId, from_status: fromStatus, to_status: newStatus }) await refreshJob() } catch (err: any) { if (handlePreviewReadOnlyError(err)) return @@ -958,6 +960,7 @@ const showDeleteConfirm = ref(false) async function handleDelete() { isDeleting.value = true try { + track('job_deleted', { job_id: jobId }) await deleteJob() } catch (err: any) { if (handlePreviewReadOnlyError(err)) return @@ -995,6 +998,7 @@ async function scoreAllCandidates() { method: 'POST', }) scoringProgress.value.total = applicationIds.length + track('bulk_scoring_started', { job_id: jobId, candidate_count: applicationIds.length }) if (applicationIds.length === 0) { toast.info('All candidates scored', 'Every candidate already has a score.') return @@ -1048,6 +1052,7 @@ async function scoreIndividualCandidate(applicationId: string) { if (currentApplicationId.value === applicationId) { await executeDetailFetch() } + track('individual_scoring_completed', { application_id: applicationId }) toast.success('Candidate scored', 'AI analysis complete.') } catch (err: any) { const statusMessage = err?.data?.statusMessage ?? '' @@ -1114,6 +1119,7 @@ const docPreviewDocId = ref(null) const isDocPreviewPdf = computed(() => docPreviewMimeType.value === 'application/pdf') function handleDocPreview(doc: SwipeDocument) { + track('document_viewed', { document_type: doc.type, mime_type: doc.mimeType }) if (doc.mimeType !== 'application/pdf') { // Non-PDFs: fall back to download window.open(`/api/documents/${doc.id}/download`, '_blank') @@ -1282,11 +1288,11 @@ function closeDocPreview() { @click="setFocusStatus(status)" > {{ formatStatusLabel(status) }} @@ -1597,11 +1603,11 @@ function closeDocPreview() { @@ -1646,12 +1652,12 @@ function closeDocPreview() { {{ isScoringIndividual ? 'Scoring…' : (currentSummary.score != null ? 'Re-score' : 'Score Candidate') }} - + Applied {{ new Date(currentSummary.createdAt).toLocaleDateString() }} - + - · Updated {{ new Date(currentSummary.updatedAt).toLocaleDateString() }} + · Updated {{ new Date(currentSummary.updatedAt).toLocaleDateString() }}
@@ -1690,7 +1696,7 @@ function closeDocPreview() {
-
+
diff --git a/app/pages/dashboard/jobs/[id]/settings.vue b/app/pages/dashboard/jobs/[id]/settings.vue index 7bb67251..20166ca4 100644 --- a/app/pages/dashboard/jobs/[id]/settings.vue +++ b/app/pages/dashboard/jobs/[id]/settings.vue @@ -14,6 +14,7 @@ const localePath = useLocalePath() const jobId = route.params.id as string const toast = useToast() const { handlePreviewReadOnlyError } = usePreviewReadOnly() +const { track } = useTrack() const { job, status: fetchStatus, error: fetchError, updateJob, deleteJob } = useJob(jobId) @@ -123,6 +124,7 @@ async function handleSave() { if (form.value.validThrough) payload.validThrough = new Date(form.value.validThrough) await updateJob(payload as any) + track('job_settings_saved', { job_id: jobId }) saved.value = true setTimeout(() => { saved.value = false }, 2000) } catch (err: any) { @@ -165,6 +167,7 @@ const isDeleting = ref(false) async function handleDelete() { isDeleting.value = true try { + track('job_deleted', { job_id: jobId, source: 'settings' }) await deleteJob() } catch (err: any) { if (handlePreviewReadOnlyError(err)) return diff --git a/app/pages/dashboard/jobs/new.vue b/app/pages/dashboard/jobs/new.vue index 23b2c7a4..cadeb464 100644 --- a/app/pages/dashboard/jobs/new.vue +++ b/app/pages/dashboard/jobs/new.vue @@ -23,6 +23,9 @@ import { Sparkles, Loader2, SlidersHorizontal, + Lock, + Upload, + CircleHelp, } from 'lucide-vue-next' import { z } from 'zod' @@ -62,13 +65,12 @@ type DraftQuestion = { } // Wizard state -const currentStep = ref<1 | 2 | 3 | 4 | 5>(1) +const currentStep = ref<1 | 2 | 3 | 4>(1) const steps = [ { id: 1, title: 'Job details', description: 'Tell applicants about this role.' }, { id: 2, title: 'Application form', description: 'Design the application form.' }, - { id: 3, title: 'Scoring criteria', description: 'Define how AI evaluates candidates.' }, - { id: 4, title: 'Find candidates', description: 'Post on job boards, engage recruiters.' }, - { id: 5, title: 'Publish & share', description: 'Go live and share with candidates.' }, + { id: 3, title: 'AI scoring criteria', description: 'Define how AI evaluates candidates.' }, + { id: 4, title: 'Publish & share', description: 'Go live and share with candidates.' }, ] // Step 1: Job details (API-supported fields) @@ -77,6 +79,8 @@ const form = ref({ description: '', location: '', type: 'full_time' as 'full_time' | 'part_time' | 'contract' | 'internship', + experienceLevel: 'mid' as 'junior' | 'mid' | 'senior' | 'lead', + remoteStatus: undefined as 'remote' | 'hybrid' | 'onsite' | undefined, }) // Step 2: Application form (client-only for now) @@ -86,15 +90,7 @@ const applicationForm = ref({ questions: [] as DraftQuestion[], }) -// Step 3: Find candidates (client-only for now) -const findCandidates = ref({ - experienceLevel: 'mid' as 'junior' | 'mid' | 'senior' | 'lead', - skills: [] as string[], - enableSourcing: true, - locationPreference: 'anywhere' as 'onsite' | 'hybrid' | 'remote' | 'anywhere', -}) - -// Step 3: Scoring criteria +// Step 3: AI scoring criteria type ScoringCriterionDraft = { key: string name: string @@ -210,7 +206,8 @@ async function generateAiCriteria() { }) } else { toast.error('Failed to generate criteria', { - message: statusMessage || 'An unexpected error occurred. Check your AI settings and try again.', + message: 'Could not generate criteria. Make sure your AI provider is configured in Settings → AI, then try again.', + details: statusMessage || `${statusCode ?? 'Unknown'} error — no additional details from server.`, statusCode, }) } @@ -261,6 +258,72 @@ const linkCopied = ref(false) const questionActionError = ref(null) const nextQuestionId = ref(1) +// Check if AI provider is configured +const { data: aiConfigData } = useFetch('/api/ai-config', { key: 'ai-config-check' }) +const isAiConfigured = computed(() => { + return aiConfigData.value && aiConfigData.value.provider && aiConfigData.value.hasApiKey +}) + +// Auto-save to localStorage +const AUTO_SAVE_KEY = 'reqcore-job-draft' + +function saveFormToStorage() { + if (!import.meta.client) return + try { + const data = { + form: form.value, + applicationForm: applicationForm.value, + scoringCriteria: scoringCriteria.value, + scoringMode: scoringMode.value, + autoScoreOnApply: autoScoreOnApply.value, + currentStep: currentStep.value, + } + localStorage.setItem(AUTO_SAVE_KEY, JSON.stringify(data)) + } catch { /* storage full or unavailable */ } +} + +function restoreFormFromStorage() { + if (!import.meta.client) return + try { + const raw = localStorage.getItem(AUTO_SAVE_KEY) + if (!raw) return + const data = JSON.parse(raw) + if (data.form) Object.assign(form.value, data.form) + if (data.applicationForm) Object.assign(applicationForm.value, data.applicationForm) + if (data.scoringCriteria) scoringCriteria.value = data.scoringCriteria + if (data.scoringMode) scoringMode.value = data.scoringMode + if (data.autoScoreOnApply != null) autoScoreOnApply.value = data.autoScoreOnApply + if (data.currentStep) currentStep.value = data.currentStep + } catch { /* corrupted data, ignore */ } +} + +function clearFormStorage() { + if (!import.meta.client) return + try { localStorage.removeItem(AUTO_SAVE_KEY) } catch { /* ignore */ } +} + +onMounted(() => { + restoreFormFromStorage() +}) + +// Auto-save when step changes or form data changes +watch([currentStep, form, applicationForm, scoringCriteria, scoringMode, autoScoreOnApply], () => { + saveFormToStorage() +}, { deep: true }) + +// Notify user when entering step 3 without AI configured +watch(currentStep, (step) => { + if (step === 3 && !isAiConfigured.value) { + toast.add({ + type: 'warning', + title: 'AI integration not set up', + message: 'To use AI-powered candidate scoring, configure your AI provider in Settings → AI. You can still add criteria manually.', + link: { label: 'Go to AI Settings', href: '/dashboard/settings/ai' }, + duration: 10000, + }) + } +}) + // Step 4: Publish & Share const publishChoice = ref<'publish' | 'draft'>('publish') const isPublished = ref(false) @@ -299,8 +362,15 @@ const canGoNext = computed(() => { return true }) +function goToStep(step: 1 | 2 | 3 | 4) { + if (step === currentStep.value) return + // Validate step 1 before leaving it + if (currentStep.value === 1 && step > 1 && !validateStep1()) return + currentStep.value = step +} + function nextStep() { - if (currentStep.value < 5) { + if (currentStep.value < 4) { if (currentStep.value === 1 && !validateStep1()) return currentStep.value++ } @@ -403,26 +473,6 @@ async function copyApplicationLink() { } } -function addSkillFromInput(e: Event) { - const input = e.target as HTMLInputElement - const value = input.value.trim() - if (!value) return - value - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - .forEach((skill) => { - if (!findCandidates.value.skills.includes(skill)) { - findCandidates.value.skills.push(skill) - } - }) - input.value = '' -} - -function removeSkill(index: number) { - findCandidates.value.skills.splice(index, 1) -} - async function handleSubmit(mode: 'publish' | 'draft' = publishChoice.value) { // Ensure step 1 is valid before submit if (!validateStep1()) { @@ -437,6 +487,7 @@ async function handleSubmit(mode: 'publish' | 'draft' = publishChoice.value) { description: form.value.description || undefined, location: form.value.location || undefined, type: form.value.type, + remoteStatus: form.value.remoteStatus || undefined, requireResume: applicationForm.value.requireResume, requireCoverLetter: applicationForm.value.requireCoverLetter, autoScoreOnApply: autoScoreOnApply.value, @@ -514,6 +565,7 @@ async function handleSubmit(mode: 'publish' | 'draft' = publishChoice.value) { // Saved as draft — go to jobs list await navigateTo(localePath('/dashboard/jobs')) } + clearFormStorage() } catch (err: any) { const statusMessage = err?.data?.statusMessage ?? 'Something went wrong while creating the job.' toast.error('Failed to create job', { @@ -580,7 +632,7 @@ const questionTypeLabels: Record = { Save draft -
+
-

Application requirements

-
- -
+
+
+ Email + +
+ + Mandatory + +
+
+
+ Phone + +
+ + Optional +
+
+
+ + +
+

Documents

+
+ +
- Ask for cover letter - Optional for candidates. +
+ + Resume / CV +
+

PDF, DOC, or DOCX up to 10 MB

- +
+ + +
+
+ +
+
+
+ + Cover letter +
+

Free-text field, max 10,000 characters

+
+
+ + +
+
+
-
-

Custom questions

+
+

Screening questions

+ + {{ applicationForm.questions.length }} {{ applicationForm.questions.length === 1 ? 'question' : 'questions' }} added +
+
{{ questionActionError }}
-
+
-
+
{{ q.label }} - Required + + Required + + + Optional +
-
- {{ questionTypeLabels[q.type] ?? q.type }} - - - {{ q.description }} +
+ {{ questionTypeLabels[q.type] ?? q.type }} + + · {{ q.description }} - · {{ q.options.length }} options + · {{ q.options.length }} options
-
+
-

- No custom questions yet. Applicants will see only the standard fields (name, email, phone). +

+ No screening questions added yet.

- +
+ +
- +

@@ -909,6 +1066,26 @@ const questionTypeLabels: Record = {

+ +
+
+ +
+

AI provider not configured

+

+ To use AI-powered scoring, you need to configure an AI provider first. You can still define criteria manually and set up AI later. +

+ + + Go to AI settings + +
+
+
+
@@ -934,7 +1111,8 @@ const questionTypeLabels: Record = {
@@ -1158,7 +1339,8 @@ const questionTypeLabels: Record = {
@@ -1167,6 +1349,9 @@ const questionTypeLabels: Record = { When a candidate applies, AI will automatically analyze their resume against these criteria and assign a score. Requires an AI provider configured in settings plus a resume upload. + + Configure an AI provider to enable automatic scoring. +
@@ -1177,83 +1362,8 @@ const questionTypeLabels: Record = {
- -
-
-

Targeting details

-
-
- - -
-
- - -
-
-
- -
-

Key skills

-
-
- - {{ skill }} - - -
- -
-

Examples: Vue, TypeScript, Tailwind, GraphQL, AWS

-
- -
- -
-
- -
+
@@ -1427,13 +1537,13 @@ const questionTypeLabels: Record = { Back
- -
-

Need help?

-

- Our AI can help you write a compelling job description. Click the magic wand icon in the editor. -

-
diff --git a/app/pages/dashboard/settings/index.vue b/app/pages/dashboard/settings/index.vue index f67ae2aa..9b1c767c 100644 --- a/app/pages/dashboard/settings/index.vue +++ b/app/pages/dashboard/settings/index.vue @@ -11,6 +11,7 @@ useSeoMeta({ const { activeOrg } = useCurrentOrg() const { allowed: canUpdateOrg } = usePermission({ organization: ['update'] }) const { allowed: canDeleteOrg } = usePermission({ organization: ['delete'] }) +const { track } = useTrack() // ───────────────────────────────────────────── // Org name/slug editing @@ -65,6 +66,7 @@ async function handleSaveOrg() { slug: trimmedSlug, }, }) + track('org_settings_saved') saveSuccess.value = true setTimeout(() => { saveSuccess.value = false }, 3000) } @@ -95,6 +97,7 @@ async function handleDeleteOrg() { deleteError.value = '' try { + track('org_deleted') await authClient.organization.delete({ organizationId: activeOrg.value!.id, }) diff --git a/app/pages/dashboard/timeline.vue b/app/pages/dashboard/timeline.vue new file mode 100644 index 00000000..5649876e --- /dev/null +++ b/app/pages/dashboard/timeline.vue @@ -0,0 +1,737 @@ + + + diff --git a/app/plugins/posthog-identity.client.ts b/app/plugins/posthog-identity.client.ts index 48a5623a..53a72289 100644 --- a/app/plugins/posthog-identity.client.ts +++ b/app/plugins/posthog-identity.client.ts @@ -136,9 +136,10 @@ export default defineNuxtPlugin({ }, // Only id and human-readable name are forwarded. slug is redundant // for analytics purposes and is omitted to minimise data collection. - posthogSetOrganization: (org: { id: string, name?: string }) => { + posthogSetOrganization: (org: { id: string, name?: string, member_role?: string }) => { posthog.group('organization', org.id, { name: org.name || undefined, + member_role: org.member_role || undefined, }) }, posthogReset: () => { diff --git a/e2e/critical-flows/candidate-application.spec.ts b/e2e/critical-flows/candidate-application.spec.ts index a8629d8f..9a24ceaf 100644 --- a/e2e/critical-flows/candidate-application.spec.ts +++ b/e2e/critical-flows/candidate-application.spec.ts @@ -96,8 +96,8 @@ async function addCustomQuestion( }, ) { // Click the trigger to open the QuestionForm - await page.getByRole('button', { name: 'Add Question' }).waitFor({ state: 'visible', timeout: 10_000 }) - await page.getByRole('button', { name: 'Add Question' }).click() + await page.getByRole('button', { name: 'Add a question' }).waitFor({ state: 'visible', timeout: 10_000 }) + await page.getByRole('button', { name: 'Add a question' }).click() // Wait for the form to render await page.locator('#q-label').waitFor({ state: 'visible', timeout: 10_000 }) @@ -155,13 +155,11 @@ test.describe('Candidate Application Flow — All Custom Question Field Types', // ── Step 2: Application form — disable resume requirement, add all question types ── - // "Require resume/CV" defaults to ON — toggle it off so the candidate flow + // "Require resume/CV" defaults to ON — switch it off so the candidate flow // does not need a resume upload (the file_upload custom question covers files) - await page.getByRole('button', { name: /Require resume\/CV/i }).waitFor({ state: 'visible', timeout: 10_000 }) - const resumeToggle = page.getByRole('button', { name: /Require resume\/CV/i }) - if ((await resumeToggle.getAttribute('aria-pressed')) === 'true') { - await resumeToggle.click() - } + const resumeRadioGroup = page.getByRole('radiogroup', { name: /Resume requirement/i }) + await resumeRadioGroup.waitFor({ state: 'visible', timeout: 10_000 }) + await resumeRadioGroup.getByRole('radio', { name: 'Off' }).click() // Add one question for each of the nine available field types for (const question of CUSTOM_QUESTIONS) { @@ -181,12 +179,7 @@ test.describe('Candidate Application Flow — All Custom Question Field Types', await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().waitFor({ state: 'visible', timeout: 10_000 }) await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().click() - // ── Step 4: Find candidates (skip) → Step 5 ────────────────────────────── - - await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().waitFor({ state: 'visible', timeout: 10_000 }) - await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().click() - - // ── Step 5: Publish the job ─────────────────────────────────────────────── + // ── Step 4: Publish the job ───────────────────────────────────────── await expect(page.getByRole('heading', { name: /Ready to go\?/i })).toBeVisible({ timeout: 10_000 }) @@ -497,18 +490,13 @@ test.describe('Candidate Application — Required Cover Letter Validation', () = .waitFor({ state: 'attached', timeout: 10_000 }) await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().click() - // Step 2: Enable "Ask for cover letter" toggle - const coverLetterToggle = page.getByRole('button', { name: /Ask for cover letter/i }) - await coverLetterToggle.waitFor({ state: 'visible', timeout: 10_000 }) - if ((await coverLetterToggle.getAttribute('aria-pressed')) !== 'true') { - await coverLetterToggle.click() - } - await expect(coverLetterToggle).toHaveAttribute('aria-pressed', 'true') + // Step 2: Enable "Cover letter" requirement via radio group + const coverLetterRadioGroup = page.getByRole('radiogroup', { name: /Cover letter requirement/i }) + await coverLetterRadioGroup.waitFor({ state: 'visible', timeout: 10_000 }) + await coverLetterRadioGroup.getByRole('radio', { name: 'Required' }).click() + await expect(coverLetterRadioGroup.getByRole('radio', { name: 'Required' })).toHaveAttribute('aria-checked', 'true') - // Step 2 → Step 3 (Scoring criteria) → Step 4 (Find candidates) → Step 5 (Publish) - await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().click() - await page.locator('form').getByRole('button', { name: 'Save & continue' }).first() - .waitFor({ state: 'visible', timeout: 10_000 }) + // Step 2 → Step 3 (Scoring criteria) → Step 4 (Publish) await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().click() await page.locator('form').getByRole('button', { name: 'Save & continue' }).first() .waitFor({ state: 'visible', timeout: 10_000 }) diff --git a/e2e/critical-flows/job-creation.spec.ts b/e2e/critical-flows/job-creation.spec.ts index 31944061..0bf67a54 100644 --- a/e2e/critical-flows/job-creation.spec.ts +++ b/e2e/critical-flows/job-creation.spec.ts @@ -47,11 +47,7 @@ test.describe('Job Creation Flow', () => { await page.locator('form').getByRole('button', { name: 'Save & continue' }).waitFor({ state: 'visible', timeout: 10_000 }) await page.locator('form').getByRole('button', { name: 'Save & continue' }).click() - // Step 4: Find candidates — skip - await page.locator('form').getByRole('button', { name: 'Save & continue' }).waitFor({ state: 'visible', timeout: 10_000 }) - await page.locator('form').getByRole('button', { name: 'Save & continue' }).click() - - // Step 5: Publish the job + // Step 4: Publish the job await expect(page.getByRole('heading', { name: /Ready to go\?/i })).toBeVisible({ timeout: 10_000 }) const publishButton = page.locator('form').getByRole('button', { name: /Publish & copy link/i }) await publishButton.waitFor({ state: 'visible', timeout: 10_000 }) diff --git a/e2e/critical-flows/resume-upload.spec.ts b/e2e/critical-flows/resume-upload.spec.ts index 234547b8..ee59561e 100644 --- a/e2e/critical-flows/resume-upload.spec.ts +++ b/e2e/critical-flows/resume-upload.spec.ts @@ -117,16 +117,16 @@ test.describe('Resume Upload — All File Formats', () => { await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().click() // Step 2: Application form — enable resume, add file_upload question - await page.getByRole('button', { name: /Require resume\/CV/i }) - .waitFor({ state: 'visible', timeout: 10_000 }) - const resumeToggle = page.getByRole('button', { name: /Require resume\/CV/i }) - if ((await resumeToggle.getAttribute('aria-pressed')) !== 'true') { - await resumeToggle.click() + const resumeRadioGroup = page.getByRole('radiogroup', { name: /Resume requirement/i }) + await resumeRadioGroup.waitFor({ state: 'visible', timeout: 10_000 }) + const resumeRequiredRadio = resumeRadioGroup.getByRole('radio', { name: 'Required' }) + if ((await resumeRequiredRadio.getAttribute('aria-checked')) !== 'true') { + await resumeRequiredRadio.click() } // Add a file_upload custom question - await page.getByRole('button', { name: 'Add Question' }).waitFor({ state: 'visible', timeout: 10_000 }) - await page.getByRole('button', { name: 'Add Question' }).click() + await page.getByRole('button', { name: 'Add a question' }).waitFor({ state: 'visible', timeout: 10_000 }) + await page.getByRole('button', { name: 'Add a question' }).click() await page.locator('#q-label').waitFor({ state: 'visible', timeout: 10_000 }) await page.locator('#q-label').fill('Portfolio document') await page.locator('#q-type').selectOption('file_upload') @@ -136,17 +136,12 @@ test.describe('Resume Upload — All File Formats', () => { // Step 2 → Step 3 (Scoring criteria) await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().click() - // Step 3 → Step 4 (Find candidates) - await page.locator('form').getByRole('button', { name: 'Save & continue' }).first() - .waitFor({ state: 'visible', timeout: 10_000 }) - await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().click() - - // Step 4 → Step 5 (Publish) + // Step 3 → Step 4 (Publish) await page.locator('form').getByRole('button', { name: 'Save & continue' }).first() .waitFor({ state: 'visible', timeout: 10_000 }) await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().click() - // Step 5: Publish + // Step 4: Publish await expect(page.getByRole('heading', { name: /Ready to go\?/i })).toBeVisible({ timeout: 10_000 }) const publishButton = page.locator('form').getByRole('button', { name: /Publish & copy link/i }) await publishButton.waitFor({ state: 'visible', timeout: 10_000 }) diff --git a/nuxt.config.ts b/nuxt.config.ts index fe074724..1cddb3b8 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -58,6 +58,9 @@ export default defineNuxtConfig({ // ───────────────────────────────────────────── // PostHog — privacy-focused product analytics & feature flags // ───────────────────────────────────────────── + // Enable source maps so PostHog error tracking can display readable stack traces + sourcemap: { client: 'hidden' }, + posthogConfig: { publicKey: process.env.POSTHOG_PUBLIC_KEY || '', host: process.env.POSTHOG_HOST || 'https://eu.i.posthog.com', @@ -76,10 +79,20 @@ export default defineNuxtConfig({ secure_cookie: true, capture_pageview: true, capture_pageleave: true, + // ── Error tracking: capture unhandled errors and rejections ── + capture_exceptions: { + capture_unhandled_errors: true, + capture_unhandled_rejections: true, + capture_console_errors: false, + }, // ── Persistence ── persistence: 'localStorage+cookie', cross_subdomain_cookie: true, }, + serverConfig: { + // Capture uncaught exceptions and unhandled rejections on the server + enableExceptionAutocapture: true, + }, }, i18n: { @@ -111,6 +124,13 @@ export default defineNuxtConfig({ { name: 'theme-color', content: '#09090b' }, { name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=5.0' }, ], + script: [ + { + // Blocking inline script to apply dark mode before first paint (prevents white flash) + innerHTML: '(function(){try{var s=localStorage.getItem("reqcore-color-mode");if(s==="dark"||(!s&&window.matchMedia("(prefers-color-scheme:dark)").matches)){document.documentElement.classList.add("dark")}}catch(e){}})()', + tagPosition: 'head', + }, + ], // Plausible removed — PostHog handles all analytics }, }, diff --git a/package-lock.json b/package-lock.json index 51c9a23c..f59ef41f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,10 @@ "@aws-sdk/client-s3": "^3.995.0", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/mdc": "^0.20.1", + "@opentelemetry/api-logs": "^0.213.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.213.0", + "@opentelemetry/resources": "^2.6.0", + "@opentelemetry/sdk-logs": "^0.213.0", "@posthog/nuxt": "^1.5.82", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", @@ -3469,9 +3473,9 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", - "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.213.0.tgz", + "integrity": "sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -3481,9 +3485,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3496,16 +3500,16 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", - "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.213.0.tgz", + "integrity": "sha512-vqDVSpLp09ZzcFIdb7QZrEFPxUlO3GzdhBKLstq3jhYB5ow3+ZtV5V0ngSdi/0BZs+J5WPiN1+UDV4X5zD/GzA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-exporter-base": "0.208.0", - "@opentelemetry/otlp-transformer": "0.208.0", - "@opentelemetry/sdk-logs": "0.208.0" + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/otlp-exporter-base": "0.213.0", + "@opentelemetry/otlp-transformer": "0.213.0", + "@opentelemetry/sdk-logs": "0.213.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -3515,13 +3519,13 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", - "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.213.0.tgz", + "integrity": "sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.208.0" + "@opentelemetry/core": "2.6.0", + "@opentelemetry/otlp-transformer": "0.213.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -3531,18 +3535,18 @@ } }, "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", - "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz", + "integrity": "sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.208.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/sdk-logs": "0.213.0", + "@opentelemetry/sdk-metrics": "2.6.0", + "@opentelemetry/sdk-trace-base": "2.6.0", + "protobufjs": "^7.0.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -3551,22 +3555,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/resources": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", @@ -3583,62 +3571,32 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", - "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/sdk-logs": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", - "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz", + "integrity": "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.0.tgz", + "integrity": "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -3647,46 +3605,14 @@ "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", + "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.2.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -7726,9 +7652,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -13380,9 +13306,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -15273,9 +15199,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -15890,6 +15816,155 @@ "web-vitals": "^5.1.0" } }, + "node_modules/posthog-js/node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/posthog-js/node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/posthog-js/node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", + "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-exporter-base": "0.208.0", + "@opentelemetry/otlp-transformer": "0.208.0", + "@opentelemetry/sdk-logs": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/posthog-js/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", + "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-transformer": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/posthog-js/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", + "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/sdk-logs": "0.208.0", + "@opentelemetry/sdk-metrics": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/posthog-js/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/posthog-js/node_modules/@opentelemetry/sdk-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", + "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/posthog-js/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", + "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/posthog-js/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", + "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/posthog-node": { "version": "5.28.1", "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.28.1.tgz", @@ -19398,9 +19473,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 16cbcc10..b897240b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "db:seed": "npx tsx server/scripts/seed.ts", + "db:reseed": "npx tsx server/scripts/delete-demo-org.ts && npx tsx server/scripts/seed.ts", "test:e2e": "npx playwright test", "i18n:crowdin:upload": "crowdin upload sources", "i18n:crowdin:download": "crowdin download --all", @@ -28,6 +29,10 @@ "@aws-sdk/client-s3": "^3.995.0", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/mdc": "^0.20.1", + "@opentelemetry/api-logs": "^0.213.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.213.0", + "@opentelemetry/resources": "^2.6.0", + "@opentelemetry/sdk-logs": "^0.213.0", "@posthog/nuxt": "^1.5.82", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", diff --git a/server/api/activity-log/timeline.get.ts b/server/api/activity-log/timeline.get.ts new file mode 100644 index 00000000..994593f5 --- /dev/null +++ b/server/api/activity-log/timeline.get.ts @@ -0,0 +1,275 @@ +import { eq, and, desc, gte, lte, inArray } from 'drizzle-orm' +import { z } from 'zod' +import { activityLog, user, job, candidate, application, interview } from '../../database/schema' + +const timelineQuerySchema = z.object({ + before: z.string().datetime().optional(), + after: z.string().datetime().optional(), + limit: z.coerce.number().int().min(1).max(200).default(100), + resourceType: z.enum(['job', 'candidate', 'application', 'interview', 'member']).optional(), +}) + +/** + * GET /api/activity-log/timeline + * + * Fetches activity-log entries for the organisation, enriched with + * resource names so the frontend can render clickable timeline items. + * + * Supports cursor-based pagination: + * ?before= — load older events + * ?after= — load newer / future events + * ?limit=100 + * ?resourceType=job|candidate|application (optional filter) + */ +export default defineEventHandler(async (event) => { + const session = await requirePermission(event, { activityLog: ['read'] }) + const orgId = session.session.activeOrganizationId + + const query = await getValidatedQuery(event, timelineQuerySchema.parse) + + const conditions = [eq(activityLog.organizationId, orgId)] + + if (query.resourceType) { + conditions.push(eq(activityLog.resourceType, query.resourceType)) + } + + if (query.before) { + conditions.push(lte(activityLog.createdAt, new Date(query.before))) + } + + if (query.after) { + conditions.push(gte(activityLog.createdAt, new Date(query.after))) + } + + const where = and(...conditions) + + // Fetch activity entries with actor info + const data = await db + .select({ + id: activityLog.id, + action: activityLog.action, + resourceType: activityLog.resourceType, + resourceId: activityLog.resourceId, + metadata: activityLog.metadata, + createdAt: activityLog.createdAt, + actorId: activityLog.actorId, + actorName: user.name, + actorEmail: user.email, + actorImage: user.image, + }) + .from(activityLog) + .innerJoin(user, eq(user.id, activityLog.actorId)) + .where(where) + .orderBy(desc(activityLog.createdAt)) + .limit(query.limit + 1) // fetch one extra to know if there's more + + const hasMore = data.length > query.limit + const items = hasMore ? data.slice(0, query.limit) : data + + // Collect resource IDs for enrichment + const jobIds = new Set() + const candidateIds = new Set() + const applicationIds = new Set() + const interviewIds = new Set() + + for (const item of items) { + switch (item.resourceType) { + case 'job': jobIds.add(item.resourceId); break + case 'candidate': candidateIds.add(item.resourceId); break + case 'application': applicationIds.add(item.resourceId); break + case 'interview': interviewIds.add(item.resourceId); break + } + } + + // Enrich resource names in parallel + const [jobNames, candidateNames, applicationInfo, interviewInfo] = await Promise.all([ + jobIds.size > 0 + ? db.select({ id: job.id, title: job.title }).from(job) + .where(and( + eq(job.organizationId, orgId), + inArray(job.id, Array.from(jobIds)), + )) + : Promise.resolve([]), + candidateIds.size > 0 + ? db.select({ id: candidate.id, firstName: candidate.firstName, lastName: candidate.lastName }).from(candidate) + .where(and( + eq(candidate.organizationId, orgId), + inArray(candidate.id, Array.from(candidateIds)), + )) + : Promise.resolve([]), + applicationIds.size > 0 + ? db.select({ + id: application.id, + jobId: application.jobId, + candidateId: application.candidateId, + jobTitle: job.title, + candidateFirstName: candidate.firstName, + candidateLastName: candidate.lastName, + }) + .from(application) + .innerJoin(job, eq(job.id, application.jobId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) + .where(and( + eq(application.organizationId, orgId), + inArray(application.id, Array.from(applicationIds)), + )) + : Promise.resolve([]), + interviewIds.size > 0 + ? db.select({ + id: interview.id, + scheduledAt: interview.scheduledAt, + type: interview.type, + applicationId: interview.applicationId, + jobId: application.jobId, + candidateId: application.candidateId, + jobTitle: job.title, + candidateFirstName: candidate.firstName, + candidateLastName: candidate.lastName, + }) + .from(interview) + .innerJoin(application, eq(application.id, interview.applicationId)) + .innerJoin(job, eq(job.id, application.jobId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) + .where(and( + eq(interview.organizationId, orgId), + inArray(interview.id, Array.from(interviewIds)), + )) + : Promise.resolve([]), + ]) + + // Build lookup maps + const jobMap = new Map(jobNames.map(j => [j.id, j.title])) + const candidateMap = new Map(candidateNames.map(c => [c.id, `${c.firstName} ${c.lastName}`])) + const applicationMap = new Map(applicationInfo.map(a => [a.id, { + jobTitle: a.jobTitle, + candidateName: `${a.candidateFirstName} ${a.candidateLastName}`, + jobId: a.jobId, + candidateId: a.candidateId, + }])) + const interviewMap = new Map(interviewInfo.map(i => [i.id, { + scheduledAt: i.scheduledAt, + type: i.type, + applicationId: i.applicationId, + jobId: i.jobId, + candidateId: i.candidateId, + jobTitle: i.jobTitle, + candidateName: `${i.candidateFirstName} ${i.candidateLastName}`, + }])) + + // Enrich items with resource display names + const enriched = items.map((item) => { + let resourceName: string | null = null + let resourceUrl: string | null = null + let jobId: string | null = null + let jobName: string | null = null + let extra: Record = {} + + switch (item.resourceType) { + case 'job': { + resourceName = jobMap.get(item.resourceId) ?? null + resourceUrl = `/dashboard/jobs/${item.resourceId}` + jobId = item.resourceId + jobName = resourceName + break + } + case 'candidate': { + resourceName = candidateMap.get(item.resourceId) ?? null + resourceUrl = `/dashboard/candidates/${item.resourceId}` + break + } + case 'application': { + const appInfo = applicationMap.get(item.resourceId) + if (appInfo) { + resourceName = `${appInfo.candidateName} → ${appInfo.jobTitle}` + resourceUrl = `/dashboard/applications/${item.resourceId}` + jobId = appInfo.jobId + jobName = appInfo.jobTitle + extra = { candidateId: appInfo.candidateId, candidateName: appInfo.candidateName } + } + break + } + case 'interview': { + const intInfo = interviewMap.get(item.resourceId) + if (intInfo) { + resourceName = `${intInfo.candidateName} — ${intInfo.type} interview` + resourceUrl = `/dashboard/interviews/${item.resourceId}` + jobId = intInfo.jobId + jobName = intInfo.jobTitle + extra = { scheduledAt: intInfo.scheduledAt, applicationId: intInfo.applicationId, candidateId: intInfo.candidateId, candidateName: intInfo.candidateName } + } + break + } + case 'member': { + resourceUrl = `/dashboard/settings/members` + break + } + } + + return { + ...item, + resourceName, + resourceUrl, + jobId, + jobName, + ...extra, + } + }) + + // Fetch upcoming interviews as future "planned" events + // Only include when no filter is active, or when filtering specifically for interviews + let upcoming: Array> = [] + if (!query.before && !query.after && (!query.resourceType || query.resourceType === 'interview')) { + const upcomingInterviews = await db + .select({ + id: interview.id, + scheduledAt: interview.scheduledAt, + type: interview.type, + applicationId: interview.applicationId, + candidateId: application.candidateId, + candidateFirstName: candidate.firstName, + candidateLastName: candidate.lastName, + jobId: application.jobId, + jobTitle: job.title, + }) + .from(interview) + .innerJoin(application, eq(application.id, interview.applicationId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) + .innerJoin(job, eq(job.id, application.jobId)) + .where(and( + eq(interview.organizationId, orgId), + gte(interview.scheduledAt, new Date()), + eq(interview.status, 'scheduled'), + )) + .orderBy(interview.scheduledAt) + .limit(20) + + upcoming = upcomingInterviews.map(i => ({ + id: `upcoming-${i.id}`, + action: 'scheduled' as any, + resourceType: 'interview', + resourceId: i.id, + metadata: { type: i.type, scheduledAt: i.scheduledAt }, + createdAt: i.scheduledAt, + actorId: '', + actorName: null, + actorEmail: null, + actorImage: null, + resourceName: `${i.candidateFirstName} ${i.candidateLastName} — ${i.type} interview`, + resourceUrl: `/dashboard/interviews/${i.id}`, + applicationId: i.applicationId, + jobId: i.jobId, + jobName: i.jobTitle, + candidateId: i.candidateId, + candidateName: `${i.candidateFirstName} ${i.candidateLastName}`, + isUpcoming: true, + })) + } + + return { + items: enriched, + upcoming, + hasMore, + oldestTimestamp: items.length > 0 ? items[items.length - 1]!.createdAt : null, + newestTimestamp: items.length > 0 ? items[0]!.createdAt : null, + } +}) diff --git a/server/api/applications/[id].patch.ts b/server/api/applications/[id].patch.ts index b79bd4c5..37abbf0b 100644 --- a/server/api/applications/[id].patch.ts +++ b/server/api/applications/[id].patch.ts @@ -63,5 +63,22 @@ export default defineEventHandler(async (event) => { : undefined, }) + // Track to PostHog for per-user debugging and funnel analytics + if (body.status && body.status !== current.status) { + trackEvent(event, session, 'application status_changed', { + application_id: id, + job_id: updated.jobId, + from_status: current.status, + to_status: body.status, + }) + + logApiRequest(event, session, 'application.status_changed', { + application_id: id, + job_id: updated.jobId, + from_status: current.status, + to_status: body.status, + }) + } + return updated }) diff --git a/server/api/auth/[...all].ts b/server/api/auth/[...all].ts index 89caa546..c0c42fd0 100644 --- a/server/api/auth/[...all].ts +++ b/server/api/auth/[...all].ts @@ -3,11 +3,10 @@ export default defineEventHandler(async (event) => { return await auth.handler(toWebRequest(event)) } catch (error) { const requestUrl = getRequestURL(event) - console.error('[Reqcore] Auth handler error', { - method: event.method, - path: requestUrl.pathname, - message: error instanceof Error ? error.message : 'Unknown error', - stack: error instanceof Error ? error.stack : undefined, + logError('auth.handler_error', { + http_method: event.method, + http_path: requestUrl.pathname, + error_message: error instanceof Error ? error.message : 'Unknown error', }) // Detect BETTER_AUTH_URL mismatch — the #1 self-hosting setup issue @@ -19,7 +18,10 @@ export default defineEventHandler(async (event) => { const isUrlMismatch = configuredOrigin && requestOrigin !== configuredOrigin if (isUrlMismatch) { - console.error(`[Reqcore] BETTER_AUTH_URL mismatch: configured=${configuredOrigin}, request=${requestOrigin}`) + logError('auth.url_mismatch', { + configured_origin: configuredOrigin, + request_origin: requestOrigin, + }) throw createError({ statusCode: 500, statusMessage: 'Auth configuration error', diff --git a/server/api/calendar/google/callback.get.ts b/server/api/calendar/google/callback.get.ts index 5e83cf3b..02be0963 100644 --- a/server/api/calendar/google/callback.get.ts +++ b/server/api/calendar/google/callback.get.ts @@ -51,13 +51,19 @@ export default defineEventHandler(async (event) => { // Set up webhook for two-way sync (non-blocking) setupCalendarWebhook(session.user.id).catch(err => { - console.error('[Calendar] Failed to setup webhook after connect:', err) + logWarn('calendar.webhook_setup_failed', { + posthog_distinct_id: session.user.id, + error_message: err instanceof Error ? err.message : String(err), + }) }) return sendRedirect(event, '/dashboard/settings/integrations?success=connected') } catch (err) { - console.error('[Calendar] OAuth callback failed:', err) + logError('calendar.oauth_callback_failed', { + posthog_distinct_id: session.user.id, + error_message: err instanceof Error ? err.message : String(err), + }) return sendRedirect(event, '/dashboard/settings/integrations?error=oauth_failed') } }) diff --git a/server/api/calendar/webhook.post.ts b/server/api/calendar/webhook.post.ts index 1a4683ca..140095ad 100644 --- a/server/api/calendar/webhook.post.ts +++ b/server/api/calendar/webhook.post.ts @@ -57,7 +57,10 @@ export default defineEventHandler(async (event) => { // Perform incremental sync to pull changes from Google Calendar // Run async — Google expects a fast response performIncrementalSync(integration.userId).catch(err => { - console.error(`[Calendar] Webhook sync failed for user ${integration.userId}:`, err) + logError('calendar.webhook_sync_failed', { + posthog_distinct_id: integration.userId, + error_message: err instanceof Error ? err.message : String(err), + }) }) setResponseStatus(event, 200) diff --git a/server/api/candidates/[id]/documents/index.post.ts b/server/api/candidates/[id]/documents/index.post.ts index 53ecab01..366e0dca 100644 --- a/server/api/candidates/[id]/documents/index.post.ts +++ b/server/api/candidates/[id]/documents/index.post.ts @@ -192,7 +192,10 @@ export default defineEventHandler(async (event) => { try { await deleteFromS3(storageKey) } catch (cleanupError) { - console.error('[Reqcore] Failed to clean up orphaned S3 object:', storageKey, cleanupError) + logWarn('document.s3_orphan_cleanup_failed', { + storage_key: storageKey, + error_message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }) } throw dbError } diff --git a/server/api/candidates/index.post.ts b/server/api/candidates/index.post.ts index 41114f75..d695ecd3 100644 --- a/server/api/candidates/index.post.ts +++ b/server/api/candidates/index.post.ts @@ -53,6 +53,14 @@ export default defineEventHandler(async (event) => { metadata: { name: `${created.firstName} ${created.lastName}` }, }) + trackEvent(event, session, 'candidate created', { + candidate_id: created.id, + }) + + logApiRequest(event, session, 'candidate.created', { + candidate_id: created.id, + }) + setResponseStatus(event, 201) return created }) diff --git a/server/api/documents/[id].delete.ts b/server/api/documents/[id].delete.ts index 2b9428dd..ef4d9d36 100644 --- a/server/api/documents/[id].delete.ts +++ b/server/api/documents/[id].delete.ts @@ -42,7 +42,10 @@ export default defineEventHandler(async (event) => { try { await deleteFromS3(doc.storageKey) } catch (s3Error) { - console.error('[Reqcore] Failed to delete S3 object:', doc.storageKey, s3Error) + logWarn('document.s3_delete_failed', { + storage_key: doc.storageKey, + error_message: s3Error instanceof Error ? s3Error.message : String(s3Error), + }) // Continue with DB deletion — orphaned S3 objects can be cleaned up later } diff --git a/server/api/feedback.post.ts b/server/api/feedback.post.ts index db43685e..8e189980 100644 --- a/server/api/feedback.post.ts +++ b/server/api/feedback.post.ts @@ -201,7 +201,9 @@ export default defineEventHandler(async (event) => { ) issueUrl = response.html_url } catch (err: any) { - console.error('[feedback] Failed to create GitHub issue:', err.data ?? err.message) + logError('feedback.github_issue_failed', { + error_message: err.data ?? err.message, + }) throw createError({ statusCode: 502, statusMessage: 'Failed to submit feedback. Please try again later.', diff --git a/server/api/interviews/[id]/index.delete.ts b/server/api/interviews/[id]/index.delete.ts index a0b0526a..4ff69910 100644 --- a/server/api/interviews/[id]/index.delete.ts +++ b/server/api/interviews/[id]/index.delete.ts @@ -21,7 +21,10 @@ export default defineEventHandler(async (event) => { // Cancel Google Calendar event (non-blocking) if (current.googleCalendarEventId) { cancelCalendarEvent(current.createdById, current.googleCalendarEventId).catch(err => { - console.error('[Calendar] Failed to cancel event on delete:', err) + logError('calendar.cancel_event_on_delete_failed', { + event_id: current.googleCalendarEventId, + error_message: err instanceof Error ? err.message : String(err), + }) }) } diff --git a/server/api/interviews/[id]/index.patch.ts b/server/api/interviews/[id]/index.patch.ts index e9708a16..f967f471 100644 --- a/server/api/interviews/[id]/index.patch.ts +++ b/server/api/interviews/[id]/index.patch.ts @@ -55,7 +55,10 @@ export default defineEventHandler(async (event) => { if (isCancelling) { cancelCalendarEvent(current.createdById, current.googleCalendarEventId).catch(err => { - console.error('[Calendar] Failed to cancel event:', err) + logError('calendar.cancel_event_failed', { + event_id: current.googleCalendarEventId, + error_message: err instanceof Error ? err.message : String(err), + }) }) } else { @@ -92,7 +95,10 @@ export default defineEventHandler(async (event) => { .where(and(eq(interview.id, id), eq(interview.organizationId, orgId))) } }).catch(err => { - console.error('[Calendar] Failed to update event:', err) + logError('calendar.update_event_failed', { + event_id: current.googleCalendarEventId, + error_message: err instanceof Error ? err.message : String(err), + }) }) } } diff --git a/server/api/interviews/index.post.ts b/server/api/interviews/index.post.ts index 3e517fa8..19cd7c24 100644 --- a/server/api/interviews/index.post.ts +++ b/server/api/interviews/index.post.ts @@ -91,7 +91,12 @@ export default defineEventHandler(async (event) => { .where(eq(interview.id, created.id)) } } catch (err) { - console.error('[Calendar] Failed to create event for interview:', err) + logError('interview.calendar_sync_failed', { + posthog_distinct_id: session.user.id, + org_id: orgId, + interview_id: created.id, + error_message: err instanceof Error ? err.message : String(err), + }) } } @@ -108,6 +113,22 @@ export default defineEventHandler(async (event) => { }, }) + trackEvent(event, session, 'interview scheduled', { + interview_id: created.id, + application_id: body.applicationId, + interview_type: body.type, + duration_minutes: body.duration, + has_calendar_sync: !!calendarEventId, + }) + + logApiRequest(event, session, 'interview.scheduled', { + interview_id: created.id, + application_id: body.applicationId, + interview_type: body.type, + duration_minutes: body.duration, + has_calendar_sync: !!calendarEventId, + }) + setResponseStatus(event, 201) return { ...created, diff --git a/server/api/jobs/[id].patch.ts b/server/api/jobs/[id].patch.ts index ce9e1c84..1e7976f1 100644 --- a/server/api/jobs/[id].patch.ts +++ b/server/api/jobs/[id].patch.ts @@ -76,5 +76,19 @@ export default defineEventHandler(async (event) => { : { title: updated.title }, }) + if (body.status && body.status !== existing.status) { + trackEvent(event, session, 'job status_changed', { + job_id: id, + from_status: existing.status, + to_status: body.status, + }) + + logApiRequest(event, session, 'job.status_changed', { + job_id: id, + from_status: existing.status, + to_status: body.status, + }) + } + return updated }) diff --git a/server/api/jobs/index.post.ts b/server/api/jobs/index.post.ts index 365341ff..91025cc1 100644 --- a/server/api/jobs/index.post.ts +++ b/server/api/jobs/index.post.ts @@ -62,6 +62,22 @@ export default defineEventHandler(async (event) => { metadata: { title: created.title }, }) + trackEvent(event, session, 'job created', { + job_id: created.id, + job_type: created.type, + has_salary: !!(created.salaryMin || created.salaryMax), + require_resume: created.requireResume, + auto_score: created.autoScoreOnApply, + }) + + logApiRequest(event, session, 'job.created', { + job_id: created.id, + job_type: created.type, + has_salary: !!(created.salaryMin || created.salaryMax), + require_resume: created.requireResume, + auto_score: created.autoScoreOnApply, + }) + setResponseStatus(event, 201) return created }) diff --git a/server/api/join-requests/[id]/approve.post.ts b/server/api/join-requests/[id]/approve.post.ts index 266da352..a2c7e109 100644 --- a/server/api/join-requests/[id]/approve.post.ts +++ b/server/api/join-requests/[id]/approve.post.ts @@ -136,7 +136,9 @@ export default defineEventHandler(async (event) => { } // Wrap unexpected database errors with a descriptive message const message = error instanceof Error ? error.message : 'Unknown error' - console.error('Failed to approve join request:', message) + logError('join_request.approve_failed', { + error_message: message, + }) throw createError({ statusCode: 500, statusMessage: 'Failed to approve join request. Please try again.', diff --git a/server/api/public/jobs/[slug]/apply.post.ts b/server/api/public/jobs/[slug]/apply.post.ts index ecfeafb9..76a8928d 100644 --- a/server/api/public/jobs/[slug]/apply.post.ts +++ b/server/api/public/jobs/[slug]/apply.post.ts @@ -463,9 +463,17 @@ export default defineEventHandler(async (event) => { try { await deleteFromS3(storageKey) } catch (cleanupError) { - console.error('[Reqcore] Failed to clean up orphaned S3 object:', storageKey, cleanupError) + logWarn('application.s3_orphan_cleanup_failed', { + storage_key: storageKey, + error_message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }) } - console.error('[Reqcore] File upload failed during application:', uploadError) + logError('application.file_upload_failed', { + job_id: jobId, + application_id: newApplication?.id, + question_id: questionId, + error_message: uploadError instanceof Error ? uploadError.message : String(uploadError), + }) // Continue processing — don't fail the entire application for a file upload error } } @@ -503,9 +511,16 @@ export default defineEventHandler(async (event) => { try { await deleteFromS3(storageKey) } catch (cleanupError) { - console.error('[Reqcore] Failed to clean up orphaned S3 object:', storageKey, cleanupError) + logWarn('application.s3_orphan_cleanup_failed', { + storage_key: storageKey, + error_message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }) } - console.error('[Reqcore] Resume upload failed during application:', uploadError) + logError('application.resume_upload_failed', { + job_id: jobId, + application_id: newApplication?.id, + error_message: uploadError instanceof Error ? uploadError.message : String(uploadError), + }) // Roll back: delete the application so the user can fix the file and retry try { @@ -515,7 +530,10 @@ export default defineEventHandler(async (event) => { await db.delete(application) .where(eq(application.id, newApplication!.id)) } catch (rollbackError) { - console.error('[Reqcore] Failed to roll back application after resume upload failure:', rollbackError) + logError('application.rollback_failed', { + application_id: newApplication!.id, + error_message: rollbackError instanceof Error ? rollbackError.message : String(rollbackError), + }) } throw createError({ statusCode: 502, statusMessage: 'Failed to upload your resume. Please try again.' }) @@ -528,10 +546,34 @@ export default defineEventHandler(async (event) => { if (existingJob.autoScoreOnApply && newApplication) { autoScoreApplication(newApplication.id, orgId).catch((err) => { - console.error('[Reqcore] Auto-score failed for application', newApplication.id, err) + logError('application.auto_score_failed', { + application_id: newApplication.id, + job_id: jobId, + error_message: err instanceof Error ? err.message : String(err), + }) }) } + // Track public application on the server side (no auth session) + trackEvent(event, null, 'application received', { + job_slug: slug, + job_id: existingJob.id, + application_id: newApplication?.id, + has_resume: !!resumeUpload, + auto_score_enabled: !!existingJob.autoScoreOnApply, + }) + + logApiRequest(event, null, 'application.received', { + job_slug: slug, + job_id: existingJob.id, + application_id: newApplication?.id, + has_resume: !!resumeUpload, + question_count: validResponses.length, + file_count: uploadedFiles.size, + auto_score_enabled: !!existingJob.autoScoreOnApply, + is_returning_candidate: !!existingCandidate, + }) + setResponseStatus(event, 201) return { success: true } }) diff --git a/server/middleware/demo-guard.ts b/server/middleware/demo-guard.ts index e5ceb906..8695f5e3 100644 --- a/server/middleware/demo-guard.ts +++ b/server/middleware/demo-guard.ts @@ -99,9 +99,9 @@ export default defineEventHandler(async (event) => { // Pass through silently — there is no demo org to protect, so blocking // all writes would break the entire application for every user. if (isExplicitlyConfigured) { - console.warn( - `[demo-guard] DEMO_ORG_SLUG is set but none of the configured slugs could be resolved: ${demoSlugs.join(', ')}. Demo write-protection is inactive.`, - ) + logWarn('demo_guard.slug_unresolved', { + demo_slugs: demoSlugs.join(', '), + }) } return } diff --git a/server/middleware/posthog-api-tracking.ts b/server/middleware/posthog-api-tracking.ts new file mode 100644 index 00000000..5c31d429 --- /dev/null +++ b/server/middleware/posthog-api-tracking.ts @@ -0,0 +1,70 @@ +/** + * Server middleware: captures API request outcomes to PostHog as both events + * and structured OpenTelemetry logs (PostHog Logs). + * + * - Emits a structured wide-event log for every API request (one log per request) + * - Tracks 4xx/5xx responses as 'api error' PostHog events + * - Tracks requests slower than 3s as 'api slow_request' events + * - Skips static assets, non-API routes, and health checks + */ + +// Paths that generate high volume with zero debugging value. +const EXCLUDED_PATHS = new Set(['/api/health', '/api/healthz', '/api/ping']) + +export default defineEventHandler(async (event) => { + const path = getRequestURL(event).pathname + + // Only track API routes — skip static assets, ingest proxy, etc. + if (!path.startsWith('/api/')) return + + // Exclude high-frequency health checks / load balancer pings + if (EXCLUDED_PATHS.has(path)) return + + const start = Date.now() + + event.node.res.on('finish', () => { + try { + const duration = Date.now() - start + const statusCode = event.node.res.statusCode + const method = getMethod(event) + + // ── PostHog Logs: structured wide event per API request ── + // Uses requestAttributes() which includes $session_id and + // posthog_distinct_id from the PostHog cookie for Session Replay linking. + const isError = statusCode >= 400 + const isSlow = duration > 3000 + + const logAttrs: Record = { + ...requestAttributes(event), + http_status: statusCode, + duration_ms: duration, + } + + if (isSlow) logAttrs.slow_request = true + + if (isError) { + logError(`${method} ${path} ${statusCode}`, logAttrs) + } + else if (isSlow) { + logWarn(`${method} ${path} slow ${duration}ms`, logAttrs) + } + else { + logInfo(`${method} ${path} ${statusCode}`, logAttrs) + } + + // ── PostHog Events: error/slow tracking (existing behaviour) ── + if (isError) { + trackApiError(event, statusCode, { duration_ms: duration }) + } + if (isSlow) { + trackApiError(event, statusCode, { + duration_ms: duration, + slow_request: true, + }) + } + } + catch { + // Tracking must never break the response + } + }) +}) diff --git a/server/plugins/migrations.ts b/server/plugins/migrations.ts index 71902c89..053dc21e 100644 --- a/server/plugins/migrations.ts +++ b/server/plugins/migrations.ts @@ -9,6 +9,7 @@ export default defineNitroPlugin(async () => { // Running runtime migrations there can conflict with drizzle-kit push/migrate. if (process.env.RAILWAY_ENVIRONMENT_ID) { console.log('[Reqcore] Skipping runtime migrations on Railway (handled in preDeploy)') + logInfo('migrations.skipped_railway') return } @@ -25,6 +26,7 @@ export default defineNitroPlugin(async () => { if (!locked) { console.log('[Reqcore] Another instance is running migrations, skipping') + logInfo('migrations.skipped_locked') return } @@ -36,8 +38,12 @@ export default defineNitroPlugin(async () => { }) await db.execute(`SET client_min_messages TO notice`) console.log('[Reqcore] Database migrations applied successfully') + logInfo('migrations.completed') } catch (error) { console.error('[Reqcore] Migration failed:', error) + logError('migrations.failed', { + error_message: error instanceof Error ? error.message : String(error), + }) throw error } finally { await db.execute( diff --git a/server/plugins/posthog.ts b/server/plugins/posthog.ts index 002b1241..a5d68c1a 100644 --- a/server/plugins/posthog.ts +++ b/server/plugins/posthog.ts @@ -1,8 +1,20 @@ /** - * Nitro plugin: gracefully shut down the server-side PostHog Node client when - * the server process closes. Without this the flush-interval timer keeps the - * event loop alive and any buffered events are silently discarded. + * Nitro plugin: initialise PostHog integrations on startup and shut them + * down cleanly when the server process closes. + * + * – PostHog Node client (event capture, error tracking) + * – OpenTelemetry logger (PostHog Logs via OTLP) */ export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hookOnce('close', () => shutdownServerPostHog()) + // Start the OpenTelemetry LoggerProvider so structured logs + // are sent to PostHog's /i/v1/logs endpoint throughout the lifetime + // of the server process. + initLoggerProvider() + + nitroApp.hooks.hookOnce('close', async () => { + await Promise.all([ + shutdownServerPostHog(), + shutdownLoggerProvider(), + ]) + }) }) diff --git a/server/plugins/s3-bucket.ts b/server/plugins/s3-bucket.ts index e5433a36..4f29d2e8 100644 --- a/server/plugins/s3-bucket.ts +++ b/server/plugins/s3-bucket.ts @@ -15,14 +15,20 @@ export default defineNitroPlugin(async () => { // and enforce privacy at the platform level — skip bucket initialization if (!env.S3_FORCE_PATH_STYLE) { console.log(`[Reqcore] S3 bucket "${env.S3_BUCKET}" — managed provider detected, skipping initialization`) + logInfo('s3.managed_provider_detected', { bucket: env.S3_BUCKET }) return } try { await ensureBucketExists() console.log(`[Reqcore] S3 bucket "${env.S3_BUCKET}" is ready`) + logInfo('s3.bucket_ready', { bucket: env.S3_BUCKET }) } catch (error) { console.error(`[Reqcore] Failed to initialize S3 bucket "${env.S3_BUCKET}":`, error) + logError('s3.bucket_init_failed', { + bucket: env.S3_BUCKET, + error_message: error instanceof Error ? error.message : String(error), + }) // Don't throw — the app can still start, but uploads will fail. // This allows the app to boot even if MinIO is temporarily unavailable. } diff --git a/server/scripts/delete-demo-org.ts b/server/scripts/delete-demo-org.ts new file mode 100644 index 00000000..39d5892e --- /dev/null +++ b/server/scripts/delete-demo-org.ts @@ -0,0 +1,66 @@ +/** + * Deletes the demo organization and all cascaded data so seed.ts can re-run. + * Usage: npx tsx server/scripts/delete-demo-org.ts + */ +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { eq } from 'drizzle-orm' +import * as schema from '../database/schema' + +const processWithLoadEnv = process as NodeJS.Process & { loadEnvFile?: (path?: string) => void } +if (!process.env.DATABASE_URL && typeof processWithLoadEnv.loadEnvFile === 'function') { + try { processWithLoadEnv.loadEnvFile('.env') } catch {} +} + +const DATABASE_URL = process.env.DATABASE_URL +if (!DATABASE_URL) { + console.error('DATABASE_URL is required.') + process.exit(1) +} + +const client = postgres(DATABASE_URL, { max: 1 }) +const db = drizzle(client, { schema }) + +async function main() { + const [org] = await db + .select({ id: schema.organization.id }) + .from(schema.organization) + .where(eq(schema.organization.slug, 'reqcore-demo')) + .limit(1) + + if (org) { + const orgId = org.id + // Delete in dependency order to avoid FK violations + // (some migrations may not have applied CASCADE correctly) + await db.delete(schema.activityLog).where(eq(schema.activityLog.organizationId, orgId)) + await db.delete(schema.criterionScore).where(eq(schema.criterionScore.organizationId, orgId)) + await db.delete(schema.analysisRun).where(eq(schema.analysisRun.organizationId, orgId)) + await db.delete(schema.scoringCriterion).where(eq(schema.scoringCriterion.organizationId, orgId)) + await db.delete(schema.aiConfig).where(eq(schema.aiConfig.organizationId, orgId)) + await db.delete(schema.comment).where(eq(schema.comment.organizationId, orgId)) + await db.delete(schema.interview).where(eq(schema.interview.organizationId, orgId)) + await db.delete(schema.questionResponse).where(eq(schema.questionResponse.organizationId, orgId)) + await db.delete(schema.application).where(eq(schema.application.organizationId, orgId)) + await db.delete(schema.jobQuestion).where(eq(schema.jobQuestion.organizationId, orgId)) + await db.delete(schema.document).where(eq(schema.document.organizationId, orgId)) + await db.delete(schema.candidate).where(eq(schema.candidate.organizationId, orgId)) + await db.delete(schema.job).where(eq(schema.job.organizationId, orgId)) + await db.delete(schema.emailTemplate).where(eq(schema.emailTemplate.organizationId, orgId)) + await db.delete(schema.inviteLink).where(eq(schema.inviteLink.organizationId, orgId)) + await db.delete(schema.joinRequest).where(eq(schema.joinRequest.organizationId, orgId)) + await db.delete(schema.member).where(eq(schema.member.organizationId, orgId)) + await db.delete(schema.invitation).where(eq(schema.invitation.organizationId, orgId)) + await db.delete(schema.organization).where(eq(schema.organization.id, orgId)) + console.log(`✅ Deleted demo organization and all related data: ${orgId}`) + } + else { + console.log('ℹ️ No demo organization found — nothing to delete.') + } + + await client.end() +} + +main().catch((err) => { + console.error('❌ Failed:', err) + client.end().then(() => process.exit(1)) +}) diff --git a/server/scripts/seed.ts b/server/scripts/seed.ts index 728a2930..b45c286e 100644 --- a/server/scripts/seed.ts +++ b/server/scripts/seed.ts @@ -10,6 +10,7 @@ * - Custom questions on select jobs * - Question responses on applications * - 35+ scheduled/completed interviews across the pipeline + * - 200+ activity log entries covering all action types (for Timeline page) * * Usage: npx tsx server/scripts/seed.ts * Requires DATABASE_URL in .env (loaded via dotenv or shell env). @@ -2235,19 +2236,24 @@ async function seed() { // 8. Create interviews let totalInterviews = 0 + const interviewIds: string[] = [] // track IDs for activity log for (const iv of INTERVIEWS_DATA) { const applicationId = applicationMap.get(`${iv.jobIndex}-${iv.candidateIndex}`) if (!applicationId) { console.warn(`⚠️ Skipping interview "${iv.title}" — no application found for job ${iv.jobIndex}, candidate ${iv.candidateIndex}`) + interviewIds.push('') // placeholder to keep index alignment continue } const scheduledAt = dateWithOffset(iv.daysOffset, iv.hour, iv.minute ?? 0) const responded = iv.candidateResponse !== 'pending' + const interviewId = id() + interviewIds.push(interviewId) + await db.insert(schema.interview).values({ - id: id(), + id: interviewId, organizationId: orgId, applicationId, title: iv.title, @@ -2272,6 +2278,150 @@ async function seed() { console.log(`✅ Created ${totalInterviews} interviews across the pipeline`) + // 9. Create activity log entries so the Timeline page is populated + let totalActivities = 0 + + // --- Job creation activities --- + for (let i = 0; i < JOBS_DATA.length; i++) { + const jobData = JOBS_DATA[i] + const jobId = jobIds[i] + if (!jobData || !jobId) continue + + await db.insert(schema.activityLog).values({ + id: id(), + organizationId: orgId, + actorId: userId, + action: 'created', + resourceType: 'job', + resourceId: jobId, + metadata: { title: jobData.title }, + createdAt: daysAgo(20 + Math.floor(Math.random() * 10)), + }) + totalActivities++ + } + + // --- Candidate creation activities --- + for (let i = 0; i < CANDIDATES_DATA.length; i++) { + const c = CANDIDATES_DATA[i] + const cId = candidateIds[i] + if (!c || !cId) continue + + await db.insert(schema.activityLog).values({ + id: id(), + organizationId: orgId, + actorId: userId, + action: 'created', + resourceType: 'candidate', + resourceId: cId, + metadata: { name: `${c.firstName} ${c.lastName}` }, + createdAt: daysAgo(5 + Math.floor(Math.random() * 20)), + }) + totalActivities++ + } + + // --- Application creation + status change activities --- + // Maps each application's final status to a realistic sequence of transitions + const STATUS_PIPELINE: Record = { + new: [], + screening: ['screening'], + interview: ['screening', 'interview'], + offer: ['screening', 'interview', 'offer'], + hired: ['screening', 'interview', 'offer', 'hired'], + rejected: ['rejected'], // rejected can happen at any stage + } + + for (let jobIndex = 0; jobIndex < JOB_APPLICATIONS.length; jobIndex++) { + const apps = JOB_APPLICATIONS[jobIndex] + const jobId = jobIds[jobIndex] + if (!apps || !jobId) continue + + for (const app of apps) { + const candidateId = candidateIds[app.candidateIndex] + const appId = applicationMap.get(`${jobIndex}-${app.candidateIndex}`) + if (!candidateId || !appId) continue + + // Application created activity + const appCreatedDays = 1 + Math.floor(Math.random() * 15) + await db.insert(schema.activityLog).values({ + id: id(), + organizationId: orgId, + actorId: userId, + action: 'created', + resourceType: 'application', + resourceId: appId, + metadata: { candidateId, jobId }, + createdAt: daysAgo(appCreatedDays), + }) + totalActivities++ + + // Status change activities along the pipeline + const transitions = STATUS_PIPELINE[app.status] ?? [] + let previousStatus: AppStatus = 'new' + for (let t = 0; t < transitions.length; t++) { + const toStatus = transitions[t]! + const transitionDays = Math.max(0, appCreatedDays - (t + 1) * 2) + await db.insert(schema.activityLog).values({ + id: id(), + organizationId: orgId, + actorId: userId, + action: 'status_changed', + resourceType: 'application', + resourceId: appId, + metadata: { from: previousStatus, to: toStatus }, + createdAt: daysAgo(transitionDays), + }) + totalActivities++ + previousStatus = toStatus + } + + // Scored activity for applications with a score + if (app.score) { + await db.insert(schema.activityLog).values({ + id: id(), + organizationId: orgId, + actorId: userId, + action: 'scored', + resourceType: 'application', + resourceId: appId, + metadata: { compositeScore: app.score, model: 'gpt-4o-mini', criterionCount: 5 }, + createdAt: daysAgo(Math.max(0, appCreatedDays - 1)), + }) + totalActivities++ + } + } + } + + // --- Interview creation activities --- + for (let i = 0; i < INTERVIEWS_DATA.length; i++) { + const iv = INTERVIEWS_DATA[i] + const interviewId = interviewIds[i] + if (!iv || !interviewId) continue + + const appId = applicationMap.get(`${iv.jobIndex}-${iv.candidateIndex}`) + if (!appId) continue + + const scheduledAt = dateWithOffset(iv.daysOffset, iv.hour, iv.minute ?? 0) + const interviewCreatedDays = Math.abs(iv.daysOffset) + 4 + + await db.insert(schema.activityLog).values({ + id: id(), + organizationId: orgId, + actorId: userId, + action: 'created', + resourceType: 'interview', + resourceId: interviewId, + metadata: { + applicationId: appId, + title: iv.title, + scheduledAt: scheduledAt.toISOString(), + }, + createdAt: daysAgo(interviewCreatedDays), + }) + totalActivities++ + } + + console.log(`✅ Created ${totalActivities} activity log entries for timeline`) + // Summary const statusCounts: Record = {} for (const apps of JOB_APPLICATIONS) { diff --git a/server/utils/auth.ts b/server/utils/auth.ts index d66b42ac..a4108e12 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -16,9 +16,11 @@ function resolveTrustedOrigins(baseUrl: string): string[] { ? [ 'http://localhost:3000', 'http://localhost:3001', + 'http://localhost:3002', 'http://localhost:3333', 'http://127.0.0.1:3000', 'http://127.0.0.1:3001', + 'http://127.0.0.1:3002', 'http://127.0.0.1:3333', ] : [] diff --git a/server/utils/email.ts b/server/utils/email.ts index abe2b779..f5e17bd4 100644 --- a/server/utils/email.ts +++ b/server/utils/email.ts @@ -68,7 +68,10 @@ export async function sendOrgInvitationEmail(data: { }) if (error) { - console.error('[Reqcore] Failed to send invitation email via Resend:', error) + logError('email.invitation_send_failed', { + provider: 'resend', + error_message: error.message, + }) throw new Error(`Failed to send invitation email: ${error.message}`) } @@ -289,7 +292,10 @@ export async function sendInterviewInvitationEmail(params: { }) if (error) { - console.error('[Reqcore] Failed to send interview invitation email via Resend:', error) + logError('email.interview_invitation_send_failed', { + provider: 'resend', + error_message: error.message, + }) throw new Error(`Failed to send interview invitation email: ${error.message}`) } diff --git a/server/utils/google-calendar.ts b/server/utils/google-calendar.ts index 79d81a2c..6a20faea 100644 --- a/server/utils/google-calendar.ts +++ b/server/utils/google-calendar.ts @@ -116,7 +116,9 @@ export async function getCalendarClient(userId: string): Promise { } catch (err) { // Non-critical — channel may have already expired - console.warn('[Calendar] Failed to stop webhook channel on disconnect:', err) + logWarn('calendar.webhook_channel_stop_failed', { + posthog_distinct_id: userId, + error_message: err instanceof Error ? err.message : String(err), + }) } } @@ -318,7 +326,10 @@ export async function createCalendarEvent( return { id, htmlLink } } catch (err) { - console.error('[Calendar] Failed to create event:', err) + logError('calendar.create_event_failed', { + posthog_distinct_id: userId, + error_message: err instanceof Error ? err.message : String(err), + }) return null } } @@ -392,7 +403,11 @@ export async function updateCalendarEvent( return response.data.htmlLink ?? null } catch (err) { - console.error('[Calendar] Failed to update event:', err) + logError('calendar.update_event_failed', { + posthog_distinct_id: userId, + event_id: eventId, + error_message: err instanceof Error ? err.message : String(err), + }) return null } } @@ -424,7 +439,11 @@ export async function cancelCalendarEvent( return true } catch (err) { - console.error('[Calendar] Failed to cancel event:', err) + logError('calendar.cancel_event_failed', { + posthog_distinct_id: userId, + event_id: eventId, + error_message: err instanceof Error ? err.message : String(err), + }) return false } } @@ -502,7 +521,10 @@ export async function setupCalendarWebhook(userId: string): Promise { return true } catch (err) { - console.error('[Calendar] Failed to setup webhook:', err) + logError('calendar.webhook_setup_failed', { + posthog_distinct_id: userId, + error_message: err instanceof Error ? err.message : String(err), + }) return false } } @@ -563,7 +585,9 @@ export async function performIncrementalSync(userId: string): Promise { return performIncrementalSync(userId) } // Already cleared syncToken but still getting 410 — bail out - console.error('[Calendar] Persistent 410 error during sync, aborting') + logError('calendar.persistent_410_error', { + posthog_distinct_id: userId, + }) return } throw err @@ -588,7 +612,10 @@ export async function performIncrementalSync(userId: string): Promise { } } catch (err) { - console.error('[Calendar] Incremental sync failed:', err) + logError('calendar.incremental_sync_failed', { + posthog_distinct_id: userId, + error_message: err instanceof Error ? err.message : String(err), + }) } } diff --git a/server/utils/logger.ts b/server/utils/logger.ts new file mode 100644 index 00000000..b2dc61a3 --- /dev/null +++ b/server/utils/logger.ts @@ -0,0 +1,211 @@ +import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs' +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' +import { logs, SeverityNumber } from '@opentelemetry/api-logs' +import type { AnyValueMap } from '@opentelemetry/api-logs' +import { resourceFromAttributes } from '@opentelemetry/resources' +import type { H3Event } from 'h3' +import { version as APP_VERSION } from '../../package.json' + +let loggerProvider: LoggerProvider | null = null + +/** + * Initialize the OpenTelemetry LoggerProvider that sends structured logs + * to PostHog via OTLP HTTP. + * + * Call once during server startup (Nitro plugin). Subsequent calls are no-ops. + */ +export function initLoggerProvider(): void { + if (loggerProvider) return + + const token = process.env.POSTHOG_PUBLIC_KEY + if (!token) return + + const host = process.env.POSTHOG_HOST || 'https://eu.i.posthog.com' + + loggerProvider = new LoggerProvider({ + resource: resourceFromAttributes({ + 'service.name': 'reqcore', + 'service.version': APP_VERSION, + 'deployment.environment': process.env.RAILWAY_ENVIRONMENT_NAME || 'development', + }), + processors: [ + new BatchLogRecordProcessor( + new OTLPLogExporter({ + url: `${host}/i/v1/logs`, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }), + ), + ], + }) + + logs.setGlobalLoggerProvider(loggerProvider) +} + +/** + * Flush pending logs and shut down the provider. + * Call during server shutdown so buffered logs aren't lost. + */ +export async function shutdownLoggerProvider(): Promise { + if (!loggerProvider) return + await loggerProvider.forceFlush() + await loggerProvider.shutdown() + loggerProvider = null +} + +// ───────────────────────────────────────────── +// Convenience logger — wraps the OTel API +// ───────────────────────────────────────────── + +function getLogger() { + return logs.getLogger('reqcore') +} + +interface LogContext { + posthog_distinct_id?: string + org_id?: string + [key: string]: string | number | boolean | null | undefined +} + +/** + * Emit an INFO-level structured log to PostHog. + */ +export function logInfo(body: string, attributes?: LogContext): void { + try { + getLogger().emit({ + severityNumber: SeverityNumber.INFO, + severityText: 'INFO', + body, + attributes: attributes as AnyValueMap, + }) + } + catch { + // Logging must never break the primary operation + } +} + +/** + * Emit a WARN-level structured log to PostHog. + */ +export function logWarn(body: string, attributes?: LogContext): void { + try { + getLogger().emit({ + severityNumber: SeverityNumber.WARN, + severityText: 'WARN', + body, + attributes: attributes as AnyValueMap, + }) + } + catch { + // Logging must never break the primary operation + } +} + +/** + * Emit an ERROR-level structured log to PostHog. + */ +export function logError(body: string, attributes?: LogContext): void { + try { + getLogger().emit({ + severityNumber: SeverityNumber.ERROR, + severityText: 'ERROR', + body, + attributes: attributes as AnyValueMap, + }) + } + catch { + // Logging must never break the primary operation + } +} + +/** + * Emit a DEBUG-level structured log to PostHog. + * Use for detailed diagnostics during active investigation. Off in production + * by default — enable selectively for specific services. + */ +export function logDebug(body: string, attributes?: LogContext): void { + try { + getLogger().emit({ + severityNumber: SeverityNumber.DEBUG, + severityText: 'DEBUG', + body, + attributes: attributes as AnyValueMap, + }) + } + catch { + // Logging must never break the primary operation + } +} + +/** + * Extract common request attributes from an H3 event for wide-event logging. + * Includes PostHog session_id for Session Replay linking when available. + */ +export function requestAttributes(event: H3Event): Record { + const headers = getHeaders(event) + // Extract PostHog session_id from the cookie for Session Replay linking. + // The ph__posthog cookie stores a JSON blob; $sesid contains the + // active session ID. We also extract the distinct_id for identity linking. + let sessionId: string | undefined + let cookieDistinctId: string | undefined + try { + const phCookie = getCookie(event, 'ph_reqcore_posthog') + if (phCookie) { + const parsed = JSON.parse(phCookie) + sessionId = parsed?.$sesid?.[1] + cookieDistinctId = parsed?.distinct_id + } + } + catch { + // Cookie may be missing or malformed — non-critical + } + return { + http_method: getMethod(event), + http_path: getRequestURL(event).pathname, + user_agent: headers['user-agent'], + ...(sessionId ? { '$session_id': sessionId } : {}), + ...(cookieDistinctId ? { posthog_distinct_id: cookieDistinctId } : {}), + } +} + +interface SessionInfo { + user: { id: string } + session: { activeOrganizationId: string } +} + +/** + * Build a wide-event log for a completed API request. + * Follows PostHog best practices: one structured log per request with full context. + */ +export function logApiRequest( + event: H3Event, + session: SessionInfo | null, + body: string, + extra?: Record, +): void { + logInfo(body, { + ...requestAttributes(event), + posthog_distinct_id: session?.user?.id, + org_id: session?.session?.activeOrganizationId, + ...extra, + }) +} + +/** + * Log an API error as a wide event with full request context. + */ +export function logApiError( + event: H3Event, + session: SessionInfo | null, + body: string, + extra?: Record, +): void { + logError(body, { + ...requestAttributes(event), + posthog_distinct_id: session?.user?.id, + org_id: session?.session?.activeOrganizationId, + ...extra, + }) +} diff --git a/server/utils/posthog.ts b/server/utils/posthog.ts index 45881305..1e3db4db 100644 --- a/server/utils/posthog.ts +++ b/server/utils/posthog.ts @@ -1,4 +1,5 @@ import { PostHog } from 'posthog-node' +import { version as APP_VERSION } from '../../package.json' let client: PostHog | null = null @@ -24,6 +25,16 @@ export function useServerPostHog(): PostHog | null { // Flush events every 10 seconds or 20 events, whichever comes first flushAt: 20, flushInterval: 10_000, + // Enable automatic capture of uncaught exceptions and unhandled rejections + enableExceptionAutocapture: true, + }) + + // Register super properties included with every server-side event + client.register({ + $app_name: 'reqcore', + $app_version: APP_VERSION, + $environment: process.env.RAILWAY_ENVIRONMENT_NAME || 'development', + $source: 'server', }) return client diff --git a/server/utils/recordActivity.ts b/server/utils/recordActivity.ts index 684436db..de38107b 100644 --- a/server/utils/recordActivity.ts +++ b/server/utils/recordActivity.ts @@ -29,6 +29,11 @@ export async function recordActivity(params: { } catch (err) { // Activity logging must never break the primary operation. - console.error('[Reqcore] Failed to record activity:', err) + logWarn('activity.record_failed', { + org_id: params.organizationId, + resource_type: params.resourceType, + resource_id: params.resourceId, + error_message: err instanceof Error ? err.message : String(err), + }) } } diff --git a/server/utils/resume-parser.ts b/server/utils/resume-parser.ts index a2307ce7..4c4c6521 100644 --- a/server/utils/resume-parser.ts +++ b/server/utils/resume-parser.ts @@ -81,12 +81,17 @@ export async function parseDocument( case 'application/msword': return await parseDoc(buffer) default: - console.warn(`[ResumeParser] Unsupported MIME type: ${mimeType}`) + logWarn('resume_parser.unsupported_mime_type', { + mime_type: mimeType, + }) return null } } catch (error) { - console.error('[ResumeParser] Failed to parse document:', error) + logError('resume_parser.parse_failed', { + mime_type: mimeType, + error_message: error instanceof Error ? error.message : String(error), + }) return null } } diff --git a/server/utils/trackEvent.ts b/server/utils/trackEvent.ts new file mode 100644 index 00000000..0e385003 --- /dev/null +++ b/server/utils/trackEvent.ts @@ -0,0 +1,129 @@ +import type { H3Event } from 'h3' + +interface TrackSession { + user: { id: string } + session: { activeOrganizationId: string } +} + +/** + * Fire-and-forget server-side PostHog event capture. + * + * Automatically enriches events with: + * - `distinctId` from the authenticated session + * - `organization` group for org-scoped analytics + * - Request context: HTTP method, path, user agent + * + * Usage in API handlers: + * const session = await requirePermission(event, { application: ['update'] }) + * trackEvent(event, session, 'application status_changed', { from: 'new', to: 'screening' }) + */ +export function trackEvent( + event: H3Event, + session: TrackSession | null, + eventName: string, + properties?: Record, +): void { + try { + const ph = useServerPostHog() + if (!ph) return + + const userId = session?.user?.id || 'anonymous' + const orgId = session?.session?.activeOrganizationId + + const headers = getHeaders(event) + const method = getMethod(event) + const path = getRequestURL(event).pathname + + ph.capture({ + distinctId: userId, + event: eventName, + groups: orgId ? { organization: orgId } : undefined, + properties: { + // Request context — invaluable for debugging per-user issues + $request_method: method, + $request_path: path, + $user_agent: headers['user-agent'], + ...properties, + }, + }) + } + catch { + // Tracking must never break the primary operation + } +} + +/** + * Track an API error event to PostHog (for the API tracking middleware). + * Unlike trackEvent, this doesn't require a session — uses anonymous tracking. + */ +export function trackApiError( + event: H3Event, + statusCode: number, + properties?: Record, +): void { + try { + const ph = useServerPostHog() + if (!ph) return + + const headers = getHeaders(event) + const method = getMethod(event) + const path = getRequestURL(event).pathname + + // Use the PostHog anonymous distinct ID from the cookie if available, + // otherwise use a request-scoped identifier. + const distinctId = getCookie(event, 'ph_reqcore_posthog') || 'server-anonymous' + + ph.capture({ + distinctId, + event: 'api error', + properties: { + status_code: statusCode, + error_category: statusCode >= 500 ? 'server' : 'client', + $request_method: method, + $request_path: path, + $user_agent: headers['user-agent'], + ...properties, + }, + }) + } + catch { + // Tracking must never break the primary operation + } +} + +/** + * Capture a server-side exception to PostHog error tracking. + * + * Uses captureException() (not capture('$exception')) per PostHog docs + * to ensure correct stack trace processing and source map integration. + */ +export function trackServerError( + event: H3Event, + session: TrackSession | null, + error: unknown, + properties?: Record, +): void { + try { + const ph = useServerPostHog() + if (!ph) return + + const userId = session?.user?.id || 'anonymous' + const headers = getHeaders(event) + const method = getMethod(event) + const path = getRequestURL(event).pathname + + ph.captureException( + error instanceof Error ? error : new Error(String(error)), + userId, + { + $request_method: method, + $request_path: path, + $user_agent: headers['user-agent'], + ...properties, + }, + ) + } + catch { + // Tracking must never break the primary operation + } +} diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 00000000..1840843f --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,11 @@ +/** + * Vitest global setup — stubs Nitro auto-imported server utilities + * that are unavailable outside the Nuxt/Nitro runtime. + */ +import { vi } from 'vitest' + +// Stub the structured logger functions (auto-imported from server/utils/logger.ts) +vi.stubGlobal('logInfo', vi.fn()) +vi.stubGlobal('logWarn', vi.fn()) +vi.stubGlobal('logError', vi.fn()) +vi.stubGlobal('logDebug', vi.fn()) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..44a5202f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + setupFiles: ['./tests/setup.ts'], + include: ['tests/**/*.test.ts'], + }, +})