feat(foundations): init schema frontend components and quality gates

This commit is contained in:
Gabriel Ramos 2026-06-09 08:58:45 -04:00
parent 84a8cf52d7
commit dd7e0ff198
10 changed files with 275 additions and 6 deletions

View file

@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
// @ts-ignore import { PDFParse } from "pdf-parse";
import pdf from "pdf-parse";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@ -17,9 +16,11 @@ export async function POST(request: NextRequest) {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
// Extract text from PDF // Extract text from PDF using PDFParse v2 API
const pdfData = await pdf(buffer); const parser = new PDFParse({ data: buffer });
const pdfData = await parser.getText();
const text = pdfData.text; const text = pdfData.text;
await parser.destroy();
// Extract candidate name from file name (strip extension) // Extract candidate name from file name (strip extension)
const name = file.name.replace(/\.[^/.]+$/, ""); const name = file.name.replace(/\.[^/.]+$/, "");
@ -75,8 +76,9 @@ export async function POST(request: NextRequest) {
message: "CVs processed and forwarded to n8n successfully", message: "CVs processed and forwarded to n8n successfully",
data: responseData, data: responseData,
}); });
} catch (error: any) { } catch (error: unknown) {
console.error("Error in parse-cv route:", error); 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 });
} }
} }

17
components/DataCard.tsx Normal file
View file

@ -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 (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-50">
<h3 className="text-lg font-bold text-slate-900 mb-1">{title}</h3>
<p className="text-slate-600 text-sm mb-4">{description}</p>
{children}
</div>
);
}

View file

@ -0,0 +1,16 @@
import React from "react";
interface PrimaryButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
}
export function PrimaryButton({ children, className = "", ...props }: PrimaryButtonProps) {
return (
<button
className={`bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm transition-colors text-sm ${className}`}
{...props}
>
{children}
</button>
);
}

View file

@ -0,0 +1,20 @@
import React from "react";
interface StatusBadgeProps {
label: string;
variant?: "primary" | "secondary";
}
export function StatusBadge({ label, variant = "primary" }: StatusBadgeProps) {
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
variant === "primary"
? "bg-blue-600 text-white"
: "bg-slate-50 text-slate-600"
}`}
>
{label}
</span>
);
}

View file

@ -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.

View file

@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
"out/**", "out/**",
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",
"scripts/**",
]), ]),
]); ]);

48
lib/logger/index.ts Normal file
View file

@ -0,0 +1,48 @@
export interface LogPayload {
message: string;
error?: Error | unknown;
latencyMs?: number;
metadata?: Record<string, unknown>;
}
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<string, unknown>, latencyMs?: number) {
console.log(this.format("INFO", { message, metadata, latencyMs }));
}
static warn(message: string, metadata?: Record<string, unknown>, latencyMs?: number) {
console.warn(this.format("WARN", { message, metadata, latencyMs }));
}
static error(
message: string,
error?: Error | unknown,
metadata?: Record<string, unknown>,
latencyMs?: number
) {
console.error(this.format("ERROR", { message, error, metadata, latencyMs }));
}
}

View file

@ -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;
$$;

28
tailwind.config.ts Normal file
View file

@ -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;

1
types.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module 'pdf-parse';