diff --git a/.gitignore b/.gitignore index 465b9996..a0a1feca 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ playwright/.cache/ *.code-workspace + +# Snyk Security Extension - AI Rules (auto-generated) +.github/instructions/snyk_rules.instructions.md diff --git a/app/components/AppTopBar.vue b/app/components/AppTopBar.vue index 085490bd..b8dce837 100644 --- a/app/components/AppTopBar.vue +++ b/app/components/AppTopBar.vue @@ -119,14 +119,14 @@ const jobTabs = computed(() => { // Main navigation // ───────────────────────────────────────────── -const mainNav = [ +const mainNav: Array<{ label: string; to: string; icon: typeof Briefcase; exact: boolean; comingSoon?: boolean }> = [ { label: 'Dashboard', to: '/dashboard', icon: LayoutDashboard, exact: true }, { label: 'Jobs', to: '/dashboard/jobs', icon: Briefcase, exact: false }, { 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: 'Source Tracking', to: '/dashboard/source-tracking', icon: Radio, exact: 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 db6fc984..942aa621 100644 --- a/app/components/CandidateDetailSidebar.vue +++ b/app/components/CandidateDetailSidebar.vue @@ -2,7 +2,7 @@ import { X, User, Calendar, Clock, Hash, MessageSquare, FileText, ExternalLink, Mail, Phone, Upload, Download, Eye, Trash2, - ArrowLeft, AlertTriangle, Brain, + ArrowLeft, AlertTriangle, Brain, History, } from 'lucide-vue-next' import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly' @@ -34,7 +34,7 @@ const hasSubNav = computed(() => { // Tabs // ───────────────────────────────────────────── -const activeTab = ref<'overview' | 'documents' | 'responses' | 'ai_analysis'>('overview') +const activeTab = ref<'overview' | 'documents' | 'responses' | 'ai_analysis' | 'timeline'>('overview') // ───────────────────────────────────────────── // Fetch application detail @@ -288,12 +288,109 @@ function onKeydown(e: KeyboardEvent) { onMounted(() => window.addEventListener('keydown', onKeydown)) onUnmounted(() => window.removeEventListener('keydown', onKeydown)) +// ───────────────────────────────────────────── +// Timeline data for the candidate +// ───────────────────────────────────────────── + +interface TimelineEntry { + id: string + action: string + resourceType: string + resourceId: string + metadata: Record | null + createdAt: string + actorName: string | null + actorEmail: string | null + resourceName: string | null + jobTitle: string | null + candidateName: string | null +} + +const timelineItems = ref([]) +const timelineLoading = ref(false) +const timelineError = ref(null) +const timelineLoaded = ref(false) + +const timelineActionLabels: Record = { + created: 'Created', + updated: 'Updated', + deleted: 'Deleted', + status_changed: 'Status changed', + comment_added: 'Comment added', + scored: 'Scored', + scheduled: 'Scheduled', +} + +function formatTimelineDate(dateStr: string) { + const d = new Date(dateStr) + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) +} + +function getTimelineActionColor(action: string): string { + switch (action) { + case 'created': return 'bg-green-500' + case 'status_changed': return 'bg-blue-500' + case 'updated': return 'bg-amber-500' + case 'deleted': return 'bg-danger-500' + case 'comment_added': return 'bg-violet-500' + case 'scored': return 'bg-teal-500' + case 'scheduled': return 'bg-brand-500' + default: return 'bg-surface-400' + } +} + +function describeTimelineItem(item: TimelineEntry): string { + const actor = item.actorName ?? item.actorEmail ?? 'System' + const action = timelineActionLabels[item.action] ?? item.action + const resource = item.resourceType + + if (item.action === 'status_changed' && item.metadata) { + const from = item.metadata.from_status ?? item.metadata.fromStatus + const to = item.metadata.to_status ?? item.metadata.toStatus + if (from && to) return `${actor} changed ${resource} status from ${from} to ${to}` + } + + if (item.action === 'scored' && item.metadata) { + const score = item.metadata.score + if (score != null) return `${actor} scored ${resource} — ${score} pts` + } + + return `${actor} ${action.toLowerCase()} ${resource}` +} + +async function loadTimeline() { + if (!candidateId.value) return + timelineLoading.value = true + timelineError.value = null + try { + const result = await $fetch<{ items: TimelineEntry[] }>('/api/activity-log/candidate-timeline', { + query: { candidateId: candidateId.value }, + }) + timelineItems.value = result.items + timelineLoaded.value = true + } catch (err: any) { + timelineError.value = err?.data?.statusMessage ?? 'Failed to load timeline' + } finally { + timelineLoading.value = false + } +} + +// Load timeline data lazily when tab is selected +watch(activeTab, (tab) => { + if (tab === 'timeline' && !timelineLoaded.value && candidateId.value) { + loadTimeline() + } +}) + // Reset state when switching to a different application watch(() => props.applicationId, () => { isEditingNotes.value = false activeTab.value = 'overview' uploadError.value = null showDocDeleteConfirm.value = null + timelineItems.value = [] + timelineLoaded.value = false + timelineError.value = null closePreview() }) @@ -432,6 +529,16 @@ function formatInterviewDate(dateStr: string) { AI Analysis + @@ -882,6 +989,82 @@ function formatInterviewDate(dateStr: string) { + + + +
+ +
+
+ Loading timeline… +
+ + +
+ +

{{ timelineError }}

+ +
+ + +
+
+ +
+

No activity recorded yet.

+

Activity for this candidate will appear here.

+
+ + +
+ +
+ +
+ +
+
+
+ + +
+

+ {{ describeTimelineItem(item) }} +

+
+ + {{ formatTimelineDate(item.createdAt) }} + + + {{ item.jobTitle }} + +
+
+
+
+
+
diff --git a/app/composables/useSourceTracking.ts b/app/composables/useSourceTracking.ts new file mode 100644 index 00000000..994c9b86 --- /dev/null +++ b/app/composables/useSourceTracking.ts @@ -0,0 +1,211 @@ +/** + * Composable for source tracking analytics and tracking link management. + * Provides data for the source tracking dashboard and link CRUD operations. + */ + +interface TrackingLink { + id: string + jobId: string | null + jobTitle: string | null + channel: string + name: string + code: string + utmSource: string | null + utmMedium: string | null + utmCampaign: string | null + utmTerm: string | null + utmContent: string | null + clickCount: number + applicationCount: number + isActive: boolean + createdAt: string + updatedAt: string +} + +interface SourceStats { + channelBreakdown: { channel: string; count: number }[] + topLinks: { + id: string + name: string + channel: string + code: string + jobTitle: string | null + clickCount: number + applicationCount: number + isActive: boolean + }[] + funnel: Record> + dailyTrend: { date: string; channel: string; count: number }[] + recentAttributed: { + applicationId: string + jobId: string + channel: string + utmSource: string | null + utmCampaign: string | null + referrerDomain: string | null + trackingLinkName: string | null + candidateFirstName: string + candidateLastName: string + candidateEmail: string + jobTitle: string + status: string + appliedAt: string + }[] + topReferrerDomains: { domain: string | null; count: number }[] + summary: { + totalTracked: number + totalUntracked: number + attributionRate: number + } +} + +export function useSourceTracking(options?: { + jobId?: Ref | string + from?: Ref | string + to?: Ref | string +}) { + const jobId = computed(() => toValue(options?.jobId)) + const from = computed(() => toValue(options?.from)) + const to = computed(() => toValue(options?.to)) + + // ─── Source stats ───────────────────────── + const statsUrl = computed(() => { + const params = new URLSearchParams() + if (jobId.value) params.set('jobId', jobId.value) + if (from.value) params.set('from', from.value) + if (to.value) params.set('to', to.value) + const qs = params.toString() + return `/api/source-tracking/stats${qs ? `?${qs}` : ''}` + }) + + const { + data: stats, + status: statsStatus, + error: statsError, + refresh: refreshStats, + } = useFetch(statsUrl, { + headers: useRequestHeaders(['cookie']), + }) + + const channelBreakdown = computed(() => stats.value?.channelBreakdown ?? []) + const topLinks = computed(() => stats.value?.topLinks ?? []) + const funnel = computed(() => stats.value?.funnel ?? {}) + const dailyTrend = computed(() => stats.value?.dailyTrend ?? []) + const recentAttributed = computed(() => stats.value?.recentAttributed ?? []) + const topReferrerDomains = computed(() => stats.value?.topReferrerDomains ?? []) + const summary = computed(() => stats.value?.summary ?? { totalTracked: 0, totalUntracked: 0, attributionRate: 0 }) + + return { + channelBreakdown, + topLinks, + funnel, + dailyTrend, + recentAttributed, + topReferrerDomains, + summary, + statsStatus, + statsError, + refreshStats, + } +} + +/** + * Composable for managing tracking links (CRUD operations). + */ +export function useTrackingLinks(options?: { + jobId?: Ref | string + channel?: Ref | string +}) { + const toast = useToast() + + const jobId = computed(() => toValue(options?.jobId)) + const channel = computed(() => toValue(options?.channel)) + + const fetchKey = computed(() => { + const parts = ['tracking-links'] + if (jobId.value) parts.push(jobId.value) + if (channel.value) parts.push(channel.value) + return parts.join(':') + }) + + const linksUrl = computed(() => { + const params = new URLSearchParams() + if (jobId.value) params.set('jobId', jobId.value) + if (channel.value) params.set('channel', channel.value) + const qs = params.toString() + return `/api/tracking-links${qs ? `?${qs}` : ''}` + }) + + const { + data, + status: fetchStatus, + error, + refresh, + } = useFetch<{ data: TrackingLink[]; total: number }>(linksUrl, { + key: fetchKey.value, + headers: useRequestHeaders(['cookie']), + }) + + const links = computed(() => data.value?.data ?? []) + const total = computed(() => data.value?.total ?? 0) + + async function createLink(payload: { + jobId?: string + channel?: string + name: string + utmSource?: string + utmMedium?: string + utmCampaign?: string + utmTerm?: string + utmContent?: string + }) { + const created = await $fetch('/api/tracking-links', { + method: 'POST', + body: payload, + }) + await refresh() + toast.success('Tracking link created') + return created + } + + async function updateLink(id: string, payload: { + name?: string + channel?: string + utmSource?: string + utmMedium?: string + utmCampaign?: string + utmTerm?: string + utmContent?: string + isActive?: boolean + }) { + const updated = await $fetch(`/api/tracking-links/${id}`, { + method: 'PATCH', + body: payload, + }) + await refresh() + return updated + } + + async function deleteLink(id: string) { + await $fetch(`/api/tracking-links/${id}`, { method: 'DELETE' }) + await refresh() + toast.success('Tracking link deleted') + } + + async function toggleLink(id: string, isActive: boolean) { + await updateLink(id, { isActive }) + toast.success(isActive ? 'Link activated' : 'Link deactivated') + } + + return { + links, + total, + fetchStatus, + error, + refresh, + createLink, + updateLink, + deleteLink, + toggleLink, + } +} diff --git a/app/pages/dashboard/ai-analysis.vue b/app/pages/dashboard/ai-analysis.vue index a7ce9763..6f71f964 100644 --- a/app/pages/dashboard/ai-analysis.vue +++ b/app/pages/dashboard/ai-analysis.vue @@ -186,98 +186,93 @@ function statusBadgeClass(status: string): string {
-
-
+
+
+
-
- Total Runs -
- -
-
-
- {{ formatNumber(summary.totalRuns) }} +
+ + {{ formatNumber(summary.totalRuns) }} + +
-
+ Total Runs +
- {{ summary.completedRuns }} completed + {{ summary.completedRuns }} - {{ summary.failedRuns }} failed + {{ summary.failedRuns }}
-
-
+
+
+
-
- Success Rate -
- -
-
-
- {{ successRate }}% +
+ + {{ successRate }}% + +
-

+ Success Rate +

{{ summary.completedRuns }} of {{ summary.totalRuns }} successful

-
-
+
+
+
-
- Prompt Tokens -
- -
-
-
- {{ formatNumber(summary.totalPromptTokens) }} +
+ + {{ formatNumber(summary.totalPromptTokens) }} + +
-

Input tokens sent

+ Prompt Tokens +

Input tokens sent

-
-
+
+
+
-
- Completion Tokens -
- -
-
-
- {{ formatNumber(summary.totalCompletionTokens) }} +
+ + {{ formatNumber(summary.totalCompletionTokens) }} + +
-

Output tokens generated

+ Completion Tokens +

Output tokens generated

-
-
+
+
+
-
- Total Cost -
- -
-
-
- {{ pricing.configured ? formatCost(totalCost) : '—' }} +
+ + {{ pricing.configured ? formatCost(totalCost) : '—' }} + +
-

+ Total Cost +

diff --git a/app/pages/dashboard/jobs/[id]/index.vue b/app/pages/dashboard/jobs/[id]/index.vue index 85bed7cc..f7103f61 100644 --- a/app/pages/dashboard/jobs/[id]/index.vue +++ b/app/pages/dashboard/jobs/[id]/index.vue @@ -5,7 +5,7 @@ import { UserPlus, Pencil, Trash2, MoreHorizontal, Globe, ChevronDown, X, Video, Building2, Code2, UsersRound, Save, Check, MapPin, Users, Plus, CheckCircle2, XCircle, AlertTriangle, ArrowUpDown, ListFilter, - Maximize2, Minimize2, Brain, Loader2, + Maximize2, Minimize2, Brain, Loader2, History, } from 'lucide-vue-next' import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly' import { APPLICATION_STATUS_TRANSITIONS, JOB_STATUS_TRANSITIONS, INTERVIEW_STATUS_TRANSITIONS } from '~~/shared/status-transitions' @@ -243,7 +243,7 @@ watch(currentIndex, () => { const currentSummary = computed(() => filteredApplications.value[currentIndex.value] ?? null) // Detail tab for center panel -type DetailTab = 'overview' | 'interviews' | 'documents' | 'responses' | 'ai-analysis' +type DetailTab = 'overview' | 'interviews' | 'documents' | 'responses' | 'ai-analysis' | 'timeline' const detailTab = ref('overview') // Overview section visibility toggles @@ -277,8 +277,98 @@ const showSection = computed(() => ({ interviews: detailTab.value === 'overview' ? overviewSections.interviews : detailTab.value === 'interviews', documents: detailTab.value === 'overview' ? overviewSections.documents : detailTab.value === 'documents', responses: detailTab.value === 'overview' ? overviewSections.responses : detailTab.value === 'responses', + timeline: detailTab.value === 'timeline', })) +// ───────────────────────────────────────────── +// Timeline +// ───────────────────────────────────────────── + +interface TimelineEntry { + id: string + action: string + resourceType: string + resourceId: string + metadata: Record | null + createdAt: string + actorName: string | null + actorEmail: string | null + resourceName: string | null + jobTitle: string | null + candidateName: string | null +} + +const timelineItems = ref([]) +const timelineLoading = ref(false) +const timelineError = ref(null) +const timelineLoaded = ref(false) + +const timelineActionLabels: Record = { + created: 'Created', + updated: 'Updated', + deleted: 'Deleted', + status_changed: 'Status changed', + comment_added: 'Comment added', + scored: 'Scored', + scheduled: 'Scheduled', +} + +function formatTimelineDate(dateStr: string) { + const d = new Date(dateStr) + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) +} + +interface TimelineActionStyle { + icon: typeof Plus + color: string + bg: string +} + +function getTimelineActionStyle(action: string): TimelineActionStyle { + const map: Record = { + created: { icon: Plus, color: 'text-success-600 dark:text-success-400', bg: 'bg-success-50 dark:bg-success-950/50' }, + updated: { icon: Pencil, color: 'text-brand-600 dark:text-brand-400', bg: 'bg-brand-50 dark:bg-brand-950/50' }, + deleted: { icon: Trash2, color: 'text-danger-600 dark:text-danger-400', bg: 'bg-danger-50 dark:bg-danger-950/50' }, + status_changed: { icon: ArrowRight, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-950/50' }, + comment_added: { icon: MessageSquare, color: 'text-violet-600 dark:text-violet-400', bg: 'bg-violet-50 dark:bg-violet-950/50' }, + scored: { icon: Brain, color: 'text-accent-600 dark:text-accent-400', bg: 'bg-accent-50 dark:bg-accent-950/50' }, + scheduled: { icon: Calendar, color: 'text-brand-600 dark:text-brand-400', bg: 'bg-brand-50 dark:bg-brand-950/50' }, + } + return map[action] ?? { icon: Clock, color: 'text-surface-500 dark:text-surface-400', bg: 'bg-surface-100 dark:bg-surface-800' } +} + +function getTimelineStatusBadge(status: string): string { + const s = status.toLowerCase() + const map: Record = { + new: 'bg-blue-100 text-blue-700 dark:bg-blue-900/60 dark:text-blue-300', + screening: 'bg-violet-100 text-violet-700 dark:bg-violet-900/60 dark:text-violet-300', + interview: 'bg-amber-100 text-amber-700 dark:bg-amber-900/60 dark:text-amber-300', + offer: 'bg-teal-100 text-teal-700 dark:bg-teal-900/60 dark:text-teal-300', + hired: 'bg-green-100 text-green-700 dark:bg-green-900/60 dark:text-green-300', + rejected: 'bg-surface-200 text-surface-600 dark:bg-surface-700 dark:text-surface-300', + } + return map[s] ?? 'bg-surface-100 text-surface-600 dark:bg-surface-800 dark:text-surface-300' +} + +function describeTimelineItem(item: TimelineEntry): string { + const actor = item.actorName ?? item.actorEmail ?? 'System' + const action = timelineActionLabels[item.action] ?? item.action + const resource = item.resourceType + + if (item.action === 'status_changed' && item.metadata) { + const from = item.metadata.from_status ?? item.metadata.fromStatus + const to = item.metadata.to_status ?? item.metadata.toStatus + if (from && to) return `${actor} changed ${resource} status from ${from} to ${to}` + } + + if (item.action === 'scored' && item.metadata) { + const score = item.metadata.score + if (score != null) return `${actor} scored ${resource} — ${score} pts` + } + + return `${actor} ${action.toLowerCase()} ${resource}` +} + // Section refs const overviewRef = ref(null) const interviewsRef = ref(null) @@ -340,6 +430,7 @@ const { { key: computed(() => `pipeline-application-${currentApplicationId.value}`), immediate: false, + watch: false, headers: useRequestHeaders(['cookie']), }, ) @@ -361,11 +452,43 @@ watch(currentApplication, (val) => { } }) +watch(currentApplicationId, () => { + timelineItems.value = [] + timelineLoaded.value = false + timelineError.value = null +}) + watch(currentApplicationId, async (id) => { if (!id) return await executeDetailFetch() }, { immediate: true }) +async function loadTimeline() { + const candId = resolvedCurrentApplication.value?.candidate?.id + if (!candId) return + timelineLoading.value = true + timelineError.value = null + try { + const result = await $fetch<{ items: TimelineEntry[] }>('/api/activity-log/candidate-timeline', { + query: { candidateId: candId }, + }) + timelineItems.value = result.items + timelineLoaded.value = true + } catch (err: any) { + timelineError.value = err?.data?.statusMessage ?? 'Failed to load timeline' + } finally { + timelineLoading.value = false + } +} + +const timelineCandidateId = computed(() => resolvedCurrentApplication.value?.candidate?.id) + +watch([detailTab, timelineCandidateId], () => { + if (detailTab.value === 'timeline' && !timelineLoaded.value && timelineCandidateId.value) { + loadTimeline() + } +}) + useSeoMeta({ title: computed(() => jobData.value ? `Pipeline — ${jobData.value.title} — Reqcore` : 'Pipeline — Reqcore', @@ -1806,6 +1929,16 @@ function closeDocPreview() { ({{ resolvedCurrentApplication.responses.length }}) +

