Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src-tauri/migrations/013_recruiting.sql
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
26 changes: 26 additions & 0 deletions src-tauri/src/commands/recruiting.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
) -> Result<String, String> {
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<Vec<RecruitingSearch>, String> {
recruiting::list_searches(&state.pool)
.await
.map_err(|e| e.to_string())
}
1 change: 1 addition & 0 deletions src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ mod network;
mod performance_ratings;
mod performance_reviews;
mod pii;
mod recruiting;
mod review_cycles;
mod settings;
mod provider;
Expand Down Expand Up @@ -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
Expand Down
142 changes: 142 additions & 0 deletions src-tauri/src/recruiting/mod.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
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<String, sqlx::Error> {
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<Vec<RecruitingSearch>, 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"
);
}
}
25 changes: 25 additions & 0 deletions src/lib/tauri-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import type {
// V3.0 - Document Ingestion
DocumentFolderStats,
DocumentStats,
// FHR-71 - Recruiting (Sourcerer module)
RecruitingSearch,
} from './types';

/**
Expand Down Expand Up @@ -2106,3 +2108,26 @@ export async function rescanDocuments(): Promise<DocumentFolderStats> {
export async function getDocumentStats(): Promise<DocumentStats> {
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<string> {
return invoke('recruiting_create_search', { query, seedEmployeeId });
}

/** List all recruiting searches, newest first. */
export async function listRecruitingSearches(): Promise<RecruitingSearch[]> {
return invoke('recruiting_list_searches');
}
22 changes: 22 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,3 +650,25 @@ export interface DocumentStats {
files_by_type: Record<string, number>;
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;
}