diff --git a/.env.example b/.env.example index d1f2cf6c..097c2ff7 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,18 @@ NOTIFICATIONS_ENABLED=true # RESEND_WEBHOOK_SECRET below. External schedulers can trigger the worker at # /api/admin/notification-dispatch using CRON_SECRET (as above). +# ─── Lifecycle (activation) emails ────────────────────────────────────────── +# Emits funnel events (org.created, job.posted) to Resend Automations, which +# owns the waiting, branching, and copy. Distinct from the recruiter +# notifications above: those report workspace activity, these chase an absence +# ("signed up, never opened a role"). +# +# Fail-CLOSED — leave unset when self-hosting. Enabling it sends your users' +# email addresses to the Resend account behind RESEND_API_KEY, and the +# automations themselves live in that account's dashboard, not in this repo. +# Requires RESEND_API_KEY. +# LIFECYCLE_EMAILS_ENABLED + # ─── SEO ───────────────────────────────────────────────────────────────────── # Used by @nuxtjs/seo for sitemaps, canonical URLs, and OG tags NUXT_PUBLIC_SITE_URL=http://localhost:3000 diff --git a/.npmrc b/.npmrc index ffa72159..8eff4cfa 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,10 @@ # Only fail npm audit on high/critical severity vulnerabilities. audit-level=high + +# npm resolves peer dependencies differently than every lockfile this project +# has ever produced: the lock was generated on machines with +# legacy-peer-deps=true in ~/.npmrc, so CI (which has no user npmrc) rejected +# it with "Missing: oxc-parser@0.144.0 from lock file" — an optional peer of +# unctx that legacy resolution skips. Setting it here makes the resolution +# mode a property of the repo rather than of whoever last ran npm install. +legacy-peer-deps=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 29889425..6f5ded5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com). Categories: **Add ### Changed +* **dashboard:** the "To Review" tile now counts applicants you haven't opened, matching the read receipts already used by the jobs list, instead of the size of the `new` stage — a stage that stayed high long after every application in it had been read. Clicking it opens the candidates list filtered to the same set (`?viewed=unviewed`), so the number and the rows behind it can no longer disagree. The applications list gained a matching **Viewed** filter (any / not viewed by me / viewed by me), which is saveable as part of a view. * **billing:** make bring-your-own-AI-key (BYOK) a Solo-and-above capability instead of a free-plan feature — a free org that brought its own key got the uncapped assistant and uncapped shortlists for nothing, which is what the entry plan sells. Only *creating* a config is gated: free orgs that configured a key before this change keep editing and using it, and grandfathered orgs are unaffected (BYOK is their only route to AI at all). * **licensing:** re-open Reqcore as open-core — AGPLv3 for the core app, with a new [`ee/`](ee) directory (Nuxt layer) for paid, cloud-only features under a separate commercial license. Self-hosting is supported again, best-effort and unsupported (see [SELF-HOSTING.md](SELF-HOSTING.md)). Moved the already plan-gated SSO (`ee/server/api/sso/`), org-wide audit log (`ee/server/api/activity-log/index.get.ts`), source-analytics (`ee/server/api/source-tracking/stats.get.ts`), and AI-analysis dashboard (`ee/server/api/ai-analysis/stats.get.ts`) endpoints out of the AGPL tree and into `ee/` so the license split matches what's actually gated; the underlying tables and the ungated candidate timeline/activity feed/tracking-link CRUD stay in core. @@ -18,6 +19,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com). Categories: **Add * **applications:** track which applicants each recruiter has actually opened. Opening an application's detail — from the pipeline, the candidates table drawer, or the full page — writes a per-user read receipt (`application_view`), and unopened applicants are marked with a dot in the pipeline sidebar and the candidates table. The jobs list now says "N applicants you haven't viewed" instead of counting the `new` stage, which stayed high long after every application had been read; the same count drives the "needs your attention" grouping and card highlight. Applications that existed before this change are backfilled as viewed for current members, so the badge starts from a clean slate. * **assistant:** ship the AI assistant on every plan with no rollout flag. It runs on the platform OpenRouter key by default — so an org gets a working assistant without configuring anything — or on the org's own key (BYOK), which is never budget-capped. Free orgs get a lifetime allowance of 20 assistant messages (`AI_FREE_PLAN_CHATBOT_TURN_LIMIT`), metered separately from the free AI-shortlist allowance and surfaced as a meter on the billing page; paid orgs are metered by the monthly AI budget instead. Platform-paid turns are recorded in a new `ai_usage_event` ledger and count against the org's monthly AI budget and the global daily kill-switch alongside analysis runs. Grandfathered orgs stay BYOK-only. +* **lifecycle emails:** emit activation-funnel events (`org.created`, `job.posted`) to Resend Automations, which owns the waiting, branching, and copy for the "signed up, never opened a role" nudge. Distinct from the recruiter notification outbox: that engine reports workspace activity, this one chases an absence, and an absence has no moment to enqueue at. Off by default (`LIFECYCLE_EMAILS_ENABLED`) — enabling it sends user email addresses to the Resend account behind `RESEND_API_KEY`, so self-hosters keep it unset. Emits are fire-and-forget and skip demo workspaces and reserved (`@example.com`) addresses. * **GDPR retention:** add a shared candidate-retention runner with a daily Nitro task, external cron endpoint, instance-wide emergency switch, quarantine restoration on renewed public engagement, localized administration UI, and computed expiry visibility. * **GDPR erasure:** remove application-linked comments, custom properties, and activity records in addition to the candidate database graph and S3 objects. * **blog:** add Cluster 8 career page articles — pillar (career-page-that-converts) and two supporting articles (career-page-seo, google-for-jobs-structured-data) diff --git a/Dockerfile b/Dockerfile index 7bbed5a3..21d4fc19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,10 @@ FROM node:22.22-alpine AS builder WORKDIR /app -# Install dependencies first (layer-cached unless package.json changes) -COPY package*.json ./ +# Install dependencies first (layer-cached unless package.json changes). +# .npmrc comes along because it carries the peer-resolution mode the lockfile +# was generated under — without it `npm ci` here rejects the lock outright. +COPY package*.json .npmrc ./ RUN npm ci # Copy source and build diff --git a/app/components/ApplicationBuilderPreview.vue b/app/components/ApplicationBuilderPreview.vue index 0a7c55b5..04943b7f 100644 --- a/app/components/ApplicationBuilderPreview.vue +++ b/app/components/ApplicationBuilderPreview.vue @@ -1,5 +1,5 @@ diff --git a/app/components/ApplicationDetail.vue b/app/components/ApplicationDetail.vue index 64c89e5a..e9c51405 100644 --- a/app/components/ApplicationDetail.vue +++ b/app/components/ApplicationDetail.vue @@ -10,12 +10,16 @@ import type { Component } from 'vue' import type { Interview } from '~/composables/useInterviews' import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly' import { APPLICATION_STATUS_TRANSITIONS } from '~~/shared/status-transitions' +import type { ApplicationDetailTab } from '~~/shared/application-detail-tabs' const props = withDefaults(defineProps<{ applicationId: string variant?: 'page' | 'drawer' + /** Tab to open on. Lets deep links land on a tab (e.g. `?tab=inbox`). */ + initialTab?: ApplicationDetailTab }>(), { variant: 'page', + initialTab: 'overview', }) const emit = defineEmits<{ @@ -45,8 +49,8 @@ useSeoMeta({ // Tabs & Overview section toggles // ───────────────────────────────────────────── -type DetailTab = 'overview' | 'inbox' | 'cover-letter' | 'interviews' | 'documents' | 'responses' | 'ai-analysis' | 'timeline' | 'properties' | 'notes' -const detailTab = ref('overview') +type DetailTab = ApplicationDetailTab +const detailTab = ref(props.initialTab) // Toggle the page between centered (max-w-4xl) and full width const isWideDetail = ref(false) @@ -636,7 +640,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeyNavigation) // ───────────────────────────────────────────── watch(() => props.applicationId, () => { - detailTab.value = 'overview' + // Back to the requested tab, not a hardcoded 'overview': navigating between + // two `?tab=inbox` deep links keeps the component mounted, and resetting to + // overview there would contradict the URL. + detailTab.value = props.initialTab isWideDetail.value = false expandedInterviewId.value = null showOverviewDropdown.value = false diff --git a/app/components/ApplicationsList.vue b/app/components/ApplicationsList.vue index 0fb88ac4..307b9434 100644 --- a/app/components/ApplicationsList.vue +++ b/app/components/ApplicationsList.vue @@ -147,6 +147,35 @@ watch(activeStatus, (newStatus) => { }) const statusFilter = computed(() => activeStatus.value) + +// ── Viewed filter ───────────────────────────────────────────────────────────── +// Server-side read receipts, per signed-in user. The dashboard's "To Review" +// tile links here with `?viewed=unviewed`, so the count it shows and the rows +// listed here come from the same definition. + +const VIEWED_OPTIONS = ['unviewed', 'viewed'] as const +type ViewedFilter = typeof VIEWED_OPTIONS[number] + +const initialViewed = VIEWED_OPTIONS.includes(route.query.viewed as any) + ? (route.query.viewed as ViewedFilter) + : undefined +const activeViewed = useState(`app-filter-viewed${scopeSuffix}`, () => initialViewed) +if (initialViewed !== undefined) { + activeViewed.value = initialViewed +} + +watch(activeViewed, (next) => { + const query = { ...route.query } + if (next) { + query.viewed = next + } + else { + delete query.viewed + } + router.replace({ query }) +}) + +const viewedFilter = computed(() => activeViewed.value) const propertyFilters = ref([]) const jobIdFilter = computed(() => props.jobId) @@ -154,11 +183,12 @@ const { applications, total, fetchStatus, error, refresh } = useApplications({ page, limit: pageSize, status: statusFilter, + viewed: viewedFilter, propertyFilters, jobId: jobIdFilter, }) -watch([statusFilter, propertyFilters, pageSize], () => { +watch([statusFilter, viewedFilter, propertyFilters, pageSize], () => { page.value = 1 }, { deep: true }) @@ -251,11 +281,12 @@ const filteredApplications = computed(() => { }) const hasActiveFilters = computed(() => - activeStatus.value != null || activeJobId.value != null || debouncedSearch.value.length > 0 || propertyFilters.value.length > 0, + activeStatus.value != null || activeViewed.value != null || activeJobId.value != null || debouncedSearch.value.length > 0 || propertyFilters.value.length > 0, ) function clearAllFilters() { activeStatus.value = undefined + activeViewed.value = undefined activeJobId.value = undefined searchInput.value = '' debouncedSearch.value = '' @@ -312,6 +343,7 @@ const statusLabels: Record = { type ApplicationsViewSettings = { status?: Status + viewed?: ViewedFilter jobId?: string propertyFilters: import('~~/shared/properties').PropertyFilter[] sortKey: SortKey @@ -321,6 +353,7 @@ type ApplicationsViewSettings = { const defaultSettings: ApplicationsViewSettings = { status: undefined, + viewed: undefined, jobId: undefined, propertyFilters: [], sortKey: 'created', @@ -335,6 +368,7 @@ const isFullscreen = ref(false) const isWideDetail = ref(!!props.jobId) const currentSettings = computed(() => ({ status: activeStatus.value, + viewed: activeViewed.value, jobId: activeJobId.value, propertyFilters: [...propertyFilters.value], sortKey: sortKey.value, @@ -344,6 +378,7 @@ const currentSettings = computed(() => ({ function applySettings(s: ApplicationsViewSettings) { activeStatus.value = s.status + activeViewed.value = s.viewed activeJobId.value = s.jobId propertyFilters.value = [...(s.propertyFilters ?? [])] sortKey.value = s.sortKey @@ -406,7 +441,7 @@ function onUpdateView(id: string) { } const drawerActiveCount = computed(() => - [activeStatus.value, activeJobId.value].filter(Boolean).length + propertyFilters.value.length, + [activeStatus.value, activeViewed.value, activeJobId.value].filter(Boolean).length + propertyFilters.value.length, ) // ── Property value lookup helper ────────────────────────────────────────────── @@ -543,6 +578,40 @@ async function handleApplicationDeleted() { + +
+ +
+ + + +
+