@@ -2297,6 +2430,92 @@ function closeDocPreview() {
+ +
+

+ + Timeline +

+ + +
+
+ Loading timeline… +
+ + +
+ +

{{ timelineError }}

+ +
+ + +
+
+ +
+

No activity recorded yet.

+

Activity for this candidate will appear here.

+
+ + +
+ +
+ +
+
+ +
+ +
+ + +
+
+ {{ timelineActionLabels[item.action] ?? item.action }} + {{ item.resourceType }} + + +
+
+ {{ item.actorName ?? item.actorEmail }} + {{ formatTimelineDate(item.createdAt) }} + + {{ item.jobTitle }} + +
+
+
+
+
+
+
diff --git a/app/pages/dashboard/jobs/new.vue b/app/pages/dashboard/jobs/new.vue index cadeb464..ab38f1b9 100644 --- a/app/pages/dashboard/jobs/new.vue +++ b/app/pages/dashboard/jobs/new.vue @@ -26,6 +26,15 @@ import { Lock, Upload, CircleHelp, + Share2, + Globe, + Mail, + Users, + BarChart3, + Hash, + Megaphone, + Building2, + Search, } from 'lucide-vue-next' import { z } from 'zod' @@ -70,7 +79,7 @@ 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: 'AI scoring criteria', description: 'Define how AI evaluates candidates.' }, - { id: 4, title: 'Publish & share', description: 'Go live and share with candidates.' }, + { id: 4, title: 'Publish & distribute', description: 'Go live and share across job boards.' }, ] // Step 1: Job details (API-supported fields) @@ -324,7 +333,7 @@ watch(currentStep, (step) => { } }) -// Step 4: Publish & Share +// Step 4: Publish & Distribute const publishChoice = ref<'publish' | 'draft'>('publish') const isPublished = ref(false) const createdJobSlug = ref('') @@ -332,6 +341,125 @@ const createdJobId = ref('') const finalApplicationLink = ref('') const linkCopiedFinal = ref(false) +// Distribution channels for quick tracking link creation +const distributionChannels = [ + { channel: 'linkedin', name: 'LinkedIn', description: 'Post on LinkedIn Jobs or share in your feed', category: 'job_board' }, + { channel: 'indeed', name: 'Indeed', description: 'List on the Indeed job board', category: 'job_board' }, + { channel: 'glassdoor', name: 'Glassdoor', description: 'Publish on Glassdoor listings', category: 'job_board' }, + { channel: 'ziprecruiter', name: 'ZipRecruiter', description: 'Post on ZipRecruiter', category: 'job_board' }, + { channel: 'email', name: 'Email campaign', description: 'Send to candidates or mailing list', category: 'outreach' }, + { channel: 'referral', name: 'Employee referral', description: 'Share internally with your team', category: 'outreach' }, + { channel: 'career_site', name: 'Career site', description: 'Embed on your company website', category: 'outreach' }, + { channel: 'twitter', name: 'X (Twitter)', description: 'Share on your X timeline', category: 'social' }, + { channel: 'facebook', name: 'Facebook', description: 'Post on Facebook page or groups', category: 'social' }, + { channel: 'reddit', name: 'Reddit', description: 'Share in relevant subreddits', category: 'social' }, +] as const + +const channelIcons: Record = { + linkedin: Briefcase, + indeed: Search, + glassdoor: Building2, + ziprecruiter: Megaphone, + email: Mail, + referral: Users, + career_site: Globe, + twitter: Hash, + facebook: Users, + reddit: MessageSquare, +} + +// Track created distribution links: channel → { code, url, loading, copied } +const createdLinks = ref>({}) + +async function createChannelLink(channel: string, channelName: string) { + if (createdLinks.value[channel]?.code) return + createdLinks.value[channel] = { code: '', url: '', loading: true, copied: false } + try { + const result = await $fetch<{ id: string; code: string }>('/api/tracking-links', { + method: 'POST', + body: { + jobId: createdJobId.value, + channel, + name: `${form.value.title} — ${channelName}`, + }, + }) + const base = `${requestUrl.protocol}//${requestUrl.host}` + const trackUrl = `${base}/api/public/track/${encodeURIComponent(result.code)}` + createdLinks.value[channel] = { code: result.code, url: trackUrl, loading: false, copied: false } + track('tracking_link_created', { channel, source: 'job_wizard' }) + } catch { + delete createdLinks.value[channel] + toast.error(`Failed to create tracking link for ${channelName}`) + } +} + +async function copyChannelLink(channel: string) { + const link = createdLinks.value[channel] + if (!link?.url) return + try { + await navigator.clipboard.writeText(link.url) + link.copied = true + setTimeout(() => { link.copied = false }, 2500) + } catch { + toast.info(link.url) + } +} + +const createdLinkCount = computed(() => + Object.values(createdLinks.value).filter(l => l.code).length + customBoardLinks.value.length +) + +// Custom job board links +const customBoardName = ref('') +const customBoardLinks = ref>([]) +const isCreatingCustomBoard = ref(false) + +async function createCustomBoardLink() { + const name = customBoardName.value.trim() + if (!name) return + // Use a slug derived from the custom board name for local dedup only + const dedupeKey = `custom_${name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 50)}` + + // Prevent duplicates + if (customBoardLinks.value.some(l => l.channel === dedupeKey)) { + toast.warning('Duplicate board', `A custom link for "${name}" already exists.`) + return + } + + isCreatingCustomBoard.value = true + try { + const result = await $fetch<{ id: string; code: string }>('/api/tracking-links', { + method: 'POST', + body: { + jobId: createdJobId.value, + channel: 'custom', + name: `${form.value.title} — ${name}`, + }, + }) + const base = `${requestUrl.protocol}//${requestUrl.host}` + const trackUrl = `${base}/api/public/track/${encodeURIComponent(result.code)}` + customBoardLinks.value.push({ id: result.id, name, channel: dedupeKey, code: result.code, url: trackUrl, copied: false }) + customBoardName.value = '' + track('tracking_link_created', { channel: 'custom', customName: name, source: 'job_wizard_custom' }) + } catch { + toast.error(`Failed to create tracking link for "${name}"`) + } finally { + isCreatingCustomBoard.value = false + } +} + +async function copyCustomBoardLink(index: number) { + const link = customBoardLinks.value[index] + if (!link?.url) return + try { + await navigator.clipboard.writeText(link.url) + link.copied = true + setTimeout(() => { link.copied = false }, 2500) + } catch { + toast.info(link.url) + } +} + // Validation (only Step 1 is required to submit) const formSchema = z.object({ title: z @@ -1362,34 +1490,48 @@ const questionTypeLabels: Record = {
- +
-
-
- +
+ +
+
+ +
+
+

Your job is live!

+

+ {{ form.title }} is now accepting applications. +

+
+ + + Preview +
-

Your job is live!

-

- {{ form.title }} has been published and the application link has been copied to your clipboard. -

- -
-
- - Application Link + +
+
+ + Direct application link + (no tracking)
+ +
+
+ +

Distribute to job boards

+
+

+ Create tracked links for each platform. This lets you see exactly where your applicants come from. +

+ + +
+

Job boards

+
+
+
+
+ +
+
+ {{ ch.name }} + {{ ch.description }} +
+
+ + +
+ +
+ + +
+ + Creating... +
+ + +
+
+ + +
+

+ + Clicks and applications from this link will be tracked +

+
+
+
+
+ + +
+

Direct outreach

+
+
+
+
+ +
+
+ {{ ch.name }} + {{ ch.description }} +
+
+
+ +
+
+ + Creating... +
+
+
+ + +
+

+ + Clicks and applications from this link will be tracked +

+
+
+
+
+ + +
+

Social media

+
+
+
+
+ +
+
+ {{ ch.name }} + {{ ch.description }} +
+
+
+ +
+
+ + Creating... +
+
+
+ + +
+

+ + Clicks and applications from this link will be tracked +

+
+
+
+
+ + +
+

Custom job board

+

+ Create a tracked link for any platform not listed above. +

+ + +
+ + +
+ + +
+
+
+
+ +
+
+ {{ cbl.name }} + Custom job board +
+
+
+
+ + +
+

+ + Clicks and applications from this link will be tracked +

+
+
+
+
+ + +
+
+ +
+

+ + {{ createdLinkCount }} tracking {{ createdLinkCount === 1 ? 'link' : 'links' }} created. + + View all analytics and manage links in the + Source Tracking dashboard. +

