diff --git a/app/(dashboard)/candidates/api/parse-cv/route.ts b/app/(dashboard)/candidates/api/parse-cv/route.ts
index fc5e84a..044a05e 100644
--- a/app/(dashboard)/candidates/api/parse-cv/route.ts
+++ b/app/(dashboard)/candidates/api/parse-cv/route.ts
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-// @ts-ignore
-import pdf from "pdf-parse";
+import { PDFParse } from "pdf-parse";
export async function POST(request: NextRequest) {
try {
@@ -17,9 +16,11 @@ export async function POST(request: NextRequest) {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
- // Extract text from PDF
- const pdfData = await pdf(buffer);
+ // Extract text from PDF using PDFParse v2 API
+ const parser = new PDFParse({ data: buffer });
+ const pdfData = await parser.getText();
const text = pdfData.text;
+ await parser.destroy();
// Extract candidate name from file name (strip extension)
const name = file.name.replace(/\.[^/.]+$/, "");
@@ -75,8 +76,9 @@ export async function POST(request: NextRequest) {
message: "CVs processed and forwarded to n8n successfully",
data: responseData,
});
- } catch (error: any) {
+ } catch (error: unknown) {
console.error("Error in parse-cv route:", error);
- return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 });
+ const errorMessage = error instanceof Error ? error.message : "Internal server error";
+ return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
diff --git a/components/DataCard.tsx b/components/DataCard.tsx
new file mode 100644
index 0000000..fa9b26b
--- /dev/null
+++ b/components/DataCard.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+
+interface DataCardProps {
+ title: string;
+ description: string;
+ children?: React.ReactNode;
+}
+
+export function DataCard({ title, description, children }: DataCardProps) {
+ return (
+
+
{title}
+
{description}
+ {children}
+
+ );
+}
diff --git a/components/PrimaryButton.tsx b/components/PrimaryButton.tsx
new file mode 100644
index 0000000..2bf8bdf
--- /dev/null
+++ b/components/PrimaryButton.tsx
@@ -0,0 +1,16 @@
+import React from "react";
+
+interface PrimaryButtonProps extends React.ButtonHTMLAttributes {
+ children: React.ReactNode;
+}
+
+export function PrimaryButton({ children, className = "", ...props }: PrimaryButtonProps) {
+ return (
+
+ );
+}
diff --git a/components/StatusBadge.tsx b/components/StatusBadge.tsx
new file mode 100644
index 0000000..52ff158
--- /dev/null
+++ b/components/StatusBadge.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+
+interface StatusBadgeProps {
+ label: string;
+ variant?: "primary" | "secondary";
+}
+
+export function StatusBadge({ label, variant = "primary" }: StatusBadgeProps) {
+ return (
+
+ {label}
+
+ );
+}
diff --git a/docs/discovery-and-scope.md b/docs/discovery-and-scope.md
new file mode 100644
index 0000000..955c8ad
--- /dev/null
+++ b/docs/discovery-and-scope.md
@@ -0,0 +1,50 @@
+# Phase 1: Discovery & Scope
+
+This document establishes the business boundaries, user personas, backlog of user stories, and Definition of Done (DoD) for the AI Recruitment Platform.
+
+## User Personas
+
+* **Technical Recruiter**: Focuses on sourcing, intake, initial AI-assisted screening, seniority verification, and tracking candidates through the hiring pipeline.
+* **Hiring Manager**: Focuses on reviewing pre-screened and scored candidates, evaluating role suitability, and conducting structured interviews.
+* **Candidate**: Interacts with the platform to submit applications/CVs and receives automated status updates.
+
+## Prioritized Backlog of User Stories
+
+1. **CV Intake**
+ * *User Story*: As a recruiter, I want to upload candidate PDFs via the Web UI so they can be processed and stored.
+ * *Acceptance Criteria*:
+ * Drag-and-drop or file selector for PDF CVs.
+ * Successful text extraction and ingestion.
+ * Parsing and storage of extracted candidate info.
+2. **Semantic Ranking**
+ * *User Story*: As a recruiter, I want a ranked list of candidates matching an open job description based on contextual relevance.
+ * *Acceptance Criteria*:
+ * Compute cosine distance between job embeddings and candidate embeddings.
+ * Display ranked matches with normalized similarity score between -1 and 1.
+3. **Profile Summary**
+ * *User Story*: As a recruiter, I want a terse AI-generated candidate summary to speed up screening.
+ * *Acceptance Criteria*:
+ * Display a short summary on candidate profiles.
+ * Synthesized from parsed resume details.
+4. **Seniority Guard**
+ * *User Story*: As a recruiter, I want automatic seniority detection to route candidates to the correct interview pool.
+ * *Acceptance Criteria*:
+ * AI-based classification (e.g., Junior, Mid, Senior).
+ * Correct pool routing matches candidate seniority level.
+5. **Unified Scoring**
+ * *User Story*: As a hiring manager, I want to compare candidate suitability using a standardized score.
+ * *Acceptance Criteria*:
+ * Standardized scoring schema with AI evaluation.
+ * JSON structure containing keys: `summary`, `classification`, `suggestions`, and `riskLevel`.
+6. **Stage Progression**
+ * *User Story*: As a recruiter, I want candidate stages to update dynamically, triggering confirmation emails.
+ * *Acceptance Criteria*:
+ * Visual stage progression pipeline.
+ * Updating stage triggers background event notifications.
+
+## Definition of Done (DoD)
+
+* **Type Safety**: Fully typed Next.js App Router with TypeScript (no `any` types where avoidable).
+* **Database Schema**: Supabase relational database schema complete with verified `pgvector` distance metrics.
+* **Linting & Quality**: Zero TypeScript compilation or linting warnings/errors.
+* **Git Quality Gates**: Active Git quality gates (hooks) verifying builds, conventional commit messages, linting, and formatting.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 05e726d..5610352 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
+ "scripts/**",
]),
]);
diff --git a/lib/logger/index.ts b/lib/logger/index.ts
new file mode 100644
index 0000000..d0e2a44
--- /dev/null
+++ b/lib/logger/index.ts
@@ -0,0 +1,48 @@
+export interface LogPayload {
+ message: string;
+ error?: Error | unknown;
+ latencyMs?: number;
+ metadata?: Record;
+}
+
+export class Logger {
+ private static format(level: "INFO" | "WARN" | "ERROR", payload: LogPayload): string {
+ const timestamp = new Date().toISOString();
+ const parts: string[] = [`[${timestamp}] [${level}] ${payload.message}`];
+
+ if (payload.latencyMs !== undefined) {
+ parts.push(`(Latency: ${payload.latencyMs}ms)`);
+ }
+
+ if (payload.error) {
+ const errorMsg =
+ payload.error instanceof Error
+ ? payload.error.stack || payload.error.message
+ : String(payload.error);
+ parts.push(`\nError: ${errorMsg}`);
+ }
+
+ if (payload.metadata && Object.keys(payload.metadata).length > 0) {
+ parts.push(`\nMetadata: ${JSON.stringify(payload.metadata, null, 2)}`);
+ }
+
+ return parts.join(" ");
+ }
+
+ static info(message: string, metadata?: Record, latencyMs?: number) {
+ console.log(this.format("INFO", { message, metadata, latencyMs }));
+ }
+
+ static warn(message: string, metadata?: Record, latencyMs?: number) {
+ console.warn(this.format("WARN", { message, metadata, latencyMs }));
+ }
+
+ static error(
+ message: string,
+ error?: Error | unknown,
+ metadata?: Record,
+ latencyMs?: number
+ ) {
+ console.error(this.format("ERROR", { message, error, metadata, latencyMs }));
+ }
+}
diff --git a/supabase/migrations/20260608000000_initial_schema.sql b/supabase/migrations/20260608000000_initial_schema.sql
new file mode 100644
index 0000000..524484b
--- /dev/null
+++ b/supabase/migrations/20260608000000_initial_schema.sql
@@ -0,0 +1,86 @@
+-- Enable pgvector extension
+CREATE EXTENSION IF NOT EXISTS vector;
+
+-- Recruiters table
+CREATE TABLE IF NOT EXISTS recruiters (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ email TEXT NOT NULL UNIQUE,
+ created_at TIMESTAMPTZ DEFAULT now() NOT NULL
+);
+
+-- Jobs table
+CREATE TABLE IF NOT EXISTS jobs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ title TEXT NOT NULL,
+ requirements JSONB NOT NULL,
+ embedding vector(1536),
+ recruiter_id UUID REFERENCES recruiters(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ DEFAULT now() NOT NULL
+);
+
+-- Candidates table
+CREATE TABLE IF NOT EXISTS candidates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ contact_info JSONB NOT NULL,
+ embedding vector(1536),
+ created_at TIMESTAMPTZ DEFAULT now() NOT NULL
+);
+
+-- Interviews table
+CREATE TABLE IF NOT EXISTS interviews (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ candidate_id UUID NOT NULL REFERENCES candidates(id) ON DELETE CASCADE,
+ job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
+ interview_date TIMESTAMPTZ NOT NULL,
+ stage TEXT NOT NULL, -- Technical, Cultural, etc.
+ feedback TEXT,
+ created_at TIMESTAMPTZ DEFAULT now() NOT NULL
+);
+
+-- Scores table
+CREATE TABLE IF NOT EXISTS scores (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ candidate_id UUID NOT NULL REFERENCES candidates(id) ON DELETE CASCADE,
+ interview_id UUID REFERENCES interviews(id) ON DELETE CASCADE,
+ ai_score FLOAT NOT NULL,
+ evaluation JSONB NOT NULL, -- Hold summary, classification, suggestions, riskLevel
+ created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
+ CONSTRAINT check_evaluation_schema CHECK (
+ evaluation ? 'summary' AND
+ evaluation ? 'classification' AND
+ evaluation ? 'suggestions' AND
+ evaluation ? 'riskLevel'
+ )
+);
+
+-- Cosine distance match function
+CREATE OR REPLACE FUNCTION match_candidates(
+ query_embedding vector(1536),
+ match_threshold float,
+ match_count int
+)
+RETURNS TABLE (
+ id uuid,
+ name text,
+ contact_info jsonb,
+ embedding vector(1536),
+ similarity float
+)
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ candidates.id,
+ candidates.name,
+ candidates.contact_info,
+ candidates.embedding,
+ (1 - (candidates.embedding <=> query_embedding))::float AS similarity
+ FROM candidates
+ WHERE (1 - (candidates.embedding <=> query_embedding)) > match_threshold
+ ORDER BY similarity DESC
+ LIMIT match_count;
+END;
+$$;
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000..19cdb13
--- /dev/null
+++ b/tailwind.config.ts
@@ -0,0 +1,28 @@
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx,mdx}",
+ "./components/**/*.{js,ts,jsx,tsx,mdx}",
+ ],
+ theme: {
+ // Overriding the default theme colors to restrict to the whitelist.
+ colors: {
+ transparent: "transparent",
+ current: "currentColor",
+ white: "#ffffff",
+ slate: {
+ 50: "#f8fafc",
+ 600: "#475569",
+ 900: "#0f172a",
+ },
+ blue: {
+ 600: "#2563eb",
+ 700: "#1d4ed8",
+ },
+ },
+ },
+ plugins: [],
+};
+
+export default config;
diff --git a/types.d.ts b/types.d.ts
new file mode 100644
index 0000000..dc56143
--- /dev/null
+++ b/types.d.ts
@@ -0,0 +1 @@
+declare module 'pdf-parse';