+ Rejected applicants are excluded — they've already been decided on. +

+
+
diff --git a/app/components/JobPromotePanel.vue b/app/components/JobPromotePanel.vue index fe0340e9..b4053e59 100644 --- a/app/components/JobPromotePanel.vue +++ b/app/components/JobPromotePanel.vue @@ -88,6 +88,59 @@ async function setDistributeToBoards(next: boolean) { } } +// ───────────────────────────────────────────── +// Per-board delivery +// ───────────────────────────────────────────── + +/** + * How each board's row reads. + * + * Every label is a statement about the feed, never about the board's site — + * "collected" is the strongest thing a pull-based feed can evidence, and a + * board decides on its own schedule what to do with what it collected. Saying + * "live on Adzuna" here would be the same unbacked claim this panel used to + * make for all seven at once. + */ +const DELIVERY_LABELS: Record = { + delivered: { + text: 'Collected', + dot: 'bg-success-500', + tone: 'text-surface-600 dark:text-surface-400', + }, + pending: { + text: 'Next pull', + dot: 'bg-brand-500', + tone: 'text-surface-500 dark:text-surface-400', + }, + dropped: { + text: 'Not in last pull', + dot: 'bg-warning-500', + tone: 'text-warning-700 dark:text-warning-400', + }, + never_fetched: { + text: 'No pull recorded', + dot: 'bg-surface-300 dark:bg-surface-600', + tone: 'text-surface-400 dark:text-surface-500', + }, +} + +const deliveredCount = computed(() => + data.value?.feed.deliveries.filter(d => d.state === 'delivered').length ?? 0, +) + +/** Coarse on purpose: the exact minute a crawler called is noise to a recruiter. */ +function since(value: string | Date | null): string | null { + if (!value) return null + const then = new Date(value).getTime() + const minutes = Math.floor((Date.now() - then) / 60_000) + if (minutes < 1) return 'just now' + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return days === 1 ? 'yesterday' : `${days}d ago` +} + // ───────────────────────────────────────────── // Clipboard // ───────────────────────────────────────────── @@ -446,7 +499,10 @@ onMounted(() => { : 'External job boards — off' }}

- Sent to {{ data.feed.boards.map(b => b.label).join(', ') }}. Boards refresh every few hours. + {{ deliveredCount + ? `Collected by ${deliveredCount} of ${data.feed.deliveries.length} boards.` + : 'Waiting for the boards to collect it.' }} + Each board pulls the feed on its own schedule.

{ > Fix in job settings + + +

    +
  • + + {{ d.label }} + + {{ DELIVERY_LABELS[d.state]?.text }} + + +
  • +