+
+
+
+
+ -
- - - Preview form - +
= {

Ready to go?

- Choose how you'd like to save this job. You can always change the status later from the job detail page. + Publish your job to start receiving applications. After publishing, you'll be able to create tracked links for each platform where you share it.

@@ -1515,6 +1945,19 @@ const questionTypeLabels: Record = {
+ + +
+
+ +
+

After publishing

+

+ You'll get tracked links for LinkedIn, Indeed, and other platforms so you can see exactly where your applicants come from. +

+
+
+
@@ -1612,8 +2055,12 @@ const questionTypeLabels: Record = { Publishing makes the job visible to candidates. You can unpublish at any time from the job settings.
  • -

    Share the link

    - After publishing, the application link is automatically copied. Paste it in emails, Slack, or social media. +

    Use tracking links

    + Create a unique link for each platform (LinkedIn, Indeed, etc.) to see where your best applicants come from. +
  • +
  • +

    One link per channel

    + Each tracking link counts clicks and applications separately so you can compare which channels work best.
  • Drafts are private

    diff --git a/app/pages/dashboard/source-tracking.vue b/app/pages/dashboard/source-tracking.vue deleted file mode 100644 index 14663655..00000000 --- a/app/pages/dashboard/source-tracking.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/app/pages/dashboard/source-tracking/[id].vue b/app/pages/dashboard/source-tracking/[id].vue new file mode 100644 index 00000000..66c8324c --- /dev/null +++ b/app/pages/dashboard/source-tracking/[id].vue @@ -0,0 +1,907 @@ + + + diff --git a/app/pages/dashboard/source-tracking/index.vue b/app/pages/dashboard/source-tracking/index.vue new file mode 100644 index 00000000..eb77ce16 --- /dev/null +++ b/app/pages/dashboard/source-tracking/index.vue @@ -0,0 +1,1228 @@ + + + diff --git a/app/pages/jobs/[slug]/apply.vue b/app/pages/jobs/[slug]/apply.vue index 5df91bcb..9050f133 100644 --- a/app/pages/jobs/[slug]/apply.vue +++ b/app/pages/jobs/[slug]/apply.vue @@ -9,6 +9,14 @@ const route = useRoute() const jobSlug = route.params.slug as string const { track } = useTrack() +// Capture source tracking params from the URL +const sourceRef = (route.query.ref as string) || undefined +const utmSource = (route.query.utm_source as string) || undefined +const utmMedium = (route.query.utm_medium as string) || undefined +const utmCampaign = (route.query.utm_campaign as string) || undefined +const utmTerm = (route.query.utm_term as string) || undefined +const utmContent = (route.query.utm_content as string) || undefined + onMounted(() => track('application_started', { slug: jobSlug })) // Fetch public job data (no auth needed) @@ -185,6 +193,14 @@ async function handleSubmit() { formData.append('coverLetterText', coverLetterText.value.trim()) } + // Source tracking params + if (sourceRef) formData.append('ref', sourceRef) + if (utmSource) formData.append('utmSource', utmSource) + if (utmMedium) formData.append('utmMedium', utmMedium) + if (utmCampaign) formData.append('utmCampaign', utmCampaign) + if (utmTerm) formData.append('utmTerm', utmTerm) + if (utmContent) formData.append('utmContent', utmContent) + await $fetch(`/api/public/jobs/${jobSlug}/apply`, { method: 'POST', body: formData, @@ -201,6 +217,12 @@ async function handleSubmit() { website: form.value.website, // honeypot coverLetterText: coverLetterText.value.trim() || undefined, responses: responseArray, + ref: sourceRef, + utmSource, + utmMedium, + utmCampaign, + utmTerm, + utmContent, }, }) } diff --git a/app/pages/jobs/[slug]/index.vue b/app/pages/jobs/[slug]/index.vue index 7c9457bb..a8320db6 100644 --- a/app/pages/jobs/[slug]/index.vue +++ b/app/pages/jobs/[slug]/index.vue @@ -9,6 +9,18 @@ const route = useRoute() const jobSlug = route.params.slug as string const { track } = useTrack() +/** Forward source-tracking query params (?ref=, utm_*) to the apply page */ +const applyQuery = computed(() => { + const q: Record = {} + if (route.query.ref) q.ref = route.query.ref as string + if (route.query.utm_source) q.utm_source = route.query.utm_source as string + if (route.query.utm_medium) q.utm_medium = route.query.utm_medium as string + if (route.query.utm_campaign) q.utm_campaign = route.query.utm_campaign as string + if (route.query.utm_term) q.utm_term = route.query.utm_term as string + if (route.query.utm_content) q.utm_content = route.query.utm_content as string + return q +}) + onMounted(() => track('public_job_viewed', { slug: jobSlug })) const { data: job, status: fetchStatus, error: fetchError } = useFetch( @@ -54,7 +66,7 @@ useSeoMeta({ return `Apply for ${job.value.title}${org}. ${job.value.location ?? 'Remote'}.` }), ogType: 'website', - ogImage: '/og-image.png', + ogImage: '/reqcore-banner-github.jpeg', twitterCard: 'summary_large_image', twitterTitle: computed(() => job.value?.title ?? 'Job Details'), twitterDescription: computed(() => { @@ -287,7 +299,7 @@ function formatSalary(min?: number | null, max?: number | null, currency?: strin
    Apply Now @@ -346,7 +358,7 @@ function formatSalary(min?: number | null, max?: number | null, currency?: strin

    Submit your application in just a few minutes.

    Apply for this position diff --git a/app/pages/jobs/index.vue b/app/pages/jobs/index.vue index c306af79..ce92dbd9 100644 --- a/app/pages/jobs/index.vue +++ b/app/pages/jobs/index.vue @@ -5,6 +5,20 @@ definePageMeta({ layout: 'public', }) +const route = useRoute() + +/** Forward source-tracking query params (?ref=, utm_*) through navigation */ +const sourceQuery = computed(() => { + const q: Record = {} + if (route.query.ref) q.ref = route.query.ref as string + if (route.query.utm_source) q.utm_source = route.query.utm_source as string + if (route.query.utm_medium) q.utm_medium = route.query.utm_medium as string + if (route.query.utm_campaign) q.utm_campaign = route.query.utm_campaign as string + if (route.query.utm_term) q.utm_term = route.query.utm_term as string + if (route.query.utm_content) q.utm_content = route.query.utm_content as string + return q +}) + useSeoMeta({ title: 'Open Positions — Job Board', description: @@ -13,7 +27,7 @@ useSeoMeta({ ogDescription: 'Browse open job positions and apply directly. Powered by the open-source ATS you actually own.', ogType: 'website', - ogImage: '/og-image.png', + ogImage: '/reqcore-banner-github.jpeg', twitterCard: 'summary_large_image', twitterTitle: 'Open Positions — Reqcore Job Board', twitterDescription: @@ -161,7 +175,7 @@ function formatDate(dateStr: string) {
    diff --git a/e2e/critical-flows/source-tracking.spec.ts b/e2e/critical-flows/source-tracking.spec.ts new file mode 100644 index 00000000..77ce00a5 --- /dev/null +++ b/e2e/critical-flows/source-tracking.spec.ts @@ -0,0 +1,145 @@ +import { test, expect } from '../fixtures' + +/** + * Critical flow: Source tracking query parameters (?ref=, utm_*) propagate + * through the public job browsing and application flow. + * + * When a candidate arrives via a tracking link, the redirect lands on + * /jobs?ref=CODE (org-wide) or /jobs/:slug/apply?ref=CODE (job-scoped). + * This test verifies that source params survive navigation from the job + * listing → job detail → apply page, and are included in the POST body. + * + * Recruiter setup: + * 1. Create a minimal job (no resume required, no custom questions) + * 2. Publish and capture the slug + * + * Candidate flow: + * 3. Open /jobs?ref=TRACK123&utm_source=linkedin (simulates org-wide tracking link) + * 4. Click on the job → verify ref/utm params are forwarded to the detail page + * 5. Click "Apply Now" → verify ref/utm params are forwarded to the apply page + * 6. Submit the application → verify the POST body includes ref + utm_source + */ + +const JOB_TITLE = 'Source Tracking Test Job' + +test.describe('Source Tracking — Query Parameter Propagation', () => { + test('ref and utm params propagate from job listing → detail → apply → submission', async ({ authenticatedPage, browser }, testInfo) => { + const page = authenticatedPage + + // ── Step 1: Create and publish a minimal job ─────────────────────────────── + + await page.goto('/dashboard/jobs/new') + await page.waitForLoadState('networkidle') + await page.getByLabel('Job title').waitFor({ state: 'visible', timeout: 15_000 }) + await page.getByLabel('Job title').fill(JOB_TITLE) + + // Step 1 → Step 2 + await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().waitFor({ state: 'attached', timeout: 10_000 }) + await expect(page.locator('form').getByRole('button', { name: 'Save & continue' }).first()).toBeEnabled({ timeout: 10_000 }) + await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().click() + + // Step 2: disable resume requirement + const resumeRadioGroup = page.getByRole('radiogroup', { name: /Resume requirement/i }) + await resumeRadioGroup.waitFor({ state: 'visible', timeout: 10_000 }) + await resumeRadioGroup.getByRole('radio', { name: 'Off' }).click() + + // Step 2 → Step 3 + await page.locator('form').getByRole('button', { name: 'Save & continue' }).first().click() + + // Step 3 → Step 4 + 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: 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 }) + await publishButton.click() + + await expect(page.getByRole('heading', { name: 'Your job is live!' })).toBeVisible({ timeout: 20_000 }) + + // Capture the slug from the application link + const applicationLink = await page.locator('input[readonly]').inputValue() + const slugMatch = applicationLink.match(/\/jobs\/([^/]+)\/apply/) + const jobSlug = slugMatch?.[1] ?? '' + expect(jobSlug.length, 'Job slug must not be empty').toBeGreaterThan(0) + + // ── Step 2: Candidate navigates from job listing with tracking params ────── + + const candidateContext = await browser.newContext() + const candidatePage = await candidateContext.newPage() + + const REF_CODE = 'TRACK_E2E_123' + const UTM_SOURCE = 'linkedin' + + // Simulate arriving via an org-wide tracking link → /jobs?ref=...&utm_source=... + await candidatePage.goto(`/jobs?ref=${REF_CODE}&utm_source=${UTM_SOURCE}`) + await candidatePage.waitForLoadState('networkidle') + + // Find the job listing and click on it + const jobLink = candidatePage.getByRole('link', { name: JOB_TITLE }).first() + await expect(jobLink).toBeVisible({ timeout: 15_000 }) + await jobLink.click() + + // ── Verify: job detail page URL contains ref + utm_source ────────────────── + + await candidatePage.waitForURL(`**/jobs/${jobSlug}**`, { waitUntil: 'commit', timeout: 10_000 }) + const detailUrl = new URL(candidatePage.url()) + expect(detailUrl.searchParams.get('ref'), 'ref param must survive navigation to job detail').toBe(REF_CODE) + expect(detailUrl.searchParams.get('utm_source'), 'utm_source must survive navigation to job detail').toBe(UTM_SOURCE) + + // Click "Apply Now" + await candidatePage.getByRole('link', { name: 'Apply Now' }).first().click() + + // ── Verify: apply page URL contains ref + utm_source ─────────────────────── + + await candidatePage.waitForURL(`**/jobs/${jobSlug}/apply**`, { waitUntil: 'commit', timeout: 10_000 }) + const applyUrl = new URL(candidatePage.url()) + expect(applyUrl.searchParams.get('ref'), 'ref param must survive navigation to apply page').toBe(REF_CODE) + expect(applyUrl.searchParams.get('utm_source'), 'utm_source must survive navigation to apply page').toBe(UTM_SOURCE) + + // ── Step 3: Fill and submit the application ──────────────────────────────── + + const APPLICANT = { + firstName: 'Tracking', + lastName: 'Test', + email: `tracking.test.${Date.now()}.r${testInfo.retry}@example.com`, + } + + await candidatePage.getByRole('button', { name: /submit/i }).waitFor({ state: 'visible', timeout: 15_000 }) + await candidatePage.getByLabel('First name').fill(APPLICANT.firstName) + await candidatePage.getByLabel('Last name').fill(APPLICANT.lastName) + await candidatePage.getByLabel('Email').fill(APPLICANT.email) + + // Intercept the POST to verify the body includes source tracking params + const [applyResponse] = await Promise.all([ + candidatePage.waitForResponse( + resp => + resp.url().includes(`/api/public/jobs/${jobSlug}/apply`) && + resp.request().method() === 'POST', + { timeout: 30_000 }, + ), + candidatePage.getByRole('button', { name: /submit/i }).click(), + ]) + + // Verify 2xx response + const status = applyResponse.status() + expect(status, `Apply API returned ${status}`).toBeGreaterThanOrEqual(200) + expect(status, `Apply API returned ${status}`).toBeLessThan(300) + + // Verify the POST body included the tracking params + const requestBody = applyResponse.request().postDataJSON() + expect(requestBody.ref, 'POST body must include ref code').toBe(REF_CODE) + expect(requestBody.utmSource, 'POST body must include utmSource').toBe(UTM_SOURCE) + + // Verify confirmation page + await candidatePage.waitForURL(`**/jobs/${jobSlug}/confirmation`, { + waitUntil: 'commit', + timeout: 15_000, + }) + await expect(candidatePage.getByRole('heading', { name: 'Application Submitted!' })).toBeVisible() + + await candidatePage.close() + await candidateContext.close() + }) +}) diff --git a/nuxt.config.ts b/nuxt.config.ts index 1cddb3b8..96d286b7 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -90,8 +90,10 @@ export default defineNuxtConfig({ cross_subdomain_cookie: true, }, serverConfig: { - // Capture uncaught exceptions and unhandled rejections on the server - enableExceptionAutocapture: true, + // Disabled: the @posthog/nuxt Nitro plugin captures ALL errors + // (including 404s from bot scanners). We use a filtered error hook + // in server/plugins/posthog.ts instead. + enableExceptionAutocapture: false, }, }, diff --git a/package-lock.json b/package-lock.json index f59ef41f..a8723459 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1027,6 +1027,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1401,6 +1402,7 @@ "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.5.5.tgz", "integrity": "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA==", "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" @@ -1505,12 +1507,14 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.1.tgz", "integrity": "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@better-fetch/fetch": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", - "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==", + "peer": true }, "node_modules/@bomb.sh/tab": { "version": "0.0.14", @@ -2112,7 +2116,6 @@ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "license": "MIT", - "peer": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -2122,7 +2125,6 @@ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", @@ -2137,7 +2139,6 @@ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^1.1.1" }, @@ -2150,7 +2151,6 @@ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -2163,7 +2163,6 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } @@ -2173,7 +2172,6 @@ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" @@ -2241,7 +2239,6 @@ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18.0" } @@ -2251,7 +2248,6 @@ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" @@ -2265,7 +2261,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.22" }, @@ -2279,7 +2274,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -2685,7 +2679,6 @@ "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", "license": "MIT", - "peer": true, "dependencies": { "sparse-bitfield": "^3.0.3" } @@ -3142,6 +3135,7 @@ "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz", "integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==", "license": "MIT", + "peer": true, "dependencies": { "c12": "^3.3.3", "consola": "^3.4.2", @@ -3226,6 +3220,7 @@ "resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz", "integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==", "license": "MIT", + "peer": true, "dependencies": { "@vue/shared": "^3.5.27", "defu": "^6.1.4", @@ -3468,6 +3463,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -5220,17 +5216,17 @@ } }, "node_modules/@rollup/plugin-terser": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", - "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", "license": "MIT", "dependencies": { - "serialize-javascript": "^6.0.1", + "serialize-javascript": "^7.0.3", "smob": "^1.0.0", "terser": "^5.17.4" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" @@ -6774,8 +6770,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -6796,8 +6791,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/mdast": { "version": "4.0.4", @@ -6856,15 +6850,13 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/whatwg-url": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", "license": "MIT", - "peer": true, "dependencies": { "@types/webidl-conversions": "*" } @@ -7021,9 +7013,9 @@ "license": "MIT" }, "node_modules/@vercel/nft": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.3.2.tgz", - "integrity": "sha512-HC8venRc4Ya7vNeBsJneKHHMDDWpQie7VaKhAIOst3MKO+DES+Y/SbzSp8mFkD7OzwAE2HhHkeSuSmwS20mz3A==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.5.0.tgz", + "integrity": "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==", "license": "MIT", "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", @@ -7362,6 +7354,7 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.30", @@ -7496,9 +7489,9 @@ "license": "MIT" }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7530,6 +7523,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7587,7 +7581,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7864,6 +7857,7 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -8088,6 +8082,7 @@ "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.2.tgz", "integrity": "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==", "license": "MIT", + "peer": true, "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", @@ -8109,6 +8104,7 @@ "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -8212,9 +8208,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -8254,6 +8250,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8273,7 +8270,6 @@ "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -8381,6 +8377,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -8544,27 +8541,11 @@ "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", "license": "MIT", + "peer": true, "dependencies": { "consola": "^3.2.3" } }, - "node_modules/clipboardy": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz", - "integrity": "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==", - "license": "MIT", - "dependencies": { - "execa": "^8.0.1", - "is-wsl": "^3.1.0", - "is64bit": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -8651,6 +8632,7 @@ "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -8765,9 +8747,19 @@ } }, "node_modules/croner": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/croner/-/croner-9.1.0.tgz", - "integrity": "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], "license": "MIT", "engines": { "node": ">=18.0" @@ -9077,8 +9069,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", @@ -9127,9 +9118,9 @@ } }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz", + "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", "license": "MIT" }, "node_modules/delayed-stream": { @@ -9337,6 +9328,7 @@ "integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", @@ -9352,6 +9344,7 @@ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", @@ -9660,6 +9653,7 @@ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -9728,7 +9722,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -9818,7 +9811,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", @@ -9849,7 +9841,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, @@ -9862,7 +9853,6 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -9872,7 +9862,6 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", @@ -9890,7 +9879,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, @@ -9916,7 +9904,6 @@ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -9929,7 +9916,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -10064,8 +10050,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-fifo": { "version": "1.3.2", @@ -10105,15 +10090,13 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-npm-meta": { "version": "1.4.2", @@ -10237,7 +10220,6 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -10286,7 +10268,6 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -10315,7 +10296,6 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "license": "MIT", - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -10328,8 +10308,7 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/follow-redirects": { "version": "1.15.11", @@ -10521,6 +10500,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -10640,7 +10631,6 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "license": "ISC", - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -10664,9 +10654,9 @@ } }, "node_modules/globby": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/globby/-/globby-16.1.1.tgz", - "integrity": "sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", + "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", @@ -11240,9 +11230,9 @@ } }, "node_modules/httpxy": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.1.7.tgz", - "integrity": "sha512-pXNx8gnANKAndgga5ahefxc++tJvNL87CXoRwxn1cJE2ZkWEojF3tNfQIEhZX/vfpt+wzeAzpUI4qkediX1MLQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.5.0.tgz", + "integrity": "sha512-qwX7QX/rK2visT10/b7bSeZWQOMlSm3svTD0pZpU+vJjNUP0YHtNv4c3z+MO+MSnGuRFWJFdCZiV+7F7dXIOzg==", "license": "MIT" }, "node_modules/human-signals": { @@ -11327,7 +11317,6 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.19" } @@ -11348,9 +11337,9 @@ } }, "node_modules/ioredis": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", - "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", "license": "MIT", "dependencies": { "@ioredis/commands": "1.5.1", @@ -11496,6 +11485,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -11605,21 +11606,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is64bit": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is64bit/-/is64bit-2.0.0.tgz", - "integrity": "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==", - "license": "MIT", - "dependencies": { - "system-architecture": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -11665,6 +11651,7 @@ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -11712,8 +11699,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema": { "version": "0.4.0", @@ -11725,15 +11711,13 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -11850,7 +11834,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "license": "MIT", - "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -11884,6 +11867,7 @@ "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.14.tgz", "integrity": "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.0.0" } @@ -11945,7 +11929,6 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -12225,27 +12208,27 @@ } }, "node_modules/listhen": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.0.tgz", - "integrity": "sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.1.tgz", + "integrity": "sha512-4EhoyVcXEpNlY5HJRSQpH7Rba94M8N2JmI62ePjl0lrJKXSfG0F1FAgHGxBoz/T3pe41sUEwkIRRIcaUL0/Ofw==", "license": "MIT", "dependencies": { - "@parcel/watcher": "^2.4.1", - "@parcel/watcher-wasm": "^2.4.1", - "citty": "^0.1.6", - "clipboardy": "^4.0.0", - "consola": "^3.2.3", - "crossws": ">=0.2.0 <0.4.0", - "defu": "^6.1.4", - "get-port-please": "^3.1.2", - "h3": "^1.12.0", + "@parcel/watcher": "^2.5.6", + "@parcel/watcher-wasm": "^2.5.6", + "citty": "^0.2.2", + "consola": "^3.4.2", + "crossws": ">=0.2.0 <0.5.0", + "defu": "^6.1.6", + "get-port-please": "^3.2.0", + "h3": "^1.15.11", "http-shutdown": "^1.2.2", - "jiti": "^2.1.2", - "mlly": "^1.7.1", - "node-forge": "^1.3.1", - "pathe": "^1.1.2", - "std-env": "^3.7.0", - "ufo": "^1.5.4", + "jiti": "^2.6.1", + "mlly": "^1.8.2", + "node-forge": "^1.4.0", + "pathe": "^2.0.3", + "std-env": "^4.0.0", + "tinyclip": "^0.1.12", + "ufo": "^1.6.3", "untun": "^0.1.3", "uqr": "^0.1.2" }, @@ -12254,10 +12237,45 @@ "listhen": "bin/listhen.mjs" } }, - "node_modules/listhen/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "node_modules/listhen/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "license": "MIT" + }, + "node_modules/listhen/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/listhen/node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/listhen/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/listhen/node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "license": "MIT" }, "node_modules/local-pkg": { @@ -12282,7 +12300,6 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -12294,9 +12311,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.defaults": { @@ -12711,8 +12728,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/merge-stream": { "version": "2.0.0", @@ -13519,7 +13535,6 @@ "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" @@ -13578,6 +13593,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -13598,84 +13614,83 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/nitropack": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/nitropack/-/nitropack-2.13.1.tgz", - "integrity": "sha512-2dDj89C4wC2uzG7guF3CnyG+zwkZosPEp7FFBGHB3AJo11AywOolWhyQJFHDzve8COvGxJaqscye9wW2IrUsNw==", + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/nitropack/-/nitropack-2.13.3.tgz", + "integrity": "sha512-C8vO7RxkU0AQ3HbYUumuG6MVM5JjRaBchke/rYFOp3EvrLtTBHZYhDVGECdpa27vNuOYRzm3GtQMn2YDOjDJLA==", "license": "MIT", "dependencies": { "@cloudflare/kv-asset-handler": "^0.4.2", "@rollup/plugin-alias": "^6.0.0", - "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-commonjs": "^29.0.2", "@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-replace": "^6.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@vercel/nft": "^1.2.0", + "@rollup/plugin-terser": "^1.0.0", + "@vercel/nft": "^1.5.0", "archiver": "^7.0.1", - "c12": "^3.3.3", + "c12": "^3.3.4", "chokidar": "^5.0.0", - "citty": "^0.1.6", + "citty": "^0.2.2", "compatx": "^0.2.0", - "confbox": "^0.2.2", + "confbox": "^0.2.4", "consola": "^3.4.2", - "cookie-es": "^2.0.0", - "croner": "^9.1.0", + "cookie-es": "^2.0.1", + "croner": "^10.0.1", "crossws": "^0.3.5", "db0": "^0.3.4", - "defu": "^6.1.4", + "defu": "^6.1.6", "destr": "^2.0.5", "dot-prop": "^10.1.0", - "esbuild": "^0.27.2", + "esbuild": "^0.27.5", "escape-string-regexp": "^5.0.0", "etag": "^1.8.1", "exsolve": "^1.0.8", - "globby": "^16.1.0", + "globby": "^16.2.0", "gzip-size": "^7.0.0", - "h3": "^1.15.5", + "h3": "^1.15.10", "hookable": "^5.5.3", - "httpxy": "^0.1.7", - "ioredis": "^5.9.1", + "httpxy": "^0.5.0", + "ioredis": "^5.10.1", "jiti": "^2.6.1", "klona": "^2.0.6", "knitwork": "^1.3.0", - "listhen": "^1.9.0", + "listhen": "^1.9.1", "magic-string": "^0.30.21", - "magicast": "^0.5.1", + "magicast": "^0.5.2", "mime": "^4.1.0", - "mlly": "^1.8.0", + "mlly": "^1.8.2", "node-fetch-native": "^1.6.7", "node-mock-http": "^1.0.4", "ofetch": "^1.5.1", "ohash": "^2.0.11", "pathe": "^2.0.3", - "perfect-debounce": "^2.0.0", + "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "pretty-bytes": "^7.1.0", "radix3": "^1.1.2", - "rollup": "^4.55.1", - "rollup-plugin-visualizer": "^6.0.5", + "rollup": "^4.60.1", + "rollup-plugin-visualizer": "^7.0.1", "scule": "^1.3.0", - "semver": "^7.7.3", + "semver": "^7.7.4", "serve-placeholder": "^2.0.2", "serve-static": "^2.2.1", "source-map": "^0.7.6", - "std-env": "^3.10.0", + "std-env": "^4.0.0", "ufo": "^1.6.3", "ultrahtml": "^1.6.0", "uncrypto": "^0.1.3", "unctx": "^2.5.0", - "unenv": "^2.0.0-rc.24", - "unimport": "^5.6.0", + "unenv": "2.0.0-rc.24", + "unimport": "^6.0.2", "unplugin-utils": "^0.3.1", - "unstorage": "^1.17.4", + "unstorage": "^1.17.5", "untyped": "^2.0.0", "unwasm": "^0.5.3", - "youch": "^4.1.0-beta.13", + "youch": "^4.1.1", "youch-core": "^0.3.3" }, "bin": { @@ -13694,47 +13709,380 @@ } } }, - "node_modules/nitropack/node_modules/cookie-es": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz", - "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", - "license": "MIT" - }, - "node_modules/nitropack/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "node_modules/nitropack/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/nitropack/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "license": "BSD-3-Clause", + "node_modules/nitropack/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", "engines": { - "node": ">= 12" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/node-abi": { - "version": "3.88.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz", - "integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==", + "node_modules/nitropack/node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", "license": "MIT", "dependencies": { - "semver": "^7.3.5" + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "7.1.1", + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/nitropack/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "license": "MIT" + }, + "node_modules/nitropack/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/nitropack/node_modules/cookie-es": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz", + "integrity": "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==", + "license": "MIT" + }, + "node_modules/nitropack/node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nitropack/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/nitropack/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nitropack/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/nitropack/node_modules/giget": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", + "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/nitropack/node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/nitropack/node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/nitropack/node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/nitropack/node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nitropack/node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "node_modules/nitropack/node_modules/rollup-plugin-visualizer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-7.0.1.tgz", + "integrity": "sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==", + "license": "MIT", + "dependencies": { + "open": "^11.0.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^18.0.0" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "rolldown": "1.x || ^1.0.0-beta || ^1.0.0-rc", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/nitropack/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/nitropack/node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "license": "MIT" + }, + "node_modules/nitropack/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nitropack/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/nitropack/node_modules/unimport": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-6.0.2.tgz", + "integrity": "sha512-ZSOkrDw380w+KIPniY3smyXh2h7H9v2MNr9zejDuh239o5sdea44DRAYrv+rfUi2QGT186P2h0GPGKvy8avQ5g==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "pkg-types": "^2.3.0", + "scule": "^1.3.0", + "strip-literal": "^3.1.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/nitropack/node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/nitropack/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/nitropack/node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nitropack/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/nitropack/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/node-abi": { + "version": "3.88.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz", + "integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" @@ -13823,9 +14171,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" @@ -13922,6 +14270,7 @@ "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz", "integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==", "license": "MIT", + "peer": true, "dependencies": { "@dxup/nuxt": "^0.3.2", "@nuxt/cli": "^3.33.0", @@ -14522,6 +14871,7 @@ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz", "integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==", "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "^0.112.0" }, @@ -14846,7 +15196,6 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -14898,6 +15247,7 @@ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz", "integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==", "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "^0.95.0" }, @@ -14971,7 +15321,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "license": "MIT", - "peer": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -14987,7 +15336,6 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -15073,7 +15421,6 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -15278,6 +15625,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15787,6 +16135,7 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.8.tgz", "integrity": "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==", "license": "Unlicense", + "peer": true, "engines": { "node": ">=12" }, @@ -15985,6 +16334,18 @@ } } }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/preact": { "version": "10.29.0", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", @@ -16027,7 +16388,6 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8.0" } @@ -16115,7 +16475,6 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -16635,6 +16994,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -16760,6 +17120,7 @@ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -16850,9 +17211,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.4.tgz", - "integrity": "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "license": "BSD-3-Clause", "engines": { "node": ">=20.0.0" @@ -17323,7 +17684,6 @@ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", "license": "MIT", - "peer": true, "dependencies": { "memory-pager": "^1.0.2" } @@ -17335,9 +17695,9 @@ "license": "BSD-3-Clause" }, "node_modules/srvx": { - "version": "0.11.9", - "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.9.tgz", - "integrity": "sha512-97wWJS6F0KTKAhDlHVmBzMvlBOp5FiNp3XrLoodIgYJpXxgG5tE9rX4Pg7s46n2shI4wtEsMATTS1+rI3/ubzA==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.14.tgz", + "integrity": "sha512-mx+pKrWJCzo5m6uXqyB7n4VA81mpdFRroSWsVTQTYqCZE65hFJ+jtVIeyhtL2/kvtDMrHdbA0hWEUh/vu0+Viw==", "license": "MIT", "bin": { "srvx": "bin/srvx.mjs" @@ -17614,18 +17974,6 @@ "uuid": "^10.0.0" } }, - "node_modules/system-architecture": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", - "integrity": "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -17642,7 +17990,8 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -17902,7 +18251,6 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "license": "MIT", - "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -18009,7 +18357,6 @@ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -18043,6 +18390,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18417,16 +18765,16 @@ } }, "node_modules/unstorage": { - "version": "1.17.4", - "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.4.tgz", - "integrity": "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==", + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", "license": "MIT", "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", - "h3": "^1.15.5", - "lru-cache": "^11.2.0", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" @@ -18513,9 +18861,9 @@ } }, "node_modules/unstorage/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -18612,7 +18960,6 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -18689,6 +19036,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -19059,6 +19407,7 @@ "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", @@ -19153,6 +19502,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", @@ -19189,6 +19539,7 @@ "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz", "integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==", "license": "MIT", + "peer": true, "dependencies": { "@intlify/core-base": "11.3.0", "@intlify/devtools-types": "11.3.0", @@ -19210,6 +19561,7 @@ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^6.6.4" }, @@ -19226,6 +19578,7 @@ "integrity": "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.2.5" @@ -19287,7 +19640,6 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=12" } @@ -19303,7 +19655,6 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "license": "MIT", - "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -19378,7 +19729,6 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -19559,7 +19909,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -19568,15 +19917,15 @@ } }, "node_modules/youch": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0.tgz", - "integrity": "sha512-cYekNh2tUoU+voS11X0D0UQntVCSO6LQ1h10VriQGmfbpf0mnGTruwZICts23UUNiZCXm8H8hQBtRrdsbhuNNg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.1.tgz", + "integrity": "sha512-mxW3qiSnl+GRxXsaUMzv2Mbada1Y8CDltET9UxejDQe6DBYlSekghl5U5K0ReAikcHDi0G1vKZEmmo/NWAGKLA==", "license": "MIT", "dependencies": { "@poppinss/colors": "^4.1.6", "@poppinss/dumper": "^0.7.0", "@speed-highlight/core": "^1.2.14", - "cookie-es": "^2.0.0", + "cookie-es": "^3.0.1", "youch-core": "^0.3.3" } }, @@ -19591,9 +19940,9 @@ } }, "node_modules/youch/node_modules/cookie-es": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz", - "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", "license": "MIT" }, "node_modules/zip-stream": { @@ -19615,6 +19964,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/server/api/activity-log/candidate-timeline.get.ts b/server/api/activity-log/candidate-timeline.get.ts new file mode 100644 index 00000000..870fa728 --- /dev/null +++ b/server/api/activity-log/candidate-timeline.get.ts @@ -0,0 +1,116 @@ +import { eq, and, desc, or, inArray } from 'drizzle-orm' +import { z } from 'zod' +import { activityLog, user, application, job, candidate } from '../../database/schema' + +const querySchema = z.object({ + candidateId: z.string().min(1), + limit: z.coerce.number().int().min(1).max(200).default(50), +}) + +/** + * GET /api/activity-log/candidate-timeline?candidateId=… + * + * Returns activity-log entries related to a specific candidate: + * - Direct candidate-resource events + * - Events on any application belonging to this candidate + * + * Used by the CandidateDetailSidebar "Timeline" tab. + */ +export default defineEventHandler(async (event) => { + const session = await requirePermission(event, { activityLog: ['read'] }) + const orgId = session.session.activeOrganizationId + + const query = await getValidatedQuery(event, querySchema.parse) + + // 1. Load application IDs for this candidate (within the org) + const candidateApps = await db + .select({ id: application.id }) + .from(application) + .where(and( + eq(application.organizationId, orgId), + eq(application.candidateId, query.candidateId), + )) + + const appIds = candidateApps.map(a => a.id) + + // 2. Build OR conditions for resource matching + const resourceConditions = [ + and(eq(activityLog.resourceType, 'candidate'), eq(activityLog.resourceId, query.candidateId)), + ] + if (appIds.length > 0) { + resourceConditions.push( + and(eq(activityLog.resourceType, 'application'), inArray(activityLog.resourceId, appIds)), + ) + } + + // 3. Fetch activity entries + 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(and( + eq(activityLog.organizationId, orgId), + or(...resourceConditions), + )) + .orderBy(desc(activityLog.createdAt)) + .limit(query.limit) + + // 4. Enrich application events with job title + const jobIdsForApps = new Set() + if (appIds.length > 0) { + const appJobs = await db + .select({ id: application.id, jobId: application.jobId, jobTitle: job.title }) + .from(application) + .innerJoin(job, eq(job.id, application.jobId)) + .where(inArray(application.id, appIds)) + for (const aj of appJobs) { + jobIdsForApps.add(aj.jobId) + } + var appJobMap = new Map(appJobs.map(a => [a.id, { jobId: a.jobId, jobTitle: a.jobTitle }])) + } + + // 5. Get candidate name for display + const [cand] = await db + .select({ firstName: candidate.firstName, lastName: candidate.lastName }) + .from(candidate) + .where(and(eq(candidate.id, query.candidateId), eq(candidate.organizationId, orgId))) + .limit(1) + + const candidateName = cand ? `${cand.firstName} ${cand.lastName}` : 'Unknown' + + // 6. Enrich items + const items = data.map((item) => { + let resourceName: string | null = null + let jobTitle: string | null = null + + if (item.resourceType === 'candidate') { + resourceName = candidateName + } else if (item.resourceType === 'application' && appJobMap) { + const info = appJobMap.get(item.resourceId) + if (info) { + resourceName = `${candidateName} → ${info.jobTitle}` + jobTitle = info.jobTitle + } + } + + return { + ...item, + resourceName, + jobTitle, + candidateName, + } + }) + + return { items, candidateId: query.candidateId, candidateName } +}) diff --git a/server/api/calendar/renew-webhooks.post.ts b/server/api/calendar/renew-webhooks.post.ts index aa5c2e02..82905406 100644 --- a/server/api/calendar/renew-webhooks.post.ts +++ b/server/api/calendar/renew-webhooks.post.ts @@ -16,8 +16,13 @@ export default defineEventHandler(async (event) => { // Check for CRON_SECRET header (server-to-server / scheduled job) const cronSecret = getHeader(event, 'x-cron-secret') if (cronSecret && env.CRON_SECRET) { + // Use fixed-length buffers to avoid leaking secret length via timing + const a = Buffer.alloc(64) + const b = Buffer.alloc(64) + Buffer.from(cronSecret).copy(a) + Buffer.from(env.CRON_SECRET).copy(b) const valid = cronSecret.length === env.CRON_SECRET.length - && timingSafeEqual(Buffer.from(cronSecret), Buffer.from(env.CRON_SECRET)) + && timingSafeEqual(a, b) if (!valid) { throw createError({ statusCode: 403, statusMessage: 'Invalid cron secret' }) } diff --git a/server/api/public/jobs/[slug]/apply.post.ts b/server/api/public/jobs/[slug]/apply.post.ts index 76a8928d..774e3995 100644 --- a/server/api/public/jobs/[slug]/apply.post.ts +++ b/server/api/public/jobs/[slug]/apply.post.ts @@ -1,6 +1,6 @@ -import { eq, and, asc } from 'drizzle-orm' +import { eq, and, asc, sql } from 'drizzle-orm' import { fileTypeFromBuffer } from 'file-type' -import { job, candidate, application, jobQuestion, questionResponse, document, organization } from '../../../../database/schema' +import { job, candidate, application, jobQuestion, questionResponse, document, organization, applicationSource, trackingLink } from '../../../../database/schema' import { publicApplicationSchema, publicJobSlugSchema } from '../../../../utils/schemas/publicApplication' import { createPreviewReadOnlyError } from '../../../../utils/previewReadOnly' import { autoScoreApplication } from '../../../../utils/ai/autoScore' @@ -66,6 +66,12 @@ export default defineEventHandler(async (event) => { let website: string | undefined let responseArray: { questionId: string; value: string | string[] | number | boolean }[] = [] let coverLetterText: string | undefined + let sourceRef: string | undefined + let utmSource: string | undefined + let utmMedium: string | undefined + let utmCampaign: string | undefined + let utmTerm: string | undefined + let utmContent: string | undefined const uploadedFiles: Map = new Map() let resumeUpload: { data: Buffer; filename: string; type?: string } | null = null @@ -121,6 +127,12 @@ export default defineEventHandler(async (event) => { website: fields.website || undefined, coverLetterText: fields.coverLetterText?.trim() || undefined, responses: rawResponses, + ref: fields.ref || undefined, + utmSource: fields.utmSource || undefined, + utmMedium: fields.utmMedium || undefined, + utmCampaign: fields.utmCampaign || undefined, + utmTerm: fields.utmTerm || undefined, + utmContent: fields.utmContent || undefined, }) firstName = validated.firstName @@ -130,6 +142,12 @@ export default defineEventHandler(async (event) => { website = validated.website coverLetterText = validated.coverLetterText responseArray = validated.responses + sourceRef = validated.ref + utmSource = validated.utmSource + utmMedium = validated.utmMedium + utmCampaign = validated.utmCampaign + utmTerm = validated.utmTerm + utmContent = validated.utmContent } else { // Standard JSON body const body = await readValidatedBody(event, publicApplicationSchema.parse) @@ -140,6 +158,12 @@ export default defineEventHandler(async (event) => { website = body.website coverLetterText = body.coverLetterText responseArray = body.responses + sourceRef = body.ref + utmSource = body.utmSource + utmMedium = body.utmMedium + utmCampaign = body.utmCampaign + utmTerm = body.utmTerm + utmContent = body.utmContent } // Honeypot check — if the hidden `website` field is filled, silently reject @@ -374,6 +398,56 @@ export default defineEventHandler(async (event) => { coverLetterText: coverLetterText || null, }).returning({ id: application.id }) + // ───────────────────────────────────────────── + // 8b. Record source attribution + // ───────────────────────────────────────────── + + try { + const refererHeader = getHeader(event, 'referer') || getHeader(event, 'referrer') + const referrerDomain = refererHeader ? extractDomain(refererHeader) : null + + // Resolve tracking link from ?ref= code + let resolvedLink: { id: string; channel: typeof trackingLink.$inferSelect['channel'] } | null = null + if (sourceRef) { + const found = await db.query.trackingLink.findFirst({ + where: and(eq(trackingLink.code, sourceRef), eq(trackingLink.organizationId, orgId)), + columns: { id: true, channel: true }, + }) + if (found) { + resolvedLink = found + // Increment application counter on tracking link + await db.update(trackingLink) + .set({ applicationCount: sql`${trackingLink.applicationCount} + 1` }) + .where(eq(trackingLink.id, found.id)) + } + } + + // Determine channel: tracking link > utm_source mapping > referrer mapping > direct + const channel = resolvedLink?.channel + ?? mapUtmToChannel(utmSource) + ?? mapReferrerToChannel(referrerDomain) + ?? 'direct' + + await db.insert(applicationSource).values({ + organizationId: orgId, + applicationId: newApplication!.id, + channel: channel as typeof applicationSource.$inferInsert.channel, + trackingLinkId: resolvedLink?.id ?? null, + utmSource: utmSource ?? null, + utmMedium: utmMedium ?? null, + utmCampaign: utmCampaign ?? null, + utmTerm: utmTerm ?? null, + utmContent: utmContent ?? null, + referrerDomain, + }) + } catch (sourceErr) { + // Source tracking is best-effort — never fail an application for attribution + logWarn('application.source_tracking_failed', { + application_id: newApplication?.id, + error_message: sourceErr instanceof Error ? sourceErr.message : String(sourceErr), + }) + } + // ───────────────────────────────────────────── // 9. Store question responses // ───────────────────────────────────────────── @@ -577,3 +651,95 @@ export default defineEventHandler(async (event) => { setResponseStatus(event, 201) return { success: true } }) + +// ───────────────────────────────────────────── +// Source attribution helpers +// ───────────────────────────────────────────── + +/** Extract domain from a URL, stripping www. prefix. Returns null on invalid URLs. */ +function extractDomain(url: string): string | null { + try { + const parsed = new URL(url) + return parsed.hostname.replace(/^www\./, '') + } catch { + return null + } +} + +/** Map common utm_source values to canonical source channels */ +function mapUtmToChannel(utmSource: string | undefined): string | null { + if (!utmSource) return null + const source = utmSource.toLowerCase().trim() + const mapping: Record = { + linkedin: 'linkedin', + indeed: 'indeed', + glassdoor: 'glassdoor', + ziprecruiter: 'ziprecruiter', + monster: 'monster', + handshake: 'handshake', + angellist: 'angellist', + wellfound: 'wellfound', + dice: 'dice', + stackoverflow: 'stackoverflow', + 'stack overflow': 'stackoverflow', + weworkremotely: 'weworkremotely', + remoteok: 'remoteok', + 'remote ok': 'remoteok', + builtin: 'builtin', + hired: 'hired', + lever: 'lever', + greenhouse: 'greenhouse_board', + 'google jobs': 'google_jobs', + google_jobs: 'google_jobs', + facebook: 'facebook', + twitter: 'twitter', + x: 'twitter', + instagram: 'instagram', + tiktok: 'tiktok', + reddit: 'reddit', + referral: 'referral', + email: 'email', + newsletter: 'email', + event: 'event', + agency: 'agency', + } + return mapping[source] ?? null +} + +/** Map referrer domains to canonical source channels */ +function mapReferrerToChannel(domain: string | null): string | null { + if (!domain) return null + const d = domain.toLowerCase() + const mapping: Record = { + 'linkedin.com': 'linkedin', + 'indeed.com': 'indeed', + 'glassdoor.com': 'glassdoor', + 'ziprecruiter.com': 'ziprecruiter', + 'monster.com': 'monster', + 'joinhandshake.com': 'handshake', + 'angel.co': 'angellist', + 'wellfound.com': 'wellfound', + 'dice.com': 'dice', + 'stackoverflow.com': 'stackoverflow', + 'weworkremotely.com': 'weworkremotely', + 'remoteok.com': 'remoteok', + 'builtin.com': 'builtin', + 'hired.com': 'hired', + 'lever.co': 'lever', + 'boards.greenhouse.io': 'greenhouse_board', + 'jobs.google.com': 'google_jobs', + 'google.com': 'google_jobs', + 'facebook.com': 'facebook', + 'twitter.com': 'twitter', + 'x.com': 'twitter', + 'instagram.com': 'instagram', + 'tiktok.com': 'tiktok', + 'reddit.com': 'reddit', + } + // Check for exact match first, then suffix match for subdomains + if (mapping[d]) return mapping[d]! + for (const [key, channel] of Object.entries(mapping)) { + if (d.endsWith(`.${key}`) || d === key) return channel + } + return null +} diff --git a/server/api/public/track/[code].get.ts b/server/api/public/track/[code].get.ts new file mode 100644 index 00000000..28dfd99e --- /dev/null +++ b/server/api/public/track/[code].get.ts @@ -0,0 +1,56 @@ +import { eq, sql } from 'drizzle-orm' +import { trackingLink } from '../../../database/schema' + +/** Tracking codes are 8-char base64url strings */ +const TRACKING_CODE_RE = /^[A-Za-z0-9_-]{1,100}$/ + +/** + * GET /api/public/track/:code + * Public endpoint — no auth required. + * Increments the click counter on a tracking link and redirects + * to the appropriate job page (or careers page). + * Used when sharing direct tracking URLs. + */ +export default defineEventHandler(async (event) => { + const code = getRouterParam(event, 'code') + if (!code || !TRACKING_CODE_RE.test(code)) { + throw createError({ statusCode: 400, statusMessage: 'Invalid tracking code' }) + } + + const link = await db.query.trackingLink.findFirst({ + where: eq(trackingLink.code, code), + columns: { id: true, jobId: true, isActive: true }, + with: { + job: { columns: { slug: true } }, + }, + }) + + if (!link) { + throw createError({ statusCode: 404, statusMessage: 'Link not found' }) + } + + // Increment click counter (fire-and-forget, non-blocking) + if (link.isActive) { + db.update(trackingLink) + .set({ clickCount: sql`${trackingLink.clickCount} + 1` }) + .where(eq(trackingLink.id, link.id)) + .then(() => {}) + .catch((err) => { + logWarn('tracking_link.click_increment_failed', { + tracking_link_id: link.id, + error_message: err instanceof Error ? err.message : String(err), + }) + }) + } + + // Build redirect URL with ref param + const baseUrl = env.BETTER_AUTH_URL + if (!baseUrl) { + throw createError({ statusCode: 500, statusMessage: 'Server misconfiguration' }) + } + const targetPath = link.job?.slug + ? `/jobs/${link.job.slug}/apply?ref=${encodeURIComponent(code)}` + : `/jobs?ref=${encodeURIComponent(code)}` + + return sendRedirect(event, `${baseUrl}${targetPath}`, 302) +}) diff --git a/server/api/source-tracking/stats.get.ts b/server/api/source-tracking/stats.get.ts new file mode 100644 index 00000000..139279bd --- /dev/null +++ b/server/api/source-tracking/stats.get.ts @@ -0,0 +1,188 @@ +import { eq, and, sql, count, gte, lte, desc } from 'drizzle-orm' +import { applicationSource, application, trackingLink, job, candidate } from '../../database/schema' +import { sourceStatsQuerySchema } from '../../utils/schemas/trackingLink' + +/** + * GET /api/source-tracking/stats + * Returns comprehensive source analytics for the current organization: + * - Channel breakdown (applications per source channel) + * - Top tracking links by applications + * - Source trends over time (last 30 days by default) + * - Conversion funnel (applications by status per channel) + * - Recent attributed applications + */ +export default defineEventHandler(async (event) => { + const session = await requirePermission(event, { sourceTracking: ['read'], application: ['read'] }) + const orgId = session.session.activeOrganizationId + + const query = await getValidatedQuery(event, sourceStatsQuerySchema.parse) + + // Build date range conditions + const dateConditions = [eq(applicationSource.organizationId, orgId)] + if (query.jobId) { + dateConditions.push(eq(application.jobId, query.jobId)) + } + if (query.from) { + dateConditions.push(gte(applicationSource.createdAt, new Date(query.from))) + } + if (query.to) { + dateConditions.push(lte(applicationSource.createdAt, new Date(query.to))) + } + + const whereClause = and(...dateConditions) + + // ───────────────────────────────────────────── + // Run all analytics queries in parallel + // ───────────────────────────────────────────── + const [ + channelBreakdown, + topLinks, + statusByChannel, + dailyTrend, + recentAttributed, + totalTracked, + totalUntracked, + topReferrerDomains, + ] = await Promise.all([ + // 1. Applications per channel + db + .select({ + channel: applicationSource.channel, + count: count().as('count'), + }) + .from(applicationSource) + .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .where(whereClause) + .groupBy(applicationSource.channel) + .orderBy(sql`count(*) desc`), + + // 2. Top 10 tracking links by application count + db + .select({ + id: trackingLink.id, + name: trackingLink.name, + channel: trackingLink.channel, + code: trackingLink.code, + jobTitle: job.title, + clickCount: trackingLink.clickCount, + applicationCount: trackingLink.applicationCount, + isActive: trackingLink.isActive, + }) + .from(trackingLink) + .leftJoin(job, eq(job.id, trackingLink.jobId)) + .where(eq(trackingLink.organizationId, orgId)) + .orderBy(desc(trackingLink.applicationCount)) + .limit(10), + + // 3. Conversion funnel — application status breakdown per channel + db + .select({ + channel: applicationSource.channel, + status: application.status, + count: count().as('count'), + }) + .from(applicationSource) + .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .where(whereClause) + .groupBy(applicationSource.channel, application.status), + + // 4. Daily trend for the last 30 days + db + .select({ + date: sql`date_trunc('day', ${applicationSource.createdAt})::date`.as('day'), + channel: applicationSource.channel, + count: count().as('count'), + }) + .from(applicationSource) + .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .where(and( + ...dateConditions, + gte(applicationSource.createdAt, sql`now() - interval '30 days'`), + )) + .groupBy(sql`date_trunc('day', ${applicationSource.createdAt})::date`, applicationSource.channel) + .orderBy(sql`date_trunc('day', ${applicationSource.createdAt})::date`), + + // 5. Recent 15 attributed applications with candidate + job info + db + .select({ + applicationId: applicationSource.applicationId, + jobId: application.jobId, + channel: applicationSource.channel, + utmSource: applicationSource.utmSource, + utmCampaign: applicationSource.utmCampaign, + referrerDomain: applicationSource.referrerDomain, + trackingLinkName: trackingLink.name, + candidateFirstName: candidate.firstName, + candidateLastName: candidate.lastName, + candidateEmail: candidate.email, + jobTitle: job.title, + status: application.status, + appliedAt: applicationSource.createdAt, + }) + .from(applicationSource) + .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) + .innerJoin(job, eq(job.id, application.jobId)) + .leftJoin(trackingLink, eq(trackingLink.id, applicationSource.trackingLinkId)) + .where(whereClause) + .orderBy(desc(applicationSource.createdAt)) + .limit(200), + + // 6. Total tracked applications (have a source) + db.$count(applicationSource, eq(applicationSource.organizationId, orgId)), + + // 7. Total untracked applications (no source record) + db.execute(sql` + SELECT count(*) as count + FROM ${application} a + WHERE a.organization_id = ${orgId} + AND NOT EXISTS ( + SELECT 1 FROM ${applicationSource} s + WHERE s.application_id = a.id + ) + `).then((r: any) => Number(r[0]?.count ?? 0)), + + // 8. Top referrer domains + db + .select({ + domain: applicationSource.referrerDomain, + count: count().as('count'), + }) + .from(applicationSource) + .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .where(and( + ...dateConditions, + sql`${applicationSource.referrerDomain} IS NOT NULL`, + )) + .groupBy(applicationSource.referrerDomain) + .orderBy(sql`count(*) desc`) + .limit(10), + ]) + + // ───────────────────────────────────────────── + // Build conversion funnel map: channel → status → count + // ───────────────────────────────────────────── + const funnel: Record> = {} + for (const row of statusByChannel) { + if (!funnel[row.channel]) { + funnel[row.channel] = { new: 0, screening: 0, interview: 0, offer: 0, hired: 0, rejected: 0 } + } + funnel[row.channel]![row.status] = row.count + } + + return { + channelBreakdown, + topLinks, + funnel, + dailyTrend, + recentAttributed, + topReferrerDomains, + summary: { + totalTracked, + totalUntracked, + attributionRate: totalTracked + totalUntracked > 0 + ? Math.round((totalTracked / (totalTracked + totalUntracked)) * 100) + : 0, + }, + } +}) diff --git a/server/api/tracking-links/[id].delete.ts b/server/api/tracking-links/[id].delete.ts new file mode 100644 index 00000000..87dc9eaf --- /dev/null +++ b/server/api/tracking-links/[id].delete.ts @@ -0,0 +1,30 @@ +import { eq, and } from 'drizzle-orm' +import { trackingLink } from '../../database/schema' +import { trackingLinkIdSchema } from '../../utils/schemas/trackingLink' + +/** + * DELETE /api/tracking-links/:id + * Delete a tracking link. Existing application sources referencing + * this link will have their trackingLinkId set to null (FK on delete set null). + */ +export default defineEventHandler(async (event) => { + const session = await requirePermission(event, { sourceTracking: ['delete'] }) + const orgId = session.session.activeOrganizationId + + const { id } = await getValidatedRouterParams(event, trackingLinkIdSchema.parse) + + const existing = await db.query.trackingLink.findFirst({ + where: and(eq(trackingLink.id, id), eq(trackingLink.organizationId, orgId)), + columns: { id: true }, + }) + + if (!existing) { + throw createError({ statusCode: 404, statusMessage: 'Tracking link not found' }) + } + + await db.delete(trackingLink) + .where(and(eq(trackingLink.id, id), eq(trackingLink.organizationId, orgId))) + + setResponseStatus(event, 204) + return null +}) diff --git a/server/api/tracking-links/[id].get.ts b/server/api/tracking-links/[id].get.ts new file mode 100644 index 00000000..8d99a7b7 --- /dev/null +++ b/server/api/tracking-links/[id].get.ts @@ -0,0 +1,24 @@ +import { eq, and } from 'drizzle-orm' +import { trackingLink } from '../../database/schema' +import { trackingLinkIdSchema } from '../../utils/schemas/trackingLink' + +/** + * GET /api/tracking-links/:id + * Get a single tracking link by ID. + */ +export default defineEventHandler(async (event) => { + const session = await requirePermission(event, { sourceTracking: ['read'] }) + const orgId = session.session.activeOrganizationId + + const { id } = await getValidatedRouterParams(event, trackingLinkIdSchema.parse) + + const link = await db.query.trackingLink.findFirst({ + where: and(eq(trackingLink.id, id), eq(trackingLink.organizationId, orgId)), + }) + + if (!link) { + throw createError({ statusCode: 404, statusMessage: 'Tracking link not found' }) + } + + return link +}) diff --git a/server/api/tracking-links/[id].patch.ts b/server/api/tracking-links/[id].patch.ts new file mode 100644 index 00000000..a99075e4 --- /dev/null +++ b/server/api/tracking-links/[id].patch.ts @@ -0,0 +1,31 @@ +import { eq, and } from 'drizzle-orm' +import { trackingLink } from '../../database/schema' +import { trackingLinkIdSchema, updateTrackingLinkSchema } from '../../utils/schemas/trackingLink' + +/** + * PATCH /api/tracking-links/:id + * Update a tracking link (name, channel, UTM params, active status). + */ +export default defineEventHandler(async (event) => { + const session = await requirePermission(event, { sourceTracking: ['update'] }) + const orgId = session.session.activeOrganizationId + + const { id } = await getValidatedRouterParams(event, trackingLinkIdSchema.parse) + const body = await readValidatedBody(event, updateTrackingLinkSchema.parse) + + const existing = await db.query.trackingLink.findFirst({ + where: and(eq(trackingLink.id, id), eq(trackingLink.organizationId, orgId)), + columns: { id: true }, + }) + + if (!existing) { + throw createError({ statusCode: 404, statusMessage: 'Tracking link not found' }) + } + + const [updated] = await db.update(trackingLink) + .set({ ...body, updatedAt: new Date() }) + .where(and(eq(trackingLink.id, id), eq(trackingLink.organizationId, orgId))) + .returning() + + return updated +}) diff --git a/server/api/tracking-links/[id]/stats.get.ts b/server/api/tracking-links/[id]/stats.get.ts new file mode 100644 index 00000000..909d3709 --- /dev/null +++ b/server/api/tracking-links/[id]/stats.get.ts @@ -0,0 +1,165 @@ +import { eq, and, sql, count, gte, lte, desc } from 'drizzle-orm' +import { applicationSource, application, trackingLink, job, candidate } from '../../../database/schema' +import { trackingLinkIdSchema, sourceStatsQuerySchema } from '../../../utils/schemas/trackingLink' + +/** + * GET /api/tracking-links/:id/stats + * Returns detailed analytics for a single tracking link: + * - Link metadata (name, channel, code, UTM params, click/app counts) + * - Daily click/application trend + * - Application status breakdown (funnel) + * - All attributed applications with candidate + job info + * - Referrer domain breakdown + */ +export default defineEventHandler(async (event) => { + const session = await requirePermission(event, { sourceTracking: ['read'], application: ['read'] }) + const orgId = session.session.activeOrganizationId + + const { id } = await getValidatedRouterParams(event, trackingLinkIdSchema.parse) + const query = await getValidatedQuery(event, sourceStatsQuerySchema.parse) + + // ─── Fetch the link itself ──────────────── + const link = await db.query.trackingLink.findFirst({ + where: and(eq(trackingLink.id, id), eq(trackingLink.organizationId, orgId)), + }) + + if (!link) { + throw createError({ statusCode: 404, statusMessage: 'Tracking link not found' }) + } + + // ─── Scoped job title ───────────────────── + let jobTitle: string | null = null + if (link.jobId) { + const j = await db.query.job.findFirst({ + where: eq(job.id, link.jobId), + columns: { title: true }, + }) + jobTitle = j?.title ?? null + } + + // ─── Date conditions ────────────────────── + const dateConditions = [eq(applicationSource.trackingLinkId, id)] + if (query.from) { + dateConditions.push(gte(applicationSource.createdAt, new Date(query.from))) + } + if (query.to) { + dateConditions.push(lte(applicationSource.createdAt, new Date(query.to))) + } + + const whereClause = and(...dateConditions) + + // ─── Run all analytics queries in parallel ─ + const [ + statusBreakdown, + dailyTrend, + attributedApplications, + referrerDomains, + totalAttributed, + ] = await Promise.all([ + // 1. Application status breakdown + db + .select({ + status: application.status, + count: count().as('count'), + }) + .from(applicationSource) + .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .where(whereClause) + .groupBy(application.status), + + // 2. Daily trend (applications over time) + db + .select({ + date: sql`date_trunc('day', ${applicationSource.createdAt})::date`.as('day'), + count: count().as('count'), + }) + .from(applicationSource) + .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .where(whereClause) + .groupBy(sql`date_trunc('day', ${applicationSource.createdAt})::date`) + .orderBy(sql`date_trunc('day', ${applicationSource.createdAt})::date`), + + // 3. All attributed applications with candidate + job info + db + .select({ + applicationId: applicationSource.applicationId, + channel: applicationSource.channel, + utmSource: applicationSource.utmSource, + utmMedium: applicationSource.utmMedium, + utmCampaign: applicationSource.utmCampaign, + utmTerm: applicationSource.utmTerm, + utmContent: applicationSource.utmContent, + referrerDomain: applicationSource.referrerDomain, + candidateFirstName: candidate.firstName, + candidateLastName: candidate.lastName, + candidateEmail: candidate.email, + jobTitle: job.title, + jobId: application.jobId, + status: application.status, + appliedAt: applicationSource.createdAt, + }) + .from(applicationSource) + .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) + .innerJoin(job, eq(job.id, application.jobId)) + .where(whereClause) + .orderBy(desc(applicationSource.createdAt)) + .limit(100), + + // 4. Referrer domain breakdown + db + .select({ + domain: applicationSource.referrerDomain, + count: count().as('count'), + }) + .from(applicationSource) + .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .where(and( + ...dateConditions, + sql`${applicationSource.referrerDomain} IS NOT NULL`, + )) + .groupBy(applicationSource.referrerDomain) + .orderBy(sql`count(*) desc`) + .limit(10), + + // 5. Total attributed count + db.$count(applicationSource, eq(applicationSource.trackingLinkId, id)), + ]) + + // ─── Build funnel map ───────────────────── + const funnel: Record = { new: 0, screening: 0, interview: 0, offer: 0, hired: 0, rejected: 0 } + for (const row of statusBreakdown) { + funnel[row.status] = row.count + } + + // ─── Conversion rate ────────────────────── + const cvr = link.clickCount > 0 + ? Math.round((link.applicationCount / link.clickCount) * 100) + : 0 + + return { + link: { + id: link.id, + name: link.name, + channel: link.channel, + code: link.code, + jobId: link.jobId, + jobTitle, + utmSource: link.utmSource, + utmMedium: link.utmMedium, + utmCampaign: link.utmCampaign, + utmTerm: link.utmTerm, + utmContent: link.utmContent, + clickCount: link.clickCount, + applicationCount: link.applicationCount, + isActive: link.isActive, + createdAt: link.createdAt, + cvr, + }, + funnel, + dailyTrend, + attributedApplications, + referrerDomains, + totalAttributed, + } +}) diff --git a/server/api/tracking-links/index.get.ts b/server/api/tracking-links/index.get.ts new file mode 100644 index 00000000..bac3d5f1 --- /dev/null +++ b/server/api/tracking-links/index.get.ts @@ -0,0 +1,66 @@ +import { eq, and, desc, sql } from 'drizzle-orm' +import { trackingLink, job } from '../../database/schema' +import { trackingLinkQuerySchema } from '../../utils/schemas/trackingLink' + +/** + * GET /api/tracking-links + * List tracking links for the current organization. + * Supports filtering by job, channel, and active status. + */ +export default defineEventHandler(async (event) => { + const session = await requirePermission(event, { sourceTracking: ['read'] }) + const orgId = session.session.activeOrganizationId + + const query = await getValidatedQuery(event, trackingLinkQuerySchema.parse) + + const conditions = [eq(trackingLink.organizationId, orgId)] + + if (query.jobId) { + conditions.push(eq(trackingLink.jobId, query.jobId)) + } + if (query.channel) { + conditions.push(eq(trackingLink.channel, query.channel)) + } + if (query.isActive !== undefined) { + conditions.push(eq(trackingLink.isActive, query.isActive)) + } + + const offset = (query.page - 1) * query.limit + + const [items, totalCount] = await Promise.all([ + db + .select({ + id: trackingLink.id, + jobId: trackingLink.jobId, + jobTitle: job.title, + channel: trackingLink.channel, + name: trackingLink.name, + code: trackingLink.code, + utmSource: trackingLink.utmSource, + utmMedium: trackingLink.utmMedium, + utmCampaign: trackingLink.utmCampaign, + utmTerm: trackingLink.utmTerm, + utmContent: trackingLink.utmContent, + clickCount: trackingLink.clickCount, + applicationCount: trackingLink.applicationCount, + isActive: trackingLink.isActive, + createdAt: trackingLink.createdAt, + updatedAt: trackingLink.updatedAt, + }) + .from(trackingLink) + .leftJoin(job, eq(job.id, trackingLink.jobId)) + .where(and(...conditions)) + .orderBy(desc(trackingLink.createdAt)) + .limit(query.limit) + .offset(offset), + + db.$count(trackingLink, and(...conditions)), + ]) + + return { + data: items, + total: totalCount, + page: query.page, + limit: query.limit, + } +}) diff --git a/server/api/tracking-links/index.post.ts b/server/api/tracking-links/index.post.ts new file mode 100644 index 00000000..7f4d98ba --- /dev/null +++ b/server/api/tracking-links/index.post.ts @@ -0,0 +1,66 @@ +import { eq, and } from 'drizzle-orm' +import { trackingLink, job } from '../../database/schema' +import { createTrackingLinkSchema } from '../../utils/schemas/trackingLink' + +/** + * POST /api/tracking-links + * Create a new tracking link with a unique code. + */ +export default defineEventHandler(async (event) => { + const session = await requirePermission(event, { sourceTracking: ['create'] }) + const orgId = session.session.activeOrganizationId + const userId = session.user.id + + const body = await readValidatedBody(event, createTrackingLinkSchema.parse) + + // If scoped to a job, verify the job belongs to this org + if (body.jobId) { + const existingJob = await db.query.job.findFirst({ + where: and(eq(job.id, body.jobId), eq(job.organizationId, orgId)), + columns: { id: true }, + }) + if (!existingJob) { + throw createError({ statusCode: 404, statusMessage: 'Job not found' }) + } + } + + // Generate a unique short code (8 chars, URL-safe) with collision retry + const MAX_RETRIES = 3 + let created + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const code = generateTrackingCode() + try { + const [row] = await db.insert(trackingLink).values({ + organizationId: orgId, + jobId: body.jobId ?? null, + channel: body.channel, + name: body.name, + code, + utmSource: body.utmSource ?? null, + utmMedium: body.utmMedium ?? null, + utmCampaign: body.utmCampaign ?? null, + utmTerm: body.utmTerm ?? null, + utmContent: body.utmContent ?? null, + createdById: userId, + }).returning() + created = row + break + } catch (err: unknown) { + const isUniqueViolation = err instanceof Error && err.message.includes('unique') + if (!isUniqueViolation || attempt === MAX_RETRIES - 1) { + throw err + } + } + } + + setResponseStatus(event, 201) + return created +}) + +/** Generate a cryptographically random URL-safe tracking code */ +function generateTrackingCode(): string { + const bytes = new Uint8Array(6) + crypto.getRandomValues(bytes) + // Base64url encoding without padding — 8 chars from 6 bytes + return Buffer.from(bytes).toString('base64url') +} diff --git a/server/database/migrations/0018_source_tracking.sql b/server/database/migrations/0018_source_tracking.sql new file mode 100644 index 00000000..3c20b951 --- /dev/null +++ b/server/database/migrations/0018_source_tracking.sql @@ -0,0 +1,52 @@ +CREATE TYPE "public"."source_channel" AS ENUM('linkedin', 'indeed', 'glassdoor', 'ziprecruiter', 'monster', 'handshake', 'angellist', 'wellfound', 'dice', 'stackoverflow', 'weworkremotely', 'remoteok', 'builtin', 'hired', 'lever', 'greenhouse_board', 'google_jobs', 'facebook', 'twitter', 'instagram', 'tiktok', 'reddit', 'referral', 'career_site', 'email', 'event', 'agency', 'direct', 'other', 'custom');--> statement-breakpoint +CREATE TABLE "application_source" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "application_id" text NOT NULL, + "channel" "source_channel" DEFAULT 'direct' NOT NULL, + "tracking_link_id" text, + "utm_source" text, + "utm_medium" text, + "utm_campaign" text, + "utm_term" text, + "utm_content" text, + "referrer_domain" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "tracking_link" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "job_id" text, + "channel" "source_channel" DEFAULT 'custom' NOT NULL, + "name" text NOT NULL, + "code" text NOT NULL, + "utm_source" text, + "utm_medium" text, + "utm_campaign" text, + "utm_term" text, + "utm_content" text, + "click_count" integer DEFAULT 0 NOT NULL, + "application_count" integer DEFAULT 0 NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_by_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "tracking_link_code_unique" UNIQUE("code") +); +--> statement-breakpoint +ALTER TABLE "application_source" ADD CONSTRAINT "application_source_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "application_source" ADD CONSTRAINT "application_source_application_id_application_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."application"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "application_source" ADD CONSTRAINT "application_source_tracking_link_id_tracking_link_id_fk" FOREIGN KEY ("tracking_link_id") REFERENCES "public"."tracking_link"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tracking_link" ADD CONSTRAINT "tracking_link_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tracking_link" ADD CONSTRAINT "tracking_link_job_id_job_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."job"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tracking_link" ADD CONSTRAINT "tracking_link_created_by_id_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "application_source_organization_id_idx" ON "application_source" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "application_source_application_id_idx" ON "application_source" USING btree ("application_id");--> statement-breakpoint +CREATE INDEX "application_source_channel_idx" ON "application_source" USING btree ("channel");--> statement-breakpoint +CREATE INDEX "application_source_tracking_link_id_idx" ON "application_source" USING btree ("tracking_link_id");--> statement-breakpoint +CREATE UNIQUE INDEX "application_source_application_idx" ON "application_source" USING btree ("application_id");--> statement-breakpoint +CREATE INDEX "tracking_link_organization_id_idx" ON "tracking_link" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "tracking_link_job_id_idx" ON "tracking_link" USING btree ("job_id");--> statement-breakpoint +CREATE INDEX "tracking_link_code_idx" ON "tracking_link" USING btree ("code");--> statement-breakpoint +CREATE INDEX "tracking_link_channel_idx" ON "tracking_link" USING btree ("channel"); \ No newline at end of file diff --git a/server/database/migrations/meta/0018_snapshot.json b/server/database/migrations/meta/0018_snapshot.json new file mode 100644 index 00000000..f057142d --- /dev/null +++ b/server/database/migrations/meta/0018_snapshot.json @@ -0,0 +1,3954 @@ +{ + "id": "5efdceb2-4bf4-4610-8fbf-bec777fb5f96", + "prevId": "a0d783a5-23a5-4dbc-bbab-22f0367d0f47", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_user_org_unique_idx": { + "name": "member_user_org_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "activity_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_log_organization_id_idx": { + "name": "activity_log_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_actor_id_idx": { + "name": "activity_log_actor_id_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_resource_idx": { + "name": "activity_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_created_at_idx": { + "name": "activity_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_organization_id_organization_id_fk": { + "name": "activity_log_organization_id_organization_id_fk", + "tableFrom": "activity_log", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_log_actor_id_user_id_fk": { + "name": "activity_log_actor_id_user_id_fk", + "tableFrom": "activity_log", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_config": { + "name": "ai_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openai'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gpt-4o-mini'" + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_tokens": { + "name": "max_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4096 + }, + "input_price_per_1m": { + "name": "input_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "output_price_per_1m": { + "name": "output_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_config_organization_id_idx": { + "name": "ai_config_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_config_organization_id_organization_id_fk": { + "name": "ai_config_organization_id_organization_id_fk", + "tableFrom": "ai_config", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.analysis_run": { + "name": "analysis_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "analysis_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "criteria_snapshot": { + "name": "criteria_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "composite_score": { + "name": "composite_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "raw_response": { + "name": "raw_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scored_by_id": { + "name": "scored_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "analysis_run_organization_id_idx": { + "name": "analysis_run_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analysis_run_application_id_idx": { + "name": "analysis_run_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analysis_run_created_at_idx": { + "name": "analysis_run_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "analysis_run_organization_id_organization_id_fk": { + "name": "analysis_run_organization_id_organization_id_fk", + "tableFrom": "analysis_run", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "analysis_run_application_id_application_id_fk": { + "name": "analysis_run_application_id_application_id_fk", + "tableFrom": "analysis_run", + "tableTo": "application", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "analysis_run_scored_by_id_user_id_fk": { + "name": "analysis_run_scored_by_id_user_id_fk", + "tableFrom": "analysis_run", + "tableTo": "user", + "columnsFrom": [ + "scored_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.application": { + "name": "application", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "candidate_id": { + "name": "candidate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_letter_text": { + "name": "cover_letter_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "application_organization_id_idx": { + "name": "application_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_candidate_id_idx": { + "name": "application_candidate_id_idx", + "columns": [ + { + "expression": "candidate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_job_id_idx": { + "name": "application_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_org_candidate_job_idx": { + "name": "application_org_candidate_job_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "candidate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "application_organization_id_organization_id_fk": { + "name": "application_organization_id_organization_id_fk", + "tableFrom": "application", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_candidate_id_candidate_id_fk": { + "name": "application_candidate_id_candidate_id_fk", + "tableFrom": "application", + "tableTo": "candidate", + "columnsFrom": [ + "candidate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_job_id_job_id_fk": { + "name": "application_job_id_job_id_fk", + "tableFrom": "application", + "tableTo": "job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.application_source": { + "name": "application_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "source_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "tracking_link_id": { + "name": "tracking_link_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer_domain": { + "name": "referrer_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "application_source_organization_id_idx": { + "name": "application_source_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_source_application_id_idx": { + "name": "application_source_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_source_channel_idx": { + "name": "application_source_channel_idx", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_source_tracking_link_id_idx": { + "name": "application_source_tracking_link_id_idx", + "columns": [ + { + "expression": "tracking_link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_source_application_idx": { + "name": "application_source_application_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "application_source_organization_id_organization_id_fk": { + "name": "application_source_organization_id_organization_id_fk", + "tableFrom": "application_source", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_source_application_id_application_id_fk": { + "name": "application_source_application_id_application_id_fk", + "tableFrom": "application_source", + "tableTo": "application", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_source_tracking_link_id_tracking_link_id_fk": { + "name": "application_source_tracking_link_id_tracking_link_id_fk", + "tableFrom": "application_source", + "tableTo": "tracking_link", + "columnsFrom": [ + "tracking_link_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_integration": { + "name": "calendar_integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "calendar_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'google'" + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calendar_id": { + "name": "calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'primary'" + }, + "account_email": { + "name": "account_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_channel_id": { + "name": "webhook_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_resource_id": { + "name": "webhook_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_expiration": { + "name": "webhook_expiration", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_token": { + "name": "sync_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "calendar_integration_user_provider_idx": { + "name": "calendar_integration_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_integration_webhook_channel_idx": { + "name": "calendar_integration_webhook_channel_idx", + "columns": [ + { + "expression": "webhook_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_integration_user_id_user_id_fk": { + "name": "calendar_integration_user_id_user_id_fk", + "tableFrom": "calendar_integration", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.candidate": { + "name": "candidate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "candidate_organization_id_idx": { + "name": "candidate_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_org_email_idx": { + "name": "candidate_org_email_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "candidate_organization_id_organization_id_fk": { + "name": "candidate_organization_id_organization_id_fk", + "tableFrom": "candidate", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment": { + "name": "comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "comment_target", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comment_organization_id_idx": { + "name": "comment_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comment_target_idx": { + "name": "comment_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comment_author_id_idx": { + "name": "comment_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_organization_id_organization_id_fk": { + "name": "comment_organization_id_organization_id_fk", + "tableFrom": "comment", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_author_id_user_id_fk": { + "name": "comment_author_id_user_id_fk", + "tableFrom": "comment", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.criterion_score": { + "name": "criterion_score", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "criterion_key": { + "name": "criterion_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_score": { + "name": "max_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "applicant_score": { + "name": "applicant_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "evidence": { + "name": "evidence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strengths": { + "name": "strengths", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "gaps": { + "name": "gaps", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "criterion_score_organization_id_idx": { + "name": "criterion_score_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "criterion_score_application_id_idx": { + "name": "criterion_score_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "criterion_score_app_criterion_idx": { + "name": "criterion_score_app_criterion_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "criterion_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "criterion_score_organization_id_organization_id_fk": { + "name": "criterion_score_organization_id_organization_id_fk", + "tableFrom": "criterion_score", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "criterion_score_application_id_application_id_fk": { + "name": "criterion_score_application_id_application_id_fk", + "tableFrom": "criterion_score", + "tableTo": "application", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "candidate_id": { + "name": "candidate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "document_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'resume'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parsed_content": { + "name": "parsed_content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_organization_id_idx": { + "name": "document_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_candidate_id_idx": { + "name": "document_candidate_id_idx", + "columns": [ + { + "expression": "candidate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_organization_id_organization_id_fk": { + "name": "document_organization_id_organization_id_fk", + "tableFrom": "document", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_candidate_id_candidate_id_fk": { + "name": "document_candidate_id_candidate_id_fk", + "tableFrom": "document", + "tableTo": "candidate", + "columnsFrom": [ + "candidate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "document_storage_key_unique": { + "name": "document_storage_key_unique", + "nullsNotDistinct": false, + "columns": [ + "storage_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_template": { + "name": "email_template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_template_organization_id_idx": { + "name": "email_template_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_template_created_by_id_idx": { + "name": "email_template_created_by_id_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_template_organization_id_organization_id_fk": { + "name": "email_template_organization_id_organization_id_fk", + "tableFrom": "email_template", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_template_created_by_id_user_id_fk": { + "name": "email_template_created_by_id_user_id_fk", + "tableFrom": "email_template", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interview": { + "name": "interview", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "interview_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'video'" + }, + "status": { + "name": "status", + "type": "interview_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interviewers": { + "name": "interviewers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_sent_at": { + "name": "invitation_sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "candidate_response": { + "name": "candidate_response", + "type": "candidate_response", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "candidate_responded_at": { + "name": "candidate_responded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "google_calendar_event_id": { + "name": "google_calendar_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_calendar_event_link": { + "name": "google_calendar_event_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interview_organization_id_idx": { + "name": "interview_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interview_application_id_idx": { + "name": "interview_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interview_scheduled_at_idx": { + "name": "interview_scheduled_at_idx", + "columns": [ + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interview_status_idx": { + "name": "interview_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interview_created_by_id_idx": { + "name": "interview_created_by_id_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interview_organization_id_organization_id_fk": { + "name": "interview_organization_id_organization_id_fk", + "tableFrom": "interview", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "interview_application_id_application_id_fk": { + "name": "interview_application_id_application_id_fk", + "tableFrom": "interview", + "tableTo": "application", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "interview_created_by_id_user_id_fk": { + "name": "interview_created_by_id_user_id_fk", + "tableFrom": "interview", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite_link": { + "name": "invite_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_link_organization_id_idx": { + "name": "invite_link_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_link_token_idx": { + "name": "invite_link_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invite_link_organization_id_organization_id_fk": { + "name": "invite_link_organization_id_organization_id_fk", + "tableFrom": "invite_link", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invite_link_created_by_id_user_id_fk": { + "name": "invite_link_created_by_id_user_id_fk", + "tableFrom": "invite_link", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invite_link_token_unique": { + "name": "invite_link_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job": { + "name": "job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'full_time'" + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "salary_min": { + "name": "salary_min", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "salary_max": { + "name": "salary_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "salary_currency": { + "name": "salary_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "salary_unit": { + "name": "salary_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_status": { + "name": "remote_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valid_through": { + "name": "valid_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "require_resume": { + "name": "require_resume", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "require_cover_letter": { + "name": "require_cover_letter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_score_on_apply": { + "name": "auto_score_on_apply", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_organization_id_idx": { + "name": "job_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_organization_id_organization_id_fk": { + "name": "job_organization_id_organization_id_fk", + "tableFrom": "job", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_slug_unique": { + "name": "job_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_question": { + "name": "job_question", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "question_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'short_text'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_question_organization_id_idx": { + "name": "job_question_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_question_job_id_idx": { + "name": "job_question_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_question_organization_id_organization_id_fk": { + "name": "job_question_organization_id_organization_id_fk", + "tableFrom": "job_question", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "job_question_job_id_job_id_fk": { + "name": "job_question_job_id_job_id_fk", + "tableFrom": "job_question", + "tableTo": "job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_request": { + "name": "join_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "join_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by_id": { + "name": "reviewed_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_request_organization_id_idx": { + "name": "join_request_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_request_user_id_idx": { + "name": "join_request_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_request_status_idx": { + "name": "join_request_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_request_user_id_user_id_fk": { + "name": "join_request_user_id_user_id_fk", + "tableFrom": "join_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_request_organization_id_organization_id_fk": { + "name": "join_request_organization_id_organization_id_fk", + "tableFrom": "join_request", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_request_reviewed_by_id_user_id_fk": { + "name": "join_request_reviewed_by_id_user_id_fk", + "tableFrom": "join_request", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question_response": { + "name": "question_response", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "question_id": { + "name": "question_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "question_response_organization_id_idx": { + "name": "question_response_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "question_response_application_id_idx": { + "name": "question_response_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "question_response_question_id_idx": { + "name": "question_response_question_id_idx", + "columns": [ + { + "expression": "question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "question_response_organization_id_organization_id_fk": { + "name": "question_response_organization_id_organization_id_fk", + "tableFrom": "question_response", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "question_response_application_id_application_id_fk": { + "name": "question_response_application_id_application_id_fk", + "tableFrom": "question_response", + "tableTo": "application", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "question_response_question_id_job_question_id_fk": { + "name": "question_response_question_id_job_question_id_fk", + "tableFrom": "question_response", + "tableTo": "job_question", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scoring_criterion": { + "name": "scoring_criterion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "criterion_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "max_score": { + "name": "max_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scoring_criterion_organization_id_idx": { + "name": "scoring_criterion_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scoring_criterion_job_id_idx": { + "name": "scoring_criterion_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scoring_criterion_job_key_idx": { + "name": "scoring_criterion_job_key_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scoring_criterion_organization_id_organization_id_fk": { + "name": "scoring_criterion_organization_id_organization_id_fk", + "tableFrom": "scoring_criterion", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scoring_criterion_job_id_job_id_fk": { + "name": "scoring_criterion_job_id_job_id_fk", + "tableFrom": "scoring_criterion", + "tableTo": "job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracking_link": { + "name": "tracking_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "source_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "click_count": { + "name": "click_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "application_count": { + "name": "application_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tracking_link_organization_id_idx": { + "name": "tracking_link_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracking_link_job_id_idx": { + "name": "tracking_link_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracking_link_code_idx": { + "name": "tracking_link_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracking_link_channel_idx": { + "name": "tracking_link_channel_idx", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracking_link_organization_id_organization_id_fk": { + "name": "tracking_link_organization_id_organization_id_fk", + "tableFrom": "tracking_link", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tracking_link_job_id_job_id_fk": { + "name": "tracking_link_job_id_job_id_fk", + "tableFrom": "tracking_link", + "tableTo": "job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tracking_link_created_by_id_user_id_fk": { + "name": "tracking_link_created_by_id_user_id_fk", + "tableFrom": "tracking_link", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tracking_link_code_unique": { + "name": "tracking_link_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.activity_action": { + "name": "activity_action", + "schema": "public", + "values": [ + "created", + "updated", + "deleted", + "status_changed", + "comment_added", + "member_invited", + "member_removed", + "member_role_changed", + "scored" + ] + }, + "public.analysis_run_status": { + "name": "analysis_run_status", + "schema": "public", + "values": [ + "completed", + "failed", + "partial" + ] + }, + "public.application_status": { + "name": "application_status", + "schema": "public", + "values": [ + "new", + "screening", + "interview", + "offer", + "hired", + "rejected" + ] + }, + "public.calendar_provider": { + "name": "calendar_provider", + "schema": "public", + "values": [ + "google" + ] + }, + "public.candidate_response": { + "name": "candidate_response", + "schema": "public", + "values": [ + "pending", + "accepted", + "declined", + "tentative" + ] + }, + "public.comment_target": { + "name": "comment_target", + "schema": "public", + "values": [ + "candidate", + "application", + "job" + ] + }, + "public.criterion_category": { + "name": "criterion_category", + "schema": "public", + "values": [ + "technical", + "experience", + "soft_skills", + "education", + "culture", + "custom" + ] + }, + "public.document_type": { + "name": "document_type", + "schema": "public", + "values": [ + "resume", + "cover_letter", + "other" + ] + }, + "public.interview_status": { + "name": "interview_status", + "schema": "public", + "values": [ + "scheduled", + "completed", + "cancelled", + "no_show" + ] + }, + "public.interview_type": { + "name": "interview_type", + "schema": "public", + "values": [ + "phone", + "video", + "in_person", + "panel", + "technical", + "take_home" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "draft", + "open", + "closed", + "archived" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "full_time", + "part_time", + "contract", + "internship" + ] + }, + "public.join_request_status": { + "name": "join_request_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.question_type": { + "name": "question_type", + "schema": "public", + "values": [ + "short_text", + "long_text", + "single_select", + "multi_select", + "number", + "date", + "url", + "checkbox", + "file_upload" + ] + }, + "public.source_channel": { + "name": "source_channel", + "schema": "public", + "values": [ + "linkedin", + "indeed", + "glassdoor", + "ziprecruiter", + "monster", + "handshake", + "angellist", + "wellfound", + "dice", + "stackoverflow", + "weworkremotely", + "remoteok", + "builtin", + "hired", + "lever", + "greenhouse_board", + "google_jobs", + "facebook", + "twitter", + "instagram", + "tiktok", + "reddit", + "referral", + "career_site", + "email", + "event", + "agency", + "direct", + "other", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json index 3b8ec04c..81d82395 100644 --- a/server/database/migrations/meta/_journal.json +++ b/server/database/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1773822054166, "tag": "0017_cynical_nicolaos", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1774599034718, + "tag": "0018_source_tracking", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/database/schema/app.ts b/server/database/schema/app.ts index 37d885a3..509ce5b6 100644 --- a/server/database/schema/app.ts +++ b/server/database/schema/app.ts @@ -395,6 +395,91 @@ export const activityLog = pgTable('activity_log', { index('activity_log_created_at_idx').on(t.createdAt), ])) +// ───────────────────────────────────────────── +// Source Tracking +// ───────────────────────────────────────────── + +/** + * Well-known source identifiers for major job boards and channels. + * `custom` allows organizations to create their own named sources. + */ +export const sourceChannelEnum = pgEnum('source_channel', [ + 'linkedin', 'indeed', 'glassdoor', 'ziprecruiter', 'monster', + 'handshake', 'angellist', 'wellfound', 'dice', 'stackoverflow', + 'weworkremotely', 'remoteok', 'builtin', 'hired', 'lever', + 'greenhouse_board', 'google_jobs', 'facebook', 'twitter', 'instagram', + 'tiktok', 'reddit', 'referral', 'career_site', 'email', + 'event', 'agency', 'direct', 'other', 'custom', +]) + +/** + * Tracking links generated by recruiters to attribute candidate sources. + * Each link produces a unique campaign code appended as `?ref=CODE` to the + * public job page or global careers page. When a candidate applies through + * a tracked link, the application records the source. + */ +export const trackingLink = pgTable('tracking_link', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }), + /** Optional — links may be org-wide (null) or scoped to a single job */ + jobId: text('job_id').references(() => job.id, { onDelete: 'cascade' }), + /** Canonical source channel */ + channel: sourceChannelEnum('channel').notNull().default('custom'), + /** Human-readable label, e.g. "LinkedIn Spring Campaign" */ + name: text('name').notNull(), + /** Unique short code used in ?ref=CODE — generated from crypto */ + code: text('code').notNull().unique(), + /** Standard UTM parameters captured for external analytics */ + utmSource: text('utm_source'), + utmMedium: text('utm_medium'), + utmCampaign: text('utm_campaign'), + utmTerm: text('utm_term'), + utmContent: text('utm_content'), + /** Aggregate counters (incremented on each click/application) */ + clickCount: integer('click_count').notNull().default(0), + applicationCount: integer('application_count').notNull().default(0), + /** Soft-disabled — deactivated links stop incrementing counts */ + isActive: boolean('is_active').notNull().default(true), + createdById: text('created_by_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}, (t) => ([ + index('tracking_link_organization_id_idx').on(t.organizationId), + index('tracking_link_job_id_idx').on(t.jobId), + index('tracking_link_code_idx').on(t.code), + index('tracking_link_channel_idx').on(t.channel), +])) + +/** + * Per-application source attribution — records HOW a candidate discovered + * and applied to a job. One row per application. Populated at apply time + * from ?ref=, ?utm_*, or Referer header. + */ +export const applicationSource = pgTable('application_source', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }), + applicationId: text('application_id').notNull().references(() => application.id, { onDelete: 'cascade' }), + /** Resolved channel — normalized from tracking link, UTM, or Referer */ + channel: sourceChannelEnum('channel').notNull().default('direct'), + /** FK to tracking_link if the application came via a tracked link */ + trackingLinkId: text('tracking_link_id').references(() => trackingLink.id, { onDelete: 'set null' }), + /** Raw UTM query params captured from the application URL */ + utmSource: text('utm_source'), + utmMedium: text('utm_medium'), + utmCampaign: text('utm_campaign'), + utmTerm: text('utm_term'), + utmContent: text('utm_content'), + /** Cleaned Referer header (domain only — no path/query for privacy) */ + referrerDomain: text('referrer_domain'), + createdAt: timestamp('created_at').notNull().defaultNow(), +}, (t) => ([ + index('application_source_organization_id_idx').on(t.organizationId), + index('application_source_application_id_idx').on(t.applicationId), + index('application_source_channel_idx').on(t.channel), + index('application_source_tracking_link_id_idx').on(t.trackingLinkId), + uniqueIndex('application_source_application_idx').on(t.applicationId), +])) + // ───────────────────────────────────────────── // AI Configuration & Scoring Tables // ───────────────────────────────────────────── @@ -510,6 +595,7 @@ export const jobRelations = relations(job, ({ one, many }) => ({ applications: many(application), questions: many(jobQuestion), scoringCriteria: many(scoringCriterion), + trackingLinks: many(trackingLink), })) export const candidateRelations = relations(candidate, ({ one, many }) => ({ @@ -526,6 +612,7 @@ export const applicationRelations = relations(application, ({ one, many }) => ({ interviews: many(interview), criterionScores: many(criterionScore), analysisRuns: many(analysisRun), + source: one(applicationSource), })) export const documentRelations = relations(document, ({ one }) => ({ @@ -601,3 +688,18 @@ export const analysisRunRelations = relations(analysisRun, ({ one }) => ({ application: one(application, { fields: [analysisRun.applicationId], references: [application.id] }), scoredBy: one(user, { fields: [analysisRun.scoredById], references: [user.id] }), })) + +// ─── Source Tracking Relations ───────────────────────────────────── + +export const trackingLinkRelations = relations(trackingLink, ({ one, many }) => ({ + organization: one(organization, { fields: [trackingLink.organizationId], references: [organization.id] }), + job: one(job, { fields: [trackingLink.jobId], references: [job.id] }), + createdBy: one(user, { fields: [trackingLink.createdById], references: [user.id] }), + applicationSources: many(applicationSource), +})) + +export const applicationSourceRelations = relations(applicationSource, ({ one }) => ({ + organization: one(organization, { fields: [applicationSource.organizationId], references: [organization.id] }), + application: one(application, { fields: [applicationSource.applicationId], references: [application.id] }), + trackingLink: one(trackingLink, { fields: [applicationSource.trackingLinkId], references: [trackingLink.id] }), +})) diff --git a/server/plugins/posthog.ts b/server/plugins/posthog.ts index a5d68c1a..3cd0a7a4 100644 --- a/server/plugins/posthog.ts +++ b/server/plugins/posthog.ts @@ -4,6 +4,7 @@ * * – PostHog Node client (event capture, error tracking) * – OpenTelemetry logger (PostHog Logs via OTLP) + * – Filtered error capture (skips 404s from bot scanners) */ export default defineNitroPlugin((nitroApp) => { // Start the OpenTelemetry LoggerProvider so structured logs @@ -11,6 +12,18 @@ export default defineNitroPlugin((nitroApp) => { // of the server process. initLoggerProvider() + // Capture server errors to PostHog, but skip 404 "Page not found" errors + // which are overwhelmingly bot/vulnerability scanner noise. + nitroApp.hooks.hook('error', (error) => { + const statusCode = (error as { statusCode?: number }).statusCode + if (statusCode === 404) return + + const ph = useServerPostHog() + if (!ph) return + + ph.captureException(error) + }) + nitroApp.hooks.hookOnce('close', async () => { await Promise.all([ shutdownServerPostHog(), diff --git a/server/scripts/reset-demo.ts b/server/scripts/reset-demo.ts deleted file mode 100644 index ca344bc3..00000000 --- a/server/scripts/reset-demo.ts +++ /dev/null @@ -1,62 +0,0 @@ -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.query.organization.findFirst({ - where: eq(schema.organization.slug, 'reqcore-demo'), - }) - - if (org) { - // Manually delete tables that lack ON DELETE CASCADE in the live DB - await db.delete(schema.scoringCriterion).where(eq(schema.scoringCriterion.organizationId, org.id)) - console.log('✅ Deleted scoring criteria') - - await db.delete(schema.organization).where(eq(schema.organization.id, org.id)) - console.log('✅ Deleted demo org and all cascaded data') - } - else { - console.log('⚠️ No demo org found (slug: reqcore-demo)') - } - - const user = await db.query.user.findFirst({ - where: eq(schema.user.email, 'demo@reqcore.com'), - }) - - if (user) { - await db.delete(schema.user).where(eq(schema.user.id, user.id)) - console.log('✅ Deleted demo user (demo@reqcore.com)') - } - else { - console.log('⚠️ No demo user found') - } - - await client.end() - console.log('Done.') -} - -main().catch((err) => { - console.error(err) - process.exit(1) -}) diff --git a/server/scripts/seed.ts b/server/scripts/seed.ts index b45c286e..b253293b 100644 --- a/server/scripts/seed.ts +++ b/server/scripts/seed.ts @@ -9,6 +9,8 @@ * - 65+ applications across all pipeline stages * - Custom questions on select jobs * - Question responses on applications + * - 20 tracking links across 14+ source channels (LinkedIn, Indeed, etc.) + * - 55+ application source attribution records with UTM & referrer data * - 35+ scheduled/completed interviews across the pipeline * - 200+ activity log entries covering all action types (for Timeline page) * @@ -1797,6 +1799,407 @@ function generateResponses(jobIndex: number, candidateIndex: number): Record l.channel)).size} channels`) + + // Create application source records for attributed applications + let totalSources = 0 + + for (const src of APPLICATION_SOURCES_DATA) { + const applicationId = applicationMap.get(`${src.jobIndex}-${src.candidateIndex}`) + if (!applicationId) { + console.warn(`⚠️ Skipping source attribution — no application found for job ${src.jobIndex}, candidate ${src.candidateIndex}`) + continue + } + + const trackingLinkId = src.trackingLinkIndex !== null + ? (trackingLinkIds[src.trackingLinkIndex] ?? null) + : null + + await db.insert(schema.applicationSource).values({ + id: id(), + organizationId: orgId, + applicationId, + channel: src.channel, + trackingLinkId, + utmSource: src.utmSource ?? null, + utmMedium: src.utmMedium ?? null, + utmCampaign: src.utmCampaign ?? null, + utmTerm: src.utmTerm ?? null, + utmContent: src.utmContent ?? null, + referrerDomain: src.referrerDomain ?? null, + createdAt: daysAgo(1 + Math.floor(Math.random() * 15)), + }) + totalSources++ + } + + // Compute source distribution for logging + const channelCounts: Record = {} + for (const src of APPLICATION_SOURCES_DATA) { + channelCounts[src.channel] = (channelCounts[src.channel] || 0) + 1 + } + const trackedCount = APPLICATION_SOURCES_DATA.filter(s => s.trackingLinkIndex !== null).length + + console.log(`✅ Created ${totalSources} application source records (${trackedCount} via tracking links)`) + console.log(` 📊 Source channels: ${Object.entries(channelCounts).map(([ch, n]) => `${ch}: ${n}`).join(', ')}`) + // 7. Create AI scoring criteria and scores for first 3 jobs let totalCriteria = 0 let totalScores = 0 diff --git a/server/utils/schemas/publicApplication.ts b/server/utils/schemas/publicApplication.ts index 6b879daf..713b6854 100644 --- a/server/utils/schemas/publicApplication.ts +++ b/server/utils/schemas/publicApplication.ts @@ -26,6 +26,13 @@ export const publicApplicationSchema = z.object({ coverLetterText: z.string().max(10000).optional(), /** Honeypot field — bots fill it, humans don't see it. Validated at runtime in the handler. */ website: z.string().optional(), + /** Source attribution — captured from URL query parameters on the apply page */ + ref: z.string().max(100).optional(), + utmSource: z.string().max(200).optional(), + utmMedium: z.string().max(200).optional(), + utmCampaign: z.string().max(200).optional(), + utmTerm: z.string().max(200).optional(), + utmContent: z.string().max(200).optional(), }) /** Route param for public job slug */ diff --git a/server/utils/schemas/trackingLink.ts b/server/utils/schemas/trackingLink.ts new file mode 100644 index 00000000..faa05a8d --- /dev/null +++ b/server/utils/schemas/trackingLink.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' + +// ───────────────────────────────────────────── +// Source tracking validation schemas +// ───────────────────────────────────────────── + +export const sourceChannels = [ + 'linkedin', 'indeed', 'glassdoor', 'ziprecruiter', 'monster', + 'handshake', 'angellist', 'wellfound', 'dice', 'stackoverflow', + 'weworkremotely', 'remoteok', 'builtin', 'hired', 'lever', + 'greenhouse_board', 'google_jobs', 'facebook', 'twitter', 'instagram', + 'tiktok', 'reddit', 'referral', 'career_site', 'email', + 'event', 'agency', 'direct', 'other', 'custom', +] as const + +export type SourceChannel = typeof sourceChannels[number] + +const sourceChannelSchema = z.enum(sourceChannels) + +/** Schema for creating a tracking link */ +export const createTrackingLinkSchema = z.object({ + jobId: z.string().min(1).optional(), + channel: sourceChannelSchema.default('custom'), + name: z.string().min(1, 'Name is required').max(200), + utmSource: z.string().max(200).optional(), + utmMedium: z.string().max(200).optional(), + utmCampaign: z.string().max(200).optional(), + utmTerm: z.string().max(200).optional(), + utmContent: z.string().max(200).optional(), +}) + +/** Schema for updating a tracking link */ +export const updateTrackingLinkSchema = z.object({ + name: z.string().min(1).max(200).optional(), + channel: sourceChannelSchema.optional(), + utmSource: z.string().max(200).optional(), + utmMedium: z.string().max(200).optional(), + utmCampaign: z.string().max(200).optional(), + utmTerm: z.string().max(200).optional(), + utmContent: z.string().max(200).optional(), + isActive: z.boolean().optional(), +}) + +/** Route param for tracking link ID */ +export const trackingLinkIdSchema = z.object({ + id: z.string().min(1), +}) + +/** Query params for listing tracking links */ +export const trackingLinkQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(50), + jobId: z.string().min(1).optional(), + channel: sourceChannelSchema.optional(), + isActive: z.enum(['true', 'false']).optional().transform((v) => v === undefined ? undefined : v === 'true'), +}) + +/** Query params for source tracking stats */ +export const sourceStatsQuerySchema = z.object({ + jobId: z.string().min(1).optional(), + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), +}) + +/** + * Schema added to the public application to capture source attribution. + * These fields are appended by the client from URL query parameters. + */ +export const applicationSourceSchema = z.object({ + ref: z.string().max(100).optional(), + utmSource: z.string().max(200).optional(), + utmMedium: z.string().max(200).optional(), + utmCampaign: z.string().max(200).optional(), + utmTerm: z.string().max(200).optional(), + utmContent: z.string().max(200).optional(), +}) diff --git a/shared/permissions.ts b/shared/permissions.ts index 4bc97c7a..a4d97992 100644 --- a/shared/permissions.ts +++ b/shared/permissions.ts @@ -37,6 +37,7 @@ const atsStatements = { emailTemplate: ['create', 'read', 'update', 'delete'], activityLog: ['read'], scoring: ['create', 'read', 'update', 'delete'], + sourceTracking: ['create', 'read', 'update', 'delete'], } as const // ─── Merged statement (Better Auth defaults + ATS resources) ─────── @@ -65,6 +66,7 @@ export const owner = ac.newRole({ emailTemplate: ['create', 'read', 'update', 'delete'], activityLog: ['read'], scoring: ['create', 'read', 'update', 'delete'], + sourceTracking: ['create', 'read', 'update', 'delete'], }) export const admin = ac.newRole({ @@ -78,6 +80,7 @@ export const admin = ac.newRole({ emailTemplate: ['create', 'read', 'update', 'delete'], activityLog: ['read'], scoring: ['create', 'read', 'update', 'delete'], + sourceTracking: ['create', 'read', 'update', 'delete'], }) export const member = ac.newRole({ @@ -91,4 +94,5 @@ export const member = ac.newRole({ emailTemplate: ['create', 'read', 'update'], activityLog: ['read'], scoring: ['create', 'read'], + sourceTracking: ['read'], }) diff --git a/tests/unit/candidate-timeline.test.ts b/tests/unit/candidate-timeline.test.ts new file mode 100644 index 00000000..0935a3e2 --- /dev/null +++ b/tests/unit/candidate-timeline.test.ts @@ -0,0 +1,345 @@ +import { describe, it, expect } from 'vitest' +import { z } from 'zod' + +// ───────────────────────────────────────────── +// 1. Query schema validation +// ───────────────────────────────────────────── + +const querySchema = z.object({ + candidateId: z.string().min(1), + limit: z.coerce.number().int().min(1).max(200).default(50), +}) + +describe('candidate-timeline query schema', () => { + it('accepts a valid candidateId', () => { + const result = querySchema.parse({ candidateId: 'cand-123' }) + expect(result.candidateId).toBe('cand-123') + expect(result.limit).toBe(50) // default + }) + + it('accepts candidateId with custom limit', () => { + const result = querySchema.parse({ candidateId: 'cand-456', limit: '25' }) + expect(result.candidateId).toBe('cand-456') + expect(result.limit).toBe(25) + }) + + it('rejects empty candidateId', () => { + expect(() => querySchema.parse({ candidateId: '' })).toThrow() + }) + + it('rejects missing candidateId', () => { + expect(() => querySchema.parse({})).toThrow() + }) + + it('clamps limit to max 200', () => { + expect(() => querySchema.parse({ candidateId: 'c1', limit: '300' })).toThrow() + }) + + it('clamps limit to min 1', () => { + expect(() => querySchema.parse({ candidateId: 'c1', limit: '0' })).toThrow() + }) + + it('rejects non-integer limit', () => { + expect(() => querySchema.parse({ candidateId: 'c1', limit: '2.5' })).toThrow() + }) + + it('coerces string limit to number', () => { + const result = querySchema.parse({ candidateId: 'c1', limit: '100' }) + expect(result.limit).toBe(100) + expect(typeof result.limit).toBe('number') + }) +}) + +// ───────────────────────────────────────────── +// 2. Timeline display helpers (from CandidateDetailSidebar) +// ───────────────────────────────────────────── + +const timelineActionLabels: Record = { + created: 'Created', + updated: 'Updated', + deleted: 'Deleted', + status_changed: 'Status changed', + comment_added: 'Comment added', + scored: 'Scored', + scheduled: 'Scheduled', +} + +interface TimelineEntry { + id: string + action: string + resourceType: string + resourceId: string + metadata: Record | null + createdAt: string + actorName: string | null + actorEmail: string | null + resourceName: string | null + jobTitle: string | null + candidateName: string | null +} + +function getTimelineActionColor(action: string): string { + switch (action) { + case 'created': return 'bg-green-500' + case 'status_changed': return 'bg-blue-500' + case 'updated': return 'bg-amber-500' + case 'deleted': return 'bg-danger-500' + case 'comment_added': return 'bg-violet-500' + case 'scored': return 'bg-teal-500' + case 'scheduled': return 'bg-brand-500' + default: return 'bg-surface-400' + } +} + +function describeTimelineItem(item: TimelineEntry): string { + const actor = item.actorName ?? item.actorEmail ?? 'System' + const action = timelineActionLabels[item.action] ?? item.action + const resource = item.resourceType + + if (item.action === 'status_changed' && item.metadata) { + const from = item.metadata.from_status ?? item.metadata.fromStatus + const to = item.metadata.to_status ?? item.metadata.toStatus + if (from && to) return `${actor} changed ${resource} status from ${from} to ${to}` + } + + if (item.action === 'scored' && item.metadata) { + const score = item.metadata.score + if (score != null) return `${actor} scored ${resource} — ${score} pts` + } + + return `${actor} ${action.toLowerCase()} ${resource}` +} + +function makeEntry(overrides: Partial = {}): TimelineEntry { + return { + id: 'entry-1', + action: 'created', + resourceType: 'application', + resourceId: 'app-1', + metadata: null, + createdAt: '2026-03-15T10:30:00.000Z', + actorName: 'Alice Smith', + actorEmail: 'alice@example.com', + resourceName: null, + jobTitle: null, + candidateName: null, + ...overrides, + } +} + +describe('getTimelineActionColor', () => { + it('returns green for created', () => { + expect(getTimelineActionColor('created')).toBe('bg-green-500') + }) + + it('returns blue for status_changed', () => { + expect(getTimelineActionColor('status_changed')).toBe('bg-blue-500') + }) + + it('returns amber for updated', () => { + expect(getTimelineActionColor('updated')).toBe('bg-amber-500') + }) + + it('returns danger for deleted', () => { + expect(getTimelineActionColor('deleted')).toBe('bg-danger-500') + }) + + it('returns violet for comment_added', () => { + expect(getTimelineActionColor('comment_added')).toBe('bg-violet-500') + }) + + it('returns teal for scored', () => { + expect(getTimelineActionColor('scored')).toBe('bg-teal-500') + }) + + it('returns brand for scheduled', () => { + expect(getTimelineActionColor('scheduled')).toBe('bg-brand-500') + }) + + it('returns surface for unknown action', () => { + expect(getTimelineActionColor('some_random_action')).toBe('bg-surface-400') + }) +}) + +describe('describeTimelineItem', () => { + it('describes a basic created event using actor name', () => { + const item = makeEntry({ action: 'created', resourceType: 'application' }) + expect(describeTimelineItem(item)).toBe('Alice Smith created application') + }) + + it('falls back to actorEmail when actorName is null', () => { + const item = makeEntry({ actorName: null }) + expect(describeTimelineItem(item)).toBe('alice@example.com created application') + }) + + it('falls back to "System" when both actor fields are null', () => { + const item = makeEntry({ actorName: null, actorEmail: null }) + expect(describeTimelineItem(item)).toBe('System created application') + }) + + it('describes status_changed with from/to metadata (underscore keys)', () => { + const item = makeEntry({ + action: 'status_changed', + resourceType: 'application', + metadata: { from_status: 'new', to_status: 'screening' }, + }) + expect(describeTimelineItem(item)).toBe('Alice Smith changed application status from new to screening') + }) + + it('describes status_changed with camelCase metadata keys', () => { + const item = makeEntry({ + action: 'status_changed', + resourceType: 'application', + metadata: { fromStatus: 'screening', toStatus: 'interview' }, + }) + expect(describeTimelineItem(item)).toBe('Alice Smith changed application status from screening to interview') + }) + + it('falls back to generic description when status_changed metadata is incomplete', () => { + const item = makeEntry({ + action: 'status_changed', + resourceType: 'application', + metadata: { from_status: 'new' }, // missing to_status + }) + expect(describeTimelineItem(item)).toBe('Alice Smith status changed application') + }) + + it('describes scored event with score metadata', () => { + const item = makeEntry({ + action: 'scored', + resourceType: 'application', + metadata: { score: 85 }, + }) + expect(describeTimelineItem(item)).toBe('Alice Smith scored application — 85 pts') + }) + + it('handles scored with 0 score', () => { + const item = makeEntry({ + action: 'scored', + resourceType: 'application', + metadata: { score: 0 }, + }) + expect(describeTimelineItem(item)).toBe('Alice Smith scored application — 0 pts') + }) + + it('handles scored without score metadata', () => { + const item = makeEntry({ + action: 'scored', + resourceType: 'application', + metadata: {}, + }) + expect(describeTimelineItem(item)).toBe('Alice Smith scored application') + }) + + it('describes candidate resource type', () => { + const item = makeEntry({ action: 'updated', resourceType: 'candidate' }) + expect(describeTimelineItem(item)).toBe('Alice Smith updated candidate') + }) + + it('describes unknown action verbatim in lowercase', () => { + const item = makeEntry({ action: 'custom_action' }) + expect(describeTimelineItem(item)).toBe('Alice Smith custom_action application') + }) + + it('describes comment_added action', () => { + const item = makeEntry({ action: 'comment_added', resourceType: 'application' }) + expect(describeTimelineItem(item)).toBe('Alice Smith comment added application') + }) + + it('describes deleted action', () => { + const item = makeEntry({ action: 'deleted', resourceType: 'candidate' }) + expect(describeTimelineItem(item)).toBe('Alice Smith deleted candidate') + }) +}) + +// ───────────────────────────────────────────── +// 3. Source tracking sidebar state helpers +// ───────────────────────────────────────────── + +describe('source-tracking sidebar state', () => { + it('selectedAppId starts as null (sidebar closed)', () => { + const selectedAppId: string | null = null + const sidebarOpen = Boolean(selectedAppId) + expect(sidebarOpen).toBe(false) + }) + + it('setting selectedAppId opens the sidebar', () => { + const selectedAppId: string | null = 'app-123' + const sidebarOpen = Boolean(selectedAppId) + expect(sidebarOpen).toBe(true) + }) + + it('clearing selectedAppId closes the sidebar', () => { + let selectedAppId: string | null = 'app-123' + selectedAppId = null + const sidebarOpen = Boolean(selectedAppId) + expect(sidebarOpen).toBe(false) + }) +}) + +// ───────────────────────────────────────────── +// 4. Timeline tab state management +// ───────────────────────────────────────────── + +describe('timeline tab lazy loading', () => { + it('does not load until timeline tab is active', () => { + let activeTab: 'overview' | 'timeline' = 'overview' + let loaded = false + + // Simulating the watch behavior + if (activeTab === 'timeline' && !loaded) { + loaded = true + } + + expect(loaded).toBe(false) + }) + + it('triggers load when timeline tab becomes active', () => { + let activeTab: 'overview' | 'timeline' = 'timeline' + let loaded = false + + if (activeTab === 'timeline' && !loaded) { + loaded = true + } + + expect(loaded).toBe(true) + }) + + it('does not reload if already loaded', () => { + let loadCount = 0 + const loaded = true + + // Simulate switching back to timeline + const activeTab: 'overview' | 'timeline' = 'timeline' + if (activeTab === 'timeline' && !loaded) { + loadCount++ + } + + expect(loadCount).toBe(0) + }) +}) + +// ───────────────────────────────────────────── +// 5. Timeline items reset on application switch +// ───────────────────────────────────────────── + +describe('timeline state reset on application switch', () => { + it('clears timeline data when applicationId changes', () => { + // Simulate initial state with loaded timeline + let timelineItems: TimelineEntry[] = [makeEntry()] + let timelineLoaded = true + let timelineError: string | null = null + let activeTab = 'timeline' + + // Simulate applicationId change handler + activeTab = 'overview' + timelineItems = [] + timelineLoaded = false + timelineError = null + + expect(timelineItems).toEqual([]) + expect(timelineLoaded).toBe(false) + expect(timelineError).toBeNull() + expect(activeTab).toBe('overview') + }) +}) diff --git a/tests/unit/source-tracking.test.ts b/tests/unit/source-tracking.test.ts new file mode 100644 index 00000000..59f05fa6 --- /dev/null +++ b/tests/unit/source-tracking.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } from 'vitest' +import { + createTrackingLinkSchema, + updateTrackingLinkSchema, + trackingLinkIdSchema, + trackingLinkQuerySchema, + sourceStatsQuerySchema, + applicationSourceSchema, +} from '../../server/utils/schemas/trackingLink' + +// ───────────────────────────────────────────── +// 1. createTrackingLinkSchema +// ───────────────────────────────────────────── + +describe('createTrackingLinkSchema', () => { + it('accepts minimal valid input (name only)', () => { + const result = createTrackingLinkSchema.parse({ name: 'LinkedIn Q1' }) + expect(result.name).toBe('LinkedIn Q1') + expect(result.channel).toBe('custom') // default + expect(result.jobId).toBeUndefined() + }) + + it('accepts full input with all optional fields', () => { + const result = createTrackingLinkSchema.parse({ + name: 'Full campaign', + channel: 'linkedin', + jobId: 'job-1', + utmSource: 'linkedin', + utmMedium: 'social', + utmCampaign: 'q1-hiring', + utmTerm: 'engineering', + utmContent: 'banner', + }) + expect(result.channel).toBe('linkedin') + expect(result.utmCampaign).toBe('q1-hiring') + }) + + it('rejects empty name', () => { + expect(() => createTrackingLinkSchema.parse({ name: '' })).toThrow() + }) + + it('rejects name exceeding 200 chars', () => { + expect(() => createTrackingLinkSchema.parse({ name: 'x'.repeat(201) })).toThrow() + }) + + it('rejects invalid channel value', () => { + expect(() => createTrackingLinkSchema.parse({ name: 'ok', channel: 'invalid_channel' })).toThrow() + }) + + it('rejects UTM fields exceeding 200 chars', () => { + expect(() => + createTrackingLinkSchema.parse({ name: 'ok', utmSource: 'a'.repeat(201) }), + ).toThrow() + }) + + it('strips unknown properties', () => { + const result = createTrackingLinkSchema.parse({ + name: 'test', + __proto__: { admin: true }, + malicious: 'payload', + } as any) + expect((result as any).malicious).toBeUndefined() + }) +}) + +// ───────────────────────────────────────────── +// 2. updateTrackingLinkSchema +// ───────────────────────────────────────────── + +describe('updateTrackingLinkSchema', () => { + it('accepts empty update (all optional)', () => { + const result = updateTrackingLinkSchema.parse({}) + expect(Object.keys(result)).toHaveLength(0) + }) + + it('accepts partial update', () => { + const result = updateTrackingLinkSchema.parse({ isActive: false }) + expect(result.isActive).toBe(false) + expect(result.name).toBeUndefined() + }) + + it('rejects empty name', () => { + expect(() => updateTrackingLinkSchema.parse({ name: '' })).toThrow() + }) + + it('rejects name exceeding 200 chars', () => { + expect(() => updateTrackingLinkSchema.parse({ name: 'x'.repeat(201) })).toThrow() + }) + + it('accepts valid channel change', () => { + const result = updateTrackingLinkSchema.parse({ channel: 'indeed' }) + expect(result.channel).toBe('indeed') + }) + + it('rejects non-boolean isActive', () => { + expect(() => updateTrackingLinkSchema.parse({ isActive: 'yes' })).toThrow() + }) +}) + +// ───────────────────────────────────────────── +// 3. trackingLinkIdSchema +// ───────────────────────────────────────────── + +describe('trackingLinkIdSchema', () => { + it('accepts a valid ID string', () => { + const result = trackingLinkIdSchema.parse({ id: 'abc-123' }) + expect(result.id).toBe('abc-123') + }) + + it('rejects empty ID', () => { + expect(() => trackingLinkIdSchema.parse({ id: '' })).toThrow() + }) + + it('rejects missing ID', () => { + expect(() => trackingLinkIdSchema.parse({})).toThrow() + }) +}) + +// ───────────────────────────────────────────── +// 4. trackingLinkQuerySchema +// ───────────────────────────────────────────── + +describe('trackingLinkQuerySchema', () => { + it('provides defaults for page and limit', () => { + const result = trackingLinkQuerySchema.parse({}) + expect(result.page).toBe(1) + expect(result.limit).toBe(50) + }) + + it('coerces string page/limit to numbers', () => { + const result = trackingLinkQuerySchema.parse({ page: '3', limit: '25' }) + expect(result.page).toBe(3) + expect(result.limit).toBe(25) + }) + + it('rejects page < 1', () => { + expect(() => trackingLinkQuerySchema.parse({ page: '0' })).toThrow() + }) + + it('rejects limit > 100', () => { + expect(() => trackingLinkQuerySchema.parse({ limit: '101' })).toThrow() + }) + + it('transforms isActive string to boolean', () => { + const active = trackingLinkQuerySchema.parse({ isActive: 'true' }) + expect(active.isActive).toBe(true) + + const inactive = trackingLinkQuerySchema.parse({ isActive: 'false' }) + expect(inactive.isActive).toBe(false) + }) + + it('leaves isActive undefined when not provided', () => { + const result = trackingLinkQuerySchema.parse({}) + expect(result.isActive).toBeUndefined() + }) + + it('accepts optional jobId and channel filters', () => { + const result = trackingLinkQuerySchema.parse({ jobId: 'j-1', channel: 'linkedin' }) + expect(result.jobId).toBe('j-1') + expect(result.channel).toBe('linkedin') + }) +}) + +// ───────────────────────────────────────────── +// 5. sourceStatsQuerySchema +// ───────────────────────────────────────────── + +describe('sourceStatsQuerySchema', () => { + it('accepts empty query (all optional)', () => { + const result = sourceStatsQuerySchema.parse({}) + expect(result.jobId).toBeUndefined() + expect(result.from).toBeUndefined() + expect(result.to).toBeUndefined() + }) + + it('accepts valid ISO datetime strings', () => { + const result = sourceStatsQuerySchema.parse({ + from: '2025-01-01T00:00:00Z', + to: '2025-12-31T23:59:59Z', + }) + expect(result.from).toBe('2025-01-01T00:00:00Z') + expect(result.to).toBe('2025-12-31T23:59:59Z') + }) + + it('rejects non-datetime from/to strings', () => { + expect(() => sourceStatsQuerySchema.parse({ from: 'yesterday' })).toThrow() + }) + + it('accepts optional jobId', () => { + const result = sourceStatsQuerySchema.parse({ jobId: 'job-abc' }) + expect(result.jobId).toBe('job-abc') + }) +}) + +// ───────────────────────────────────────────── +// 6. applicationSourceSchema (public apply body) +// ───────────────────────────────────────────── + +describe('applicationSourceSchema', () => { + it('accepts empty object (all optional)', () => { + const result = applicationSourceSchema.parse({}) + expect(result.ref).toBeUndefined() + }) + + it('accepts valid ref and UTM fields', () => { + const result = applicationSourceSchema.parse({ + ref: 'TRACK123', + utmSource: 'linkedin', + utmMedium: 'social', + utmCampaign: 'spring-2025', + utmTerm: 'engineer', + utmContent: 'banner-ad', + }) + expect(result.ref).toBe('TRACK123') + expect(result.utmSource).toBe('linkedin') + }) + + it('rejects ref exceeding 100 chars', () => { + expect(() => applicationSourceSchema.parse({ ref: 'x'.repeat(101) })).toThrow() + }) + + it('rejects UTM fields exceeding 200 chars', () => { + expect(() => + applicationSourceSchema.parse({ utmSource: 'a'.repeat(201) }), + ).toThrow() + }) + + it('strips unknown properties', () => { + const result = applicationSourceSchema.parse({ + ref: 'ok', + xss: '', + } as any) + expect((result as any).xss).toBeUndefined() + }) +})