From 5b5f4fbf7060e3e5bbdfa0309c70e442c3d2d028 Mon Sep 17 00:00:00 2001 From: Matt OD Date: Tue, 26 May 2026 08:09:57 -0700 Subject: [PATCH] feat(recruiting): Rust module + first command + migration 013 (FHR-71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vertical slice for the Recruit module — the foundation S0.3+ build on. - migration 013_recruiting.sql: adds the recruiting_searches container with a seed_employee_id FK (ON DELETE SET NULL). The nullable FK seam supports the context-aware sourcing moat from day 1 (DECISIONS.md #5); ON DELETE SET NULL keeps HR and recruiting decoupled (architecture finding #1 — removing the module never breaks the HR side). - src-tauri/src/recruiting/: directory module with create_search + list_searches helpers + 2 tests (round-trip + FK SET NULL regression guard that locks in the HR/recruiting decoupling decision). - src-tauri/src/commands/recruiting.rs: Tauri bridge wrapping the two helpers. Registered in lib.rs generate_handler!. - src/lib/tauri-commands.ts + types.ts: createRecruitingSearch / listRecruitingSearches wrappers + RecruitingSearch type. Verification: - cargo test --manifest-path src-tauri/Cargo.toml: 480 passing (was 478, +2 new), 0 failing, 1 ignored. - npm run type-check: clean. - Dev DB at migration 12 confirmed; 013 will apply on next dev-app launch (verified via fresh-install + legacy-upgrade test paths). Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/migrations/013_recruiting.sql | 30 +++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/recruiting.rs | 26 +++++ src-tauri/src/db.rs | 1 + src-tauri/src/lib.rs | 4 + src-tauri/src/recruiting/mod.rs | 142 ++++++++++++++++++++++++ src/lib/tauri-commands.ts | 25 +++++ src/lib/types.ts | 22 ++++ 8 files changed, 251 insertions(+) create mode 100644 src-tauri/migrations/013_recruiting.sql create mode 100644 src-tauri/src/commands/recruiting.rs create mode 100644 src-tauri/src/recruiting/mod.rs diff --git a/src-tauri/migrations/013_recruiting.sql b/src-tauri/migrations/013_recruiting.sql new file mode 100644 index 0000000..80958fd --- /dev/null +++ b/src-tauri/migrations/013_recruiting.sql @@ -0,0 +1,30 @@ +-- Migration 013: Recruiting (Sourcerer) — skeleton schema. +-- +-- recruiting_searches is the top-level "run" container. Candidates, +-- evidence, scores, and outputs hang off it in later migrations as the +-- module earns them. +-- +-- Notes: +-- - Lives in its own migration so the module can be cleanly removed +-- later (DECISIONS.md architecture finding #1). +-- - seed_employee_id is the context-aware sourcing seam: "find people +-- like this employee". Nullable + ON DELETE SET NULL so deleting an +-- employee never cascades into recruiting history. +-- - status is TEXT, not a CHECK enum — the Rust enum is the source of +-- truth. Promote to a CHECK constraint once the value space settles. + +CREATE TABLE IF NOT EXISTS recruiting_searches ( + id TEXT PRIMARY KEY, + query TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + seed_employee_id TEXT REFERENCES employees(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_recruiting_searches_created_at + ON recruiting_searches(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_recruiting_searches_seed_employee + ON recruiting_searches(seed_employee_id) + WHERE seed_employee_id IS NOT NULL; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9011de3..b2e40e3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -14,4 +14,5 @@ pub(crate) mod enps; pub(crate) mod import; pub(crate) mod license; pub(crate) mod performance; +pub(crate) mod recruiting; pub(crate) mod system; diff --git a/src-tauri/src/commands/recruiting.rs b/src-tauri/src/commands/recruiting.rs new file mode 100644 index 0000000..ffa149c --- /dev/null +++ b/src-tauri/src/commands/recruiting.rs @@ -0,0 +1,26 @@ +//! Tauri command bridge for the `recruiting` module. + +use crate::db::Database; +use crate::recruiting::{self, RecruitingSearch}; + +/// Create a new recruiting search. Returns the generated row ID. +#[tauri::command] +pub(crate) async fn recruiting_create_search( + state: tauri::State<'_, Database>, + query: String, + seed_employee_id: Option, +) -> Result { + recruiting::create_search(&state.pool, &query, seed_employee_id.as_deref()) + .await + .map_err(|e| e.to_string()) +} + +/// List all recruiting searches, newest first. +#[tauri::command] +pub(crate) async fn recruiting_list_searches( + state: tauri::State<'_, Database>, +) -> Result, String> { + recruiting::list_searches(&state.pool) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 896877e..3e4bb9a 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -237,6 +237,7 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ (10, "schema_migrations", include_str!("../migrations/010_schema_migrations.sql")), (11, "audit_log_append_only", include_str!("../migrations/011_audit_log_append_only.sql")), (12, "license_signed_token", include_str!("../migrations/012_license_signed_token.sql")), + (13, "recruiting", include_str!("../migrations/013_recruiting.sql")), ]; /// Highest version that the pre-versioning runner may have applied. Used only diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5496bf2..a15e340 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,6 +33,7 @@ mod network; mod performance_ratings; mod performance_reviews; mod pii; +mod recruiting; mod review_cycles; mod settings; mod provider; @@ -228,6 +229,9 @@ pub fn run() { commands::documents::get_document_folder, commands::documents::rescan_documents, commands::documents::get_document_stats, + // Recruiting (Sourcerer module) — FHR-71 + commands::recruiting::recruiting_create_search, + commands::recruiting::recruiting_list_searches, ]) .setup(|app| { // Register updater plugin for auto-updates via GitHub Releases diff --git a/src-tauri/src/recruiting/mod.rs b/src-tauri/src/recruiting/mod.rs new file mode 100644 index 0000000..988641e --- /dev/null +++ b/src-tauri/src/recruiting/mod.rs @@ -0,0 +1,142 @@ +//! Recruiting (Sourcerer) module — talent sourcing inside People Partner. +//! +//! Skeleton for FHR-71 (S0.2). Owns the `recruiting_searches` row and the +//! create / list operations the first command exposes. Submodules +//! (`adapters`, `intake`, `scoring`, `enrichment`) land in S1+. +//! +//! Architecture: +//! - Domain helpers (`create_search`, `list_searches`) live here as the +//! library API. +//! - Tauri command wrappers live in `crate::commands::recruiting` so the +//! bridge layer stays separate from the domain layer (mirrors how +//! `chat`, `employees`, `documents` are organized). +//! - The migration that backs this module is `013_recruiting.sql`. + +use crate::db::DbPool; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)] +pub struct RecruitingSearch { + pub id: String, + pub query: String, + pub status: String, + pub seed_employee_id: Option, + pub created_at: String, + pub updated_at: String, +} + +/// Create a new recruiting search. Returns the generated row ID. +/// +/// `seed_employee_id`, when set, points at an existing employee whose +/// profile seeds context-aware discovery ("find people like this person"). +/// The FK is `ON DELETE SET NULL` — deleting the seed employee never +/// cascades into recruiting history. +pub async fn create_search( + pool: &DbPool, + query: &str, + seed_employee_id: Option<&str>, +) -> Result { + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO recruiting_searches (id, query, seed_employee_id) VALUES (?, ?, ?)", + ) + .bind(&id) + .bind(query) + .bind(seed_employee_id) + .execute(pool) + .await?; + Ok(id) +} + +/// List all recruiting searches, newest first. +pub async fn list_searches(pool: &DbPool) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT id, query, status, seed_employee_id, created_at, updated_at \ + FROM recruiting_searches \ + ORDER BY created_at DESC", + ) + .fetch_all(pool) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::time::Duration; + + /// In-memory pool with the same connect options as production (minus + /// WAL, which `:memory:` doesn't support) + full migration suite. + async fn test_pool() -> DbPool { + let options = SqliteConnectOptions::new() + .filename(":memory:") + .create_if_missing(true) + .foreign_keys(true) + .busy_timeout(Duration::from_secs(5)); + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("connect to :memory: pool"); + + crate::db::run_migrations_for_tests(&pool) + .await + .expect("run migrations"); + pool + } + + #[tokio::test] + async fn create_and_list_round_trip() { + let pool = test_pool().await; + + let id = create_search(&pool, "rust engineers in Berlin", None) + .await + .expect("create search"); + + let rows = list_searches(&pool).await.expect("list searches"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, id); + assert_eq!(rows[0].query, "rust engineers in Berlin"); + assert_eq!( + rows[0].status, "pending", + "default status from the migration must be 'pending'" + ); + assert!(rows[0].seed_employee_id.is_none()); + } + + /// Regression guard: the FK is `ON DELETE SET NULL`, not `ON DELETE + /// CASCADE`. Deleting the seed employee must never destroy the + /// recruiting history that referenced them — that's the architectural + /// HR/recruiting decoupling (DECISIONS.md finding #1). + #[tokio::test] + async fn seed_employee_id_is_set_null_on_employee_delete() { + let pool = test_pool().await; + + sqlx::query( + "INSERT INTO employees (id, email, full_name) \ + VALUES ('emp-1', 'sarah@example.com', 'Sarah')", + ) + .execute(&pool) + .await + .expect("insert employee"); + + let id = create_search(&pool, "find people like Sarah", Some("emp-1")) + .await + .expect("create search"); + + sqlx::query("DELETE FROM employees WHERE id = 'emp-1'") + .execute(&pool) + .await + .expect("delete employee"); + + let rows = list_searches(&pool).await.expect("list searches"); + assert_eq!(rows.len(), 1, "search must survive employee delete"); + assert_eq!(rows[0].id, id); + assert!( + rows[0].seed_employee_id.is_none(), + "FK must be SET NULL on delete; CASCADE would lose recruiting history" + ); + } +} diff --git a/src/lib/tauri-commands.ts b/src/lib/tauri-commands.ts index bd810ff..d126ae9 100644 --- a/src/lib/tauri-commands.ts +++ b/src/lib/tauri-commands.ts @@ -28,6 +28,8 @@ import type { // V3.0 - Document Ingestion DocumentFolderStats, DocumentStats, + // FHR-71 - Recruiting (Sourcerer module) + RecruitingSearch, } from './types'; /** @@ -2106,3 +2108,26 @@ export async function rescanDocuments(): Promise { export async function getDocumentStats(): Promise { return invoke('get_document_stats'); } + +// ============================================================================= +// Recruiting (Sourcerer module) — FHR-71 (S0.2) +// ============================================================================= + +/** + * Create a new recruiting search. Returns the generated row ID (UUID). + * + * `seedEmployeeId`, when provided, points at an existing employee whose + * profile seeds context-aware discovery ("find people like Sarah"). The + * underlying FK is ON DELETE SET NULL. + */ +export async function createRecruitingSearch( + query: string, + seedEmployeeId: string | null = null, +): Promise { + return invoke('recruiting_create_search', { query, seedEmployeeId }); +} + +/** List all recruiting searches, newest first. */ +export async function listRecruitingSearches(): Promise { + return invoke('recruiting_list_searches'); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 24ddd21..3f8d4cf 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -650,3 +650,25 @@ export interface DocumentStats { files_by_type: Record; last_scanned_at: string | null; } + +// ============================================================================ +// Recruiting (Sourcerer module) — FHR-71 (S0.2) +// ============================================================================ + +/** + * One row in `recruiting_searches`. Fields are snake_case because they + * come straight from the Rust serde-serialized struct. + * + * `seed_employee_id` is the context-aware sourcing seam: when set, the + * search seeds discovery off that employee's profile ("find people like + * this person"). Nullable + ON DELETE SET NULL — deleting the seed + * employee never destroys recruiting history. + */ +export interface RecruitingSearch { + id: string; + query: string; + status: string; + seed_employee_id: string | null; + created_at: string; + updated_at: string; +}