chore(db): migrate from SQLite to Postgres with pgvector
Switch the persistence layer from better-sqlite3 to postgres-js (async),
rewrite the schema in drizzle-orm/pg-core, and add Docker + scripts to
spin up Postgres 16 with pgvector locally on port 5433.
Schema changes beyond the SQLite → PG dialect swap:
- embedding_chunk.embedding becomes vector(384) with an HNSW cosine index,
enabling native similarity search via the `<=>` operator (no more
JSON-serialized arrays + JS-side cosine).
- jsonb for previously-text JSON columns (achievements, content_structured,
metadata).
- application_event generalised into a unified activity feed: adds
event_type, title, contact_id, scheduled_at, completed_at, outcome.
- New tables: contact_application (M:N junction so a recruiter can appear
in multiple applications) and company_research (per-company knowledge log).
Driver swap touches every query call site: all functions in src/db/queries
are now async, and all callers in src/app/api/**/*.ts and src/lib/{rag,claude}
await accordingly. SQLite-specific SQL (julianday, date('now', ...))
translated to Postgres equivalents (extract(epoch ...), current_date,
date_trunc('week', ...)).
Includes scripts/dump-sqlite.mjs and scripts/restore-postgres.mjs to migrate
existing data (run once: dump from old .db, then restore into the running
PG container).
This commit is contained in:
parent
1e52426347
commit
0fee844c7a
38 changed files with 3530 additions and 1387 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -42,6 +42,8 @@ yarn-error.log*
|
|||
/storage/db/*.db
|
||||
/storage/db/*.db-wal
|
||||
/storage/db/*.db-shm
|
||||
/storage/db/dump.json
|
||||
/storage/pg/
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
|
|
|||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
container_name: job-agent-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: jobagent
|
||||
POSTGRES_PASSWORD: jobagent
|
||||
POSTGRES_DB: job_agent
|
||||
ports:
|
||||
- "127.0.0.1:5433:5432"
|
||||
volumes:
|
||||
- ./storage/pg:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U jobagent -d job_agent"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
|
@ -3,8 +3,10 @@ import { defineConfig } from "drizzle-kit";
|
|||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle/migrations",
|
||||
dialect: "sqlite",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DB_PATH || "./storage/db/job-agent.db",
|
||||
url:
|
||||
process.env.DATABASE_URL ||
|
||||
"postgres://jobagent:jobagent@127.0.0.1:5433/job_agent",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
255
drizzle/migrations/0000_init.sql
Normal file
255
drizzle/migrations/0000_init.sql
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "application" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"job_title" text NOT NULL,
|
||||
"job_description" text NOT NULL,
|
||||
"job_url" text,
|
||||
"salary_range" text,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"applied_date" text,
|
||||
"response_date" text,
|
||||
"notes" text,
|
||||
"interview_prep" text,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL,
|
||||
"updated_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "application_event" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"application_id" integer NOT NULL,
|
||||
"event_type" text DEFAULT 'status_change' NOT NULL,
|
||||
"title" text,
|
||||
"notes" text,
|
||||
"from_status" text,
|
||||
"to_status" text,
|
||||
"contact_id" integer,
|
||||
"scheduled_at" text,
|
||||
"completed_at" text,
|
||||
"outcome" text,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "certification" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"issuer" text NOT NULL,
|
||||
"date_obtained" text,
|
||||
"expiry_date" text,
|
||||
"credential_url" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "company" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"website" text,
|
||||
"industry" text,
|
||||
"notes" text,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "company_research" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"source_url" text,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "contact" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer,
|
||||
"application_id" integer,
|
||||
"name" text NOT NULL,
|
||||
"role" text,
|
||||
"email" text,
|
||||
"linkedin_url" text,
|
||||
"phone" text,
|
||||
"notes" text,
|
||||
"last_contact_date" text,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL,
|
||||
"updated_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "contact_application" (
|
||||
"contact_id" integer NOT NULL,
|
||||
"application_id" integer NOT NULL,
|
||||
"role" text,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL,
|
||||
CONSTRAINT "contact_application_contact_id_application_id_pk" PRIMARY KEY("contact_id","application_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "document" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"filename" text NOT NULL,
|
||||
"original_name" text NOT NULL,
|
||||
"mime_type" text NOT NULL,
|
||||
"file_path" text NOT NULL,
|
||||
"parsed_text" text,
|
||||
"doc_type" text,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "education" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"institution" text NOT NULL,
|
||||
"degree" text NOT NULL,
|
||||
"field_of_study" text,
|
||||
"start_date" text NOT NULL,
|
||||
"end_date" text,
|
||||
"description" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL,
|
||||
"updated_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "embedding_chunk" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"source_type" text NOT NULL,
|
||||
"source_id" integer NOT NULL,
|
||||
"chunk_text" text NOT NULL,
|
||||
"chunk_index" integer DEFAULT 0 NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"embedding" vector(384) NOT NULL,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "experience" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"location" text,
|
||||
"start_date" text NOT NULL,
|
||||
"end_date" text,
|
||||
"is_current" boolean DEFAULT false NOT NULL,
|
||||
"description" text,
|
||||
"achievements" jsonb,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL,
|
||||
"updated_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "generated_cover_letter" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"application_id" integer NOT NULL,
|
||||
"version" integer DEFAULT 1 NOT NULL,
|
||||
"content_markdown" text NOT NULL,
|
||||
"pdf_path" text,
|
||||
"prompt_used" text,
|
||||
"is_approved" boolean DEFAULT false NOT NULL,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "generated_cv" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"application_id" integer NOT NULL,
|
||||
"version" integer DEFAULT 1 NOT NULL,
|
||||
"content_markdown" text NOT NULL,
|
||||
"content_structured" jsonb,
|
||||
"pdf_path" text,
|
||||
"prompt_used" text,
|
||||
"is_approved" boolean DEFAULT false NOT NULL,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "generation_feedback" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"generated_cv_id" integer,
|
||||
"generated_cl_id" integer,
|
||||
"application_id" integer NOT NULL,
|
||||
"outcome" text,
|
||||
"quality_rating" integer,
|
||||
"feedback_notes" text,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "language" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"proficiency" text NOT NULL,
|
||||
CONSTRAINT "language_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "profile" (
|
||||
"id" integer PRIMARY KEY DEFAULT 1 NOT NULL,
|
||||
"full_name" text NOT NULL,
|
||||
"email" text,
|
||||
"phone" text,
|
||||
"location" text,
|
||||
"website" text,
|
||||
"linkedin_url" text,
|
||||
"github_url" text,
|
||||
"summary" text,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL,
|
||||
"updated_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "prompt_config" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"label" text NOT NULL,
|
||||
"description" text,
|
||||
"system_prompt" text NOT NULL,
|
||||
"default_prompt" text NOT NULL,
|
||||
"updated_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL,
|
||||
CONSTRAINT "prompt_config_key_unique" UNIQUE("key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "reminder" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"application_id" integer,
|
||||
"type" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"due_date" text NOT NULL,
|
||||
"notes" text,
|
||||
"is_completed" boolean DEFAULT false NOT NULL,
|
||||
"completed_at" text,
|
||||
"created_at" text DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "skill" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"category" text,
|
||||
"proficiency" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT "skill_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD CONSTRAINT "application_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "application_event" ADD CONSTRAINT "application_event_application_id_application_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."application"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "application_event" ADD CONSTRAINT "application_event_contact_id_contact_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "company_research" ADD CONSTRAINT "company_research_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contact" ADD CONSTRAINT "contact_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contact" ADD CONSTRAINT "contact_application_id_application_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."application"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contact_application" ADD CONSTRAINT "contact_application_contact_id_contact_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contact_application" ADD CONSTRAINT "contact_application_application_id_application_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."application"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "generated_cover_letter" ADD CONSTRAINT "generated_cover_letter_application_id_application_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."application"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "generated_cv" ADD CONSTRAINT "generated_cv_application_id_application_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."application"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "generation_feedback" ADD CONSTRAINT "generation_feedback_generated_cv_id_generated_cv_id_fk" FOREIGN KEY ("generated_cv_id") REFERENCES "public"."generated_cv"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "generation_feedback" ADD CONSTRAINT "generation_feedback_generated_cl_id_generated_cover_letter_id_fk" FOREIGN KEY ("generated_cl_id") REFERENCES "public"."generated_cover_letter"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "generation_feedback" ADD CONSTRAINT "generation_feedback_application_id_application_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."application"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reminder" ADD CONSTRAINT "reminder_application_id_application_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."application"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_application_status" ON "application" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "idx_application_company" ON "application" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_application_date" ON "application" USING btree ("applied_date");--> statement-breakpoint
|
||||
CREATE INDEX "idx_event_application" ON "application_event" USING btree ("application_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_event_type" ON "application_event" USING btree ("event_type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_event_scheduled" ON "application_event" USING btree ("scheduled_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_company_name" ON "company" USING btree ("name");--> statement-breakpoint
|
||||
CREATE INDEX "idx_research_company" ON "company_research" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_research_type" ON "company_research" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_contact_company" ON "contact" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_contact_application" ON "contact" USING btree ("application_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_ca_application" ON "contact_application" USING btree ("application_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_chunk_source" ON "embedding_chunk" USING btree ("source_type","source_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_chunk_embedding_cosine" ON "embedding_chunk" USING hnsw ("embedding" vector_cosine_ops);--> statement-breakpoint
|
||||
CREATE INDEX "idx_cl_application" ON "generated_cover_letter" USING btree ("application_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cv_application" ON "generated_cv" USING btree ("application_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_feedback_outcome" ON "generation_feedback" USING btree ("outcome");--> statement-breakpoint
|
||||
CREATE INDEX "idx_reminder_due" ON "reminder" USING btree ("due_date");--> statement-breakpoint
|
||||
CREATE INDEX "idx_reminder_app" ON "reminder" USING btree ("application_id");
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
CREATE TABLE `application` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`company_id` integer NOT NULL,
|
||||
`job_title` text NOT NULL,
|
||||
`job_description` text NOT NULL,
|
||||
`job_url` text,
|
||||
`salary_range` text,
|
||||
`status` text DEFAULT 'draft' NOT NULL,
|
||||
`applied_date` text,
|
||||
`response_date` text,
|
||||
`notes` text,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
`updated_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
FOREIGN KEY (`company_id`) REFERENCES `company`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_application_status` ON `application` (`status`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_application_company` ON `application` (`company_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_application_date` ON `application` (`applied_date`);--> statement-breakpoint
|
||||
CREATE TABLE `application_event` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`application_id` integer NOT NULL,
|
||||
`from_status` text,
|
||||
`to_status` text NOT NULL,
|
||||
`notes` text,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
FOREIGN KEY (`application_id`) REFERENCES `application`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_event_application` ON `application_event` (`application_id`);--> statement-breakpoint
|
||||
CREATE TABLE `certification` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`issuer` text NOT NULL,
|
||||
`date_obtained` text,
|
||||
`expiry_date` text,
|
||||
`credential_url` text,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `company` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`website` text,
|
||||
`industry` text,
|
||||
`notes` text,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_company_name` ON `company` (`name`);--> statement-breakpoint
|
||||
CREATE TABLE `document` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`original_name` text NOT NULL,
|
||||
`mime_type` text NOT NULL,
|
||||
`file_path` text NOT NULL,
|
||||
`parsed_text` text,
|
||||
`doc_type` text,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `education` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`institution` text NOT NULL,
|
||||
`degree` text NOT NULL,
|
||||
`field_of_study` text,
|
||||
`start_date` text NOT NULL,
|
||||
`end_date` text,
|
||||
`description` text,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
`updated_at` text DEFAULT (datetime('now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `experience` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`company` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`location` text,
|
||||
`start_date` text NOT NULL,
|
||||
`end_date` text,
|
||||
`is_current` integer DEFAULT false NOT NULL,
|
||||
`description` text,
|
||||
`achievements` text,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
`updated_at` text DEFAULT (datetime('now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `generated_cover_letter` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`application_id` integer NOT NULL,
|
||||
`version` integer DEFAULT 1 NOT NULL,
|
||||
`content_markdown` text NOT NULL,
|
||||
`pdf_path` text,
|
||||
`prompt_used` text,
|
||||
`is_approved` integer DEFAULT false NOT NULL,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
FOREIGN KEY (`application_id`) REFERENCES `application`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_cl_application` ON `generated_cover_letter` (`application_id`);--> statement-breakpoint
|
||||
CREATE TABLE `generated_cv` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`application_id` integer NOT NULL,
|
||||
`version` integer DEFAULT 1 NOT NULL,
|
||||
`content_markdown` text NOT NULL,
|
||||
`content_structured` text,
|
||||
`pdf_path` text,
|
||||
`prompt_used` text,
|
||||
`is_approved` integer DEFAULT false NOT NULL,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
FOREIGN KEY (`application_id`) REFERENCES `application`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_cv_application` ON `generated_cv` (`application_id`);--> statement-breakpoint
|
||||
CREATE TABLE `generation_feedback` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`generated_cv_id` integer,
|
||||
`generated_cl_id` integer,
|
||||
`application_id` integer NOT NULL,
|
||||
`outcome` text,
|
||||
`quality_rating` integer,
|
||||
`feedback_notes` text,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
FOREIGN KEY (`generated_cv_id`) REFERENCES `generated_cv`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`generated_cl_id`) REFERENCES `generated_cover_letter`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`application_id`) REFERENCES `application`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_feedback_outcome` ON `generation_feedback` (`outcome`);--> statement-breakpoint
|
||||
CREATE TABLE `language` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`proficiency` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `language_name_unique` ON `language` (`name`);--> statement-breakpoint
|
||||
CREATE TABLE `profile` (
|
||||
`id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
|
||||
`full_name` text NOT NULL,
|
||||
`email` text,
|
||||
`phone` text,
|
||||
`location` text,
|
||||
`website` text,
|
||||
`linkedin_url` text,
|
||||
`github_url` text,
|
||||
`summary` text,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
`updated_at` text DEFAULT (datetime('now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `skill` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`category` text,
|
||||
`proficiency` text,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `skill_name_unique` ON `skill` (`name`);
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
CREATE TABLE `embedding_chunk` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`source_type` text NOT NULL,
|
||||
`source_id` integer NOT NULL,
|
||||
`chunk_text` text NOT NULL,
|
||||
`chunk_index` integer DEFAULT 0 NOT NULL,
|
||||
`metadata` text,
|
||||
`embedding` text NOT NULL,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_chunk_source` ON `embedding_chunk` (`source_type`,`source_id`);
|
||||
4
drizzle/migrations/0001_drop_contact_application_id.sql
Normal file
4
drizzle/migrations/0001_drop_contact_application_id.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE "contact" DROP CONSTRAINT "contact_application_id_application_id_fk";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX "idx_contact_application";--> statement-breakpoint
|
||||
ALTER TABLE "contact" DROP COLUMN "application_id";
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1774008205941,
|
||||
"tag": "0000_rare_enchantress",
|
||||
"version": "7",
|
||||
"when": 1779486916903,
|
||||
"tag": "0000_init",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1775520573983,
|
||||
"tag": "0001_dizzy_doctor_doom",
|
||||
"version": "7",
|
||||
"when": 1779612813782,
|
||||
"tag": "0001_drop_contact_application_id",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
94
package-lock.json
generated
94
package-lock.json
generated
|
|
@ -10,7 +10,6 @@
|
|||
"dependencies": {
|
||||
"@huggingface/transformers": "^4.0.1",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
|
|
@ -18,6 +17,7 @@
|
|||
"lucide-react": "^0.577.0",
|
||||
"next": "16.2.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
@ -28,7 +28,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
|
@ -2926,17 +2925,6 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
|
|
@ -4025,21 +4013,6 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.8.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
|
||||
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
|
|
@ -4054,6 +4027,7 @@
|
|||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
|
|
@ -4063,6 +4037,7 @@
|
|||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
|
|
@ -4172,6 +4147,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
|
|
@ -4335,7 +4311,8 @@
|
|||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
|
|
@ -4676,6 +4653,7 @@
|
|||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
|
|
@ -4691,6 +4669,7 @@
|
|||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
|
|
@ -4973,6 +4952,7 @@
|
|||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
|
|
@ -5709,6 +5689,7 @@
|
|||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
|
|
@ -5796,7 +5777,8 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
|
|
@ -5892,7 +5874,8 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
|
|
@ -6044,7 +6027,8 @@
|
|||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
|
|
@ -6341,7 +6325,8 @@
|
|||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
|
|
@ -6400,7 +6385,8 @@
|
|||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
"version": "0.2.7",
|
||||
|
|
@ -8368,6 +8354,7 @@
|
|||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
|
@ -8392,6 +8379,7 @@
|
|||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
|
|
@ -8401,7 +8389,8 @@
|
|||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
|
|
@ -8431,7 +8420,8 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/napi-postinstall": {
|
||||
"version": "0.3.4",
|
||||
|
|
@ -8551,6 +8541,7 @@
|
|||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
|
||||
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
|
|
@ -8563,6 +8554,7 @@
|
|||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
|
|
@ -8731,6 +8723,7 @@
|
|||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
|
|
@ -9025,12 +9018,27 @@
|
|||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postgres": {
|
||||
"version": "3.4.9",
|
||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
|
||||
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
|
||||
"license": "Unlicense",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/porsager"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
|
|
@ -9118,6 +9126,7 @@
|
|||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
|
|
@ -9168,6 +9177,7 @@
|
|||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
|
|
@ -9183,6 +9193,7 @@
|
|||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -9279,6 +9290,7 @@
|
|||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
|
|
@ -9892,7 +9904,8 @@
|
|||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
|
|
@ -9913,6 +9926,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
|
|
@ -10263,6 +10277,7 @@
|
|||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
|
|
@ -10275,6 +10290,7 @@
|
|||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
|
|
@ -10934,6 +10950,7 @@
|
|||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
|
|
@ -11495,7 +11512,8 @@
|
|||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
"dependencies": {
|
||||
"@huggingface/transformers": "^4.0.1",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
|
|
@ -22,6 +21,7 @@
|
|||
"lucide-react": "^0.577.0",
|
||||
"next": "16.2.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
@ -32,7 +32,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
|
|
|||
61
scripts/dump-sqlite.mjs
Normal file
61
scripts/dump-sqlite.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// One-shot dump of the SQLite DB to JSON.
|
||||
// Reads each table by name (raw SQL) so it works regardless of schema drift.
|
||||
// Output: storage/db/dump.json — keyed by table name in restore order.
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || "./storage/db/job-agent.db";
|
||||
const OUT_PATH = "./storage/db/dump.json";
|
||||
|
||||
// Order matters for restore: parents before children.
|
||||
const TABLES_IN_ORDER = [
|
||||
"profile",
|
||||
"education",
|
||||
"experience",
|
||||
"skill",
|
||||
"certification",
|
||||
"language",
|
||||
"document",
|
||||
"company",
|
||||
"application",
|
||||
"application_event",
|
||||
"generated_cv",
|
||||
"generated_cover_letter",
|
||||
"reminder",
|
||||
"contact",
|
||||
"prompt_config",
|
||||
"generation_feedback",
|
||||
"embedding_chunk",
|
||||
];
|
||||
|
||||
const db = new Database(DB_PATH, { readonly: true });
|
||||
|
||||
const existing = new Set(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
.all()
|
||||
.map((r) => r.name)
|
||||
);
|
||||
|
||||
const dump = {};
|
||||
const counts = {};
|
||||
|
||||
for (const table of TABLES_IN_ORDER) {
|
||||
if (!existing.has(table)) {
|
||||
console.warn(`skip ${table} (not in DB)`);
|
||||
continue;
|
||||
}
|
||||
const rows = db.prepare(`SELECT * FROM ${table}`).all();
|
||||
dump[table] = rows;
|
||||
counts[table] = rows.length;
|
||||
}
|
||||
|
||||
db.close();
|
||||
|
||||
mkdirSync(path.dirname(OUT_PATH), { recursive: true });
|
||||
writeFileSync(OUT_PATH, JSON.stringify(dump, null, 2));
|
||||
|
||||
console.log("Dump written to", OUT_PATH);
|
||||
console.table(counts);
|
||||
106
scripts/restore-postgres.mjs
Normal file
106
scripts/restore-postgres.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Restore SQLite dump (storage/db/dump.json) into Postgres.
|
||||
// Preserves IDs, converts SQLite booleans (0/1) to true/false, parses JSON text
|
||||
// columns to objects for jsonb, and resets serial sequences post-insert.
|
||||
|
||||
import postgres from "postgres";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const DUMP_PATH = "./storage/db/dump.json";
|
||||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL ||
|
||||
"postgres://jobagent:jobagent@127.0.0.1:5433/job_agent";
|
||||
|
||||
const BOOL_COLUMNS = {
|
||||
experience: ["is_current"],
|
||||
generated_cv: ["is_approved"],
|
||||
generated_cover_letter: ["is_approved"],
|
||||
reminder: ["is_completed"],
|
||||
};
|
||||
|
||||
const JSON_COLUMNS = {
|
||||
experience: ["achievements"],
|
||||
generated_cv: ["content_structured"],
|
||||
embedding_chunk: ["metadata"],
|
||||
};
|
||||
|
||||
// Order parents before children (FK constraints).
|
||||
const TABLES_IN_ORDER = [
|
||||
"profile",
|
||||
"education",
|
||||
"experience",
|
||||
"skill",
|
||||
"certification",
|
||||
"language",
|
||||
"document",
|
||||
"company",
|
||||
"application",
|
||||
"application_event",
|
||||
"generated_cv",
|
||||
"generated_cover_letter",
|
||||
"reminder",
|
||||
"contact",
|
||||
"prompt_config",
|
||||
"generation_feedback",
|
||||
"embedding_chunk",
|
||||
];
|
||||
|
||||
// Tables that use a SERIAL "id" sequence we must advance after restore.
|
||||
const SERIAL_TABLES = TABLES_IN_ORDER.filter(
|
||||
(t) => t !== "profile" && t !== "contact_application"
|
||||
);
|
||||
|
||||
function transformRow(table, row) {
|
||||
const out = { ...row };
|
||||
for (const col of BOOL_COLUMNS[table] || []) {
|
||||
if (out[col] !== undefined && out[col] !== null) {
|
||||
out[col] = Boolean(out[col]);
|
||||
}
|
||||
}
|
||||
for (const col of JSON_COLUMNS[table] || []) {
|
||||
if (typeof out[col] === "string" && out[col] !== "") {
|
||||
try {
|
||||
out[col] = JSON.parse(out[col]);
|
||||
} catch {
|
||||
/* leave as-is */
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const dump = JSON.parse(readFileSync(DUMP_PATH, "utf-8"));
|
||||
const sql = postgres(DATABASE_URL);
|
||||
|
||||
try {
|
||||
for (const table of TABLES_IN_ORDER) {
|
||||
const rows = dump[table];
|
||||
if (!rows || rows.length === 0) {
|
||||
console.log(`skip ${table} (empty)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const transformed = rows.map((r) => transformRow(table, r));
|
||||
|
||||
// Insert in batches; postgres-js handles array-of-objects nicely.
|
||||
const BATCH = 200;
|
||||
for (let i = 0; i < transformed.length; i += BATCH) {
|
||||
const batch = transformed.slice(i, i + BATCH);
|
||||
await sql`INSERT INTO ${sql(table)} ${sql(batch)}`;
|
||||
}
|
||||
console.log(`restored ${table}: ${transformed.length} rows`);
|
||||
}
|
||||
|
||||
// Advance sequences past current max(id) so new inserts don't collide.
|
||||
for (const table of SERIAL_TABLES) {
|
||||
if (!dump[table] || dump[table].length === 0) continue;
|
||||
await sql`
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence(${table}, 'id'),
|
||||
(SELECT COALESCE(MAX(id), 1) FROM ${sql(table)})
|
||||
)
|
||||
`;
|
||||
}
|
||||
console.log("sequences advanced");
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ export async function GET(
|
|||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const data = getFeedbackByApplicationId(Number(id));
|
||||
const data = await getFeedbackByApplicationId(Number(id));
|
||||
return NextResponse.json({ success: true, data, error: null });
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ export async function POST(
|
|||
);
|
||||
}
|
||||
|
||||
const result = createFeedback({
|
||||
const result = await createFeedback({
|
||||
applicationId: Number(id),
|
||||
outcome: parsed.data.outcome,
|
||||
qualityRating: parsed.data.qualityRating,
|
||||
|
|
@ -70,6 +70,6 @@ export async function PATCH(
|
|||
);
|
||||
}
|
||||
|
||||
const result = updateFeedback(feedbackId, data);
|
||||
const result = await updateFeedback(feedbackId, data);
|
||||
return NextResponse.json({ success: true, data: result, error: null });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ export async function POST(
|
|||
);
|
||||
}
|
||||
|
||||
const prompt = buildCoverLetterPrompt(context);
|
||||
const prompt = await buildCoverLetterPrompt(context);
|
||||
|
||||
try {
|
||||
const result = await callClaude(prompt);
|
||||
|
||||
const cl = createGeneratedCl({
|
||||
const cl = await createGeneratedCl({
|
||||
applicationId,
|
||||
contentMarkdown: result.content,
|
||||
promptUsed: prompt,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import {
|
|||
updateApplication,
|
||||
deleteApplication,
|
||||
updateApplicationStatus,
|
||||
findOrCreateCompany,
|
||||
} from "@/db/queries/applications";
|
||||
import { statusUpdateSchema } from "@/lib/validators/schemas";
|
||||
import { applicationSchema, statusUpdateSchema } from "@/lib/validators/schemas";
|
||||
import { canTransition } from "@/lib/status-machine";
|
||||
import type { ApplicationStatus } from "@/types";
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ export async function GET(
|
|||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const app = getApplicationById(Number(id));
|
||||
const app = await getApplicationById(Number(id));
|
||||
if (!app) {
|
||||
return NextResponse.json(
|
||||
{ success: false, data: null, error: "Application not found" },
|
||||
|
|
@ -30,7 +31,44 @@ export async function PUT(
|
|||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const result = updateApplication(Number(id), body);
|
||||
const parsed = applicationSchema.partial().safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, data: null, error: parsed.error.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const app = await getApplicationById(Number(id));
|
||||
if (!app) {
|
||||
return NextResponse.json(
|
||||
{ success: false, data: null, error: "Application not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { companyName, companyWebsite, companyIndustry, ...appFields } =
|
||||
parsed.data;
|
||||
|
||||
// Handle company update if name changed
|
||||
if (companyName && companyName !== app.companyName) {
|
||||
const comp = await findOrCreateCompany(
|
||||
companyName,
|
||||
companyWebsite || undefined,
|
||||
companyIndustry || undefined
|
||||
);
|
||||
(appFields as Record<string, unknown>).companyId = comp.id;
|
||||
}
|
||||
|
||||
const result = await updateApplication(Number(id), appFields);
|
||||
|
||||
// Re-index for RAG if job description changed
|
||||
if (parsed.data.jobDescription) {
|
||||
import("@/lib/rag/indexer").then(({ indexApplication }) =>
|
||||
indexApplication(Number(id)).catch(() => {})
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: result, error: null });
|
||||
}
|
||||
|
||||
|
|
@ -48,7 +86,7 @@ export async function PATCH(
|
|||
);
|
||||
}
|
||||
|
||||
const app = getApplicationById(Number(id));
|
||||
const app = await getApplicationById(Number(id));
|
||||
if (!app) {
|
||||
return NextResponse.json(
|
||||
{ success: false, data: null, error: "Application not found" },
|
||||
|
|
@ -67,11 +105,17 @@ export async function PATCH(
|
|||
);
|
||||
}
|
||||
|
||||
const result = updateApplicationStatus(
|
||||
const result = await updateApplicationStatus(
|
||||
Number(id),
|
||||
parsed.data.status,
|
||||
parsed.data.notes
|
||||
);
|
||||
|
||||
// Auto-create reminders based on new status
|
||||
import("@/db/queries/reminders").then(({ createAutoReminders }) =>
|
||||
createAutoReminders(Number(id), parsed.data.status)
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, data: result, error: null });
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +124,6 @@ export async function DELETE(
|
|||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
deleteApplication(Number(id));
|
||||
await deleteApplication(Number(id));
|
||||
return NextResponse.json({ success: true, data: null, error: null });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { applicationSchema } from "@/lib/validators/schemas";
|
|||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get("status") || undefined;
|
||||
const data = getApplications(status);
|
||||
const data = await getApplications(status);
|
||||
return NextResponse.json({ success: true, data, error: null });
|
||||
}
|
||||
|
||||
|
|
@ -23,13 +23,13 @@ export async function POST(request: Request) {
|
|||
);
|
||||
}
|
||||
|
||||
const comp = findOrCreateCompany(
|
||||
const comp = await findOrCreateCompany(
|
||||
parsed.data.companyName,
|
||||
parsed.data.companyWebsite || undefined,
|
||||
parsed.data.companyIndustry || undefined
|
||||
);
|
||||
|
||||
const app = createApplication({
|
||||
const app = await createApplication({
|
||||
companyId: comp.id,
|
||||
jobTitle: parsed.data.jobTitle,
|
||||
jobDescription: parsed.data.jobDescription,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { NextResponse } from "next/server";
|
|||
import { getDashboardStats } from "@/db/queries/dashboard";
|
||||
|
||||
export async function GET() {
|
||||
const stats = getDashboardStats();
|
||||
const stats = await getDashboardStats();
|
||||
return NextResponse.json({ success: true, data: stats, error: null });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ const UPLOAD_DIR = path.join(process.cwd(), "storage", "uploads");
|
|||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ success: true, data: getDocuments(), error: null });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: await getDocuments(),
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
|
@ -67,7 +71,7 @@ export async function POST(request: Request) {
|
|||
}
|
||||
}
|
||||
|
||||
const doc = addDocument({
|
||||
const doc = await addDocument({
|
||||
filename,
|
||||
originalName: file.name,
|
||||
mimeType: file.type,
|
||||
|
|
@ -99,7 +103,7 @@ export async function DELETE(request: Request) {
|
|||
);
|
||||
}
|
||||
|
||||
const doc = deleteDocument(id);
|
||||
const doc = await deleteDocument(id);
|
||||
if (doc) {
|
||||
try {
|
||||
await unlink(path.join(process.cwd(), "storage", doc.filePath));
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getEducation, addEducation, updateEducation, deleteEducation } from "@/db/queries/profile";
|
||||
import {
|
||||
getEducation,
|
||||
addEducation,
|
||||
updateEducation,
|
||||
deleteEducation,
|
||||
} from "@/db/queries/profile";
|
||||
import { educationSchema } from "@/lib/validators/schemas";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ success: true, data: getEducation(), error: null });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: await getEducation(),
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
|
@ -15,8 +24,11 @@ export async function POST(request: Request) {
|
|||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = addEducation(parsed.data);
|
||||
return NextResponse.json({ success: true, data: result, error: null }, { status: 201 });
|
||||
const result = await addEducation(parsed.data);
|
||||
return NextResponse.json(
|
||||
{ success: true, data: result, error: null },
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
|
|
@ -35,7 +47,7 @@ export async function PUT(request: Request) {
|
|||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = updateEducation(id, parsed.data);
|
||||
const result = await updateEducation(id, parsed.data);
|
||||
return NextResponse.json({ success: true, data: result, error: null });
|
||||
}
|
||||
|
||||
|
|
@ -48,6 +60,6 @@ export async function DELETE(request: Request) {
|
|||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
deleteEducation(id);
|
||||
await deleteEducation(id);
|
||||
return NextResponse.json({ success: true, data: null, error: null });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getExperience, addExperience, updateExperience, deleteExperience } from
|
|||
import { experienceSchema } from "@/lib/validators/schemas";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ success: true, data: getExperience(), error: null });
|
||||
return NextResponse.json({ success: true, data: await getExperience(), error: null });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
|
@ -15,7 +15,7 @@ export async function POST(request: Request) {
|
|||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = addExperience(parsed.data);
|
||||
const result = await addExperience(parsed.data);
|
||||
|
||||
// Index experience for RAG (fire-and-forget)
|
||||
import("@/lib/rag/indexer").then(({ indexExperience }) =>
|
||||
|
|
@ -41,7 +41,7 @@ export async function PUT(request: Request) {
|
|||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = updateExperience(id, parsed.data);
|
||||
const result = await updateExperience(id, parsed.data);
|
||||
|
||||
// Reindex experience for RAG (fire-and-forget)
|
||||
import("@/lib/rag/indexer").then(({ indexExperience }) =>
|
||||
|
|
@ -60,6 +60,6 @@ export async function DELETE(request: Request) {
|
|||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
deleteExperience(id);
|
||||
await deleteExperience(id);
|
||||
return NextResponse.json({ success: true, data: null, error: null });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ export async function GET(
|
|||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get("type") || "cv";
|
||||
|
||||
const doc = type === "cv" ? getGeneratedCv(Number(id)) : getGeneratedCl(Number(id));
|
||||
const doc =
|
||||
type === "cv"
|
||||
? await getGeneratedCv(Number(id))
|
||||
: await getGeneratedCl(Number(id));
|
||||
|
||||
if (!doc) {
|
||||
return NextResponse.json(
|
||||
|
|
@ -40,11 +43,11 @@ export async function PATCH(
|
|||
|
||||
if (action === "approve") {
|
||||
const doc =
|
||||
type === "cv" ? approveGeneratedCv(docId) : approveCl(docId);
|
||||
type === "cv" ? await approveGeneratedCv(docId) : await approveCl(docId);
|
||||
|
||||
if (doc) {
|
||||
// Auto-create feedback entry
|
||||
createFeedback({
|
||||
await createFeedback({
|
||||
generatedCvId: type === "cv" ? docId : undefined,
|
||||
generatedClId: type === "cl" ? docId : undefined,
|
||||
applicationId: doc.applicationId,
|
||||
|
|
@ -57,8 +60,8 @@ export async function PATCH(
|
|||
if (action === "update" && content) {
|
||||
const doc =
|
||||
type === "cv"
|
||||
? updateCvContent(docId, content)
|
||||
: updateClContent(docId, content);
|
||||
? await updateCvContent(docId, content)
|
||||
: await updateClContent(docId, content);
|
||||
return NextResponse.json({ success: true, data: doc, error: null });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,23 +5,33 @@ import { eq, desc, sql } from "drizzle-orm";
|
|||
import { updateFeedback } from "@/db/queries/feedback";
|
||||
|
||||
export async function GET() {
|
||||
const entries = db
|
||||
const entries = await db
|
||||
.select({
|
||||
id: sql<number>`MAX(${generationFeedback.id})`.as("id"),
|
||||
applicationId: generationFeedback.applicationId,
|
||||
outcome: sql<string | null>`MAX(${generationFeedback.outcome})`.as("outcome"),
|
||||
qualityRating: sql<number | null>`MAX(${generationFeedback.qualityRating})`.as("qualityRating"),
|
||||
feedbackNotes: sql<string | null>`MAX(${generationFeedback.feedbackNotes})`.as("feedbackNotes"),
|
||||
createdAt: sql<string>`MAX(${generationFeedback.createdAt})`.as("createdAt"),
|
||||
outcome: sql<string | null>`MAX(${generationFeedback.outcome})`.as(
|
||||
"outcome"
|
||||
),
|
||||
qualityRating: sql<
|
||||
number | null
|
||||
>`MAX(${generationFeedback.qualityRating})`.as("qualityRating"),
|
||||
feedbackNotes: sql<
|
||||
string | null
|
||||
>`MAX(${generationFeedback.feedbackNotes})`.as("feedbackNotes"),
|
||||
createdAt: sql<string>`MAX(${generationFeedback.createdAt})`.as(
|
||||
"createdAt"
|
||||
),
|
||||
jobTitle: application.jobTitle,
|
||||
companyName: company.name,
|
||||
})
|
||||
.from(generationFeedback)
|
||||
.innerJoin(application, eq(generationFeedback.applicationId, application.id))
|
||||
.innerJoin(
|
||||
application,
|
||||
eq(generationFeedback.applicationId, application.id)
|
||||
)
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.groupBy(generationFeedback.applicationId)
|
||||
.orderBy(desc(sql`MAX(${generationFeedback.createdAt})`))
|
||||
.all();
|
||||
.groupBy(generationFeedback.applicationId, application.jobTitle, company.name)
|
||||
.orderBy(desc(sql`MAX(${generationFeedback.createdAt})`));
|
||||
|
||||
return NextResponse.json({ success: true, data: entries, error: null });
|
||||
}
|
||||
|
|
@ -37,6 +47,6 @@ export async function PATCH(request: Request) {
|
|||
);
|
||||
}
|
||||
|
||||
const result = updateFeedback(id, data);
|
||||
const result = await updateFeedback(id, data);
|
||||
return NextResponse.json({ success: true, data: result, error: null });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getFullProfile, upsertProfile } from "@/db/queries/profile";
|
|||
import { profileSchema } from "@/lib/validators/schemas";
|
||||
|
||||
export async function GET() {
|
||||
const data = getFullProfile();
|
||||
const data = await getFullProfile();
|
||||
return NextResponse.json({ success: true, data, error: null });
|
||||
}
|
||||
|
||||
|
|
@ -16,6 +16,6 @@ export async function PUT(request: Request) {
|
|||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = upsertProfile(parsed.data);
|
||||
const result = await upsertProfile(parsed.data);
|
||||
return NextResponse.json({ success: true, data: result, error: null });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { getChunkStats, getTotalChunkCount } from "@/db/queries/chunks";
|
|||
export async function POST() {
|
||||
try {
|
||||
const stats = await reindexAll();
|
||||
const totalChunks = getTotalChunkCount();
|
||||
const totalChunks = await getTotalChunkCount();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
|
@ -23,8 +23,10 @@ export async function POST() {
|
|||
}
|
||||
|
||||
export async function GET() {
|
||||
const stats = getChunkStats();
|
||||
const total = getTotalChunkCount();
|
||||
const [stats, total] = await Promise.all([
|
||||
getChunkStats(),
|
||||
getTotalChunkCount(),
|
||||
]);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { bySourceType: stats, total },
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getSkills, addSkill, updateSkill, deleteSkill } from "@/db/queries/profile";
|
||||
import {
|
||||
getSkills,
|
||||
addSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
} from "@/db/queries/profile";
|
||||
import { skillSchema } from "@/lib/validators/schemas";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ success: true, data: getSkills(), error: null });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: await getSkills(),
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
|
@ -15,8 +24,11 @@ export async function POST(request: Request) {
|
|||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = addSkill(parsed.data);
|
||||
return NextResponse.json({ success: true, data: result, error: null }, { status: 201 });
|
||||
const result = await addSkill(parsed.data);
|
||||
return NextResponse.json(
|
||||
{ success: true, data: result, error: null },
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
|
|
@ -28,7 +40,7 @@ export async function PUT(request: Request) {
|
|||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = updateSkill(id, data);
|
||||
const result = await updateSkill(id, data);
|
||||
return NextResponse.json({ success: true, data: result, error: null });
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +53,6 @@ export async function DELETE(request: Request) {
|
|||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
deleteSkill(id);
|
||||
await deleteSkill(id);
|
||||
return NextResponse.json({ success: true, data: null, error: null });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,23 @@
|
|||
import Database from "better-sqlite3";
|
||||
import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
import path from "node:path";
|
||||
|
||||
let _db: BetterSQLite3Database<typeof schema> | null = null;
|
||||
let _db: PostgresJsDatabase<typeof schema> | null = null;
|
||||
|
||||
function getDb() {
|
||||
if (_db) return _db;
|
||||
|
||||
const DB_PATH =
|
||||
process.env.DB_PATH ||
|
||||
path.join(process.cwd(), "storage", "db", "job-agent.db");
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
|
||||
const sqlite = new Database(DB_PATH);
|
||||
sqlite.pragma("journal_mode = WAL");
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
sqlite.pragma("busy_timeout = 5000");
|
||||
|
||||
_db = drizzle(sqlite, { schema });
|
||||
const client = postgres(url, { max: 10 });
|
||||
_db = drizzle(client, { schema });
|
||||
return _db;
|
||||
}
|
||||
|
||||
export const db = new Proxy({} as BetterSQLite3Database<typeof schema>, {
|
||||
export const db = new Proxy({} as PostgresJsDatabase<typeof schema>, {
|
||||
get(_target, prop) {
|
||||
const realDb = getDb();
|
||||
const value = (realDb as unknown as Record<string | symbol, unknown>)[prop];
|
||||
|
|
|
|||
|
|
@ -3,27 +3,35 @@ import {
|
|||
application,
|
||||
applicationEvent,
|
||||
company,
|
||||
companyResearch,
|
||||
contact,
|
||||
contactApplication,
|
||||
generatedCv,
|
||||
generatedCoverLetter,
|
||||
generationFeedback,
|
||||
} from "@/db/schema";
|
||||
import { eq, desc, like, sql } from "drizzle-orm";
|
||||
import { and, eq, desc, ne, sql } from "drizzle-orm";
|
||||
|
||||
export function findOrCreateCompany(name: string, website?: string, industry?: string) {
|
||||
const existing = db
|
||||
export async function findOrCreateCompany(
|
||||
name: string,
|
||||
website?: string,
|
||||
industry?: string
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(company)
|
||||
.where(eq(company.name, name))
|
||||
.get();
|
||||
.limit(1);
|
||||
if (existing) return existing;
|
||||
return db
|
||||
const [created] = await db
|
||||
.insert(company)
|
||||
.values({ name, website: website || null, industry: industry || null })
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export function getApplications(statusFilter?: string) {
|
||||
const query = db
|
||||
export async function getApplications(statusFilter?: string) {
|
||||
const base = db
|
||||
.select({
|
||||
id: application.id,
|
||||
jobTitle: application.jobTitle,
|
||||
|
|
@ -34,17 +42,18 @@ export function getApplications(statusFilter?: string) {
|
|||
companyId: company.id,
|
||||
})
|
||||
.from(application)
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.orderBy(desc(application.createdAt));
|
||||
.innerJoin(company, eq(application.companyId, company.id));
|
||||
|
||||
if (statusFilter) {
|
||||
return query.where(eq(application.status, statusFilter)).all();
|
||||
return base
|
||||
.where(eq(application.status, statusFilter))
|
||||
.orderBy(desc(application.createdAt));
|
||||
}
|
||||
return query.all();
|
||||
return base.orderBy(desc(application.createdAt));
|
||||
}
|
||||
|
||||
export function getApplicationById(id: number) {
|
||||
const app = db
|
||||
export async function getApplicationById(id: number) {
|
||||
const [app] = await db
|
||||
.select({
|
||||
id: application.id,
|
||||
companyId: application.companyId,
|
||||
|
|
@ -56,6 +65,7 @@ export function getApplicationById(id: number) {
|
|||
appliedDate: application.appliedDate,
|
||||
responseDate: application.responseDate,
|
||||
notes: application.notes,
|
||||
interviewPrep: application.interviewPrep,
|
||||
createdAt: application.createdAt,
|
||||
updatedAt: application.updatedAt,
|
||||
companyName: company.name,
|
||||
|
|
@ -65,35 +75,92 @@ export function getApplicationById(id: number) {
|
|||
.from(application)
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.where(eq(application.id, id))
|
||||
.get();
|
||||
.limit(1);
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
const events = db
|
||||
const events = await db
|
||||
.select()
|
||||
.from(applicationEvent)
|
||||
.where(eq(applicationEvent.applicationId, id))
|
||||
.orderBy(desc(applicationEvent.createdAt))
|
||||
.all();
|
||||
.orderBy(desc(applicationEvent.createdAt));
|
||||
|
||||
const cvs = db
|
||||
const cvs = await db
|
||||
.select()
|
||||
.from(generatedCv)
|
||||
.where(eq(generatedCv.applicationId, id))
|
||||
.orderBy(desc(generatedCv.version))
|
||||
.all();
|
||||
.orderBy(desc(generatedCv.version));
|
||||
|
||||
const coverLetters = db
|
||||
const coverLetters = await db
|
||||
.select()
|
||||
.from(generatedCoverLetter)
|
||||
.where(eq(generatedCoverLetter.applicationId, id))
|
||||
.orderBy(desc(generatedCoverLetter.version))
|
||||
.all();
|
||||
.orderBy(desc(generatedCoverLetter.version));
|
||||
|
||||
return { ...app, events, cvs, coverLetters };
|
||||
const feedbacks = await db
|
||||
.select()
|
||||
.from(generationFeedback)
|
||||
.where(eq(generationFeedback.applicationId, id));
|
||||
|
||||
const cvsWithFeedback = cvs.map((cv) => {
|
||||
const fb = feedbacks.find((f) => f.generatedCvId === cv.id);
|
||||
return { ...cv, outcome: fb?.outcome || null };
|
||||
});
|
||||
|
||||
const clsWithFeedback = coverLetters.map((cl) => {
|
||||
const fb = feedbacks.find((f) => f.generatedClId === cl.id);
|
||||
return { ...cl, outcome: fb?.outcome || null };
|
||||
});
|
||||
|
||||
const contacts = await db
|
||||
.select({
|
||||
id: contact.id,
|
||||
name: contact.name,
|
||||
role: contact.role,
|
||||
email: contact.email,
|
||||
linkedinUrl: contact.linkedinUrl,
|
||||
phone: contact.phone,
|
||||
notes: contact.notes,
|
||||
lastContactDate: contact.lastContactDate,
|
||||
junctionRole: contactApplication.role,
|
||||
})
|
||||
.from(contactApplication)
|
||||
.innerJoin(contact, eq(contactApplication.contactId, contact.id))
|
||||
.where(eq(contactApplication.applicationId, id))
|
||||
.orderBy(desc(contactApplication.createdAt));
|
||||
|
||||
const research = await db
|
||||
.select()
|
||||
.from(companyResearch)
|
||||
.where(eq(companyResearch.companyId, app.companyId))
|
||||
.orderBy(desc(companyResearch.createdAt));
|
||||
|
||||
const relatedApplications = await db
|
||||
.select({
|
||||
id: application.id,
|
||||
jobTitle: application.jobTitle,
|
||||
status: application.status,
|
||||
appliedDate: application.appliedDate,
|
||||
createdAt: application.createdAt,
|
||||
})
|
||||
.from(application)
|
||||
.where(
|
||||
and(eq(application.companyId, app.companyId), ne(application.id, id))
|
||||
)
|
||||
.orderBy(desc(application.createdAt));
|
||||
|
||||
return {
|
||||
...app,
|
||||
events,
|
||||
cvs: cvsWithFeedback,
|
||||
coverLetters: clsWithFeedback,
|
||||
contacts,
|
||||
companyResearch: research,
|
||||
relatedApplications,
|
||||
};
|
||||
}
|
||||
|
||||
export function createApplication(data: {
|
||||
export async function createApplication(data: {
|
||||
companyId: number;
|
||||
jobTitle: string;
|
||||
jobDescription: string;
|
||||
|
|
@ -101,7 +168,7 @@ export function createApplication(data: {
|
|||
salaryRange?: string;
|
||||
notes?: string;
|
||||
}) {
|
||||
const app = db
|
||||
const [app] = await db
|
||||
.insert(application)
|
||||
.values({
|
||||
companyId: data.companyId,
|
||||
|
|
@ -111,44 +178,43 @@ export function createApplication(data: {
|
|||
salaryRange: data.salaryRange || null,
|
||||
notes: data.notes || null,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
|
||||
db.insert(applicationEvent).values({
|
||||
await db.insert(applicationEvent).values({
|
||||
applicationId: app.id,
|
||||
fromStatus: null,
|
||||
toStatus: "draft",
|
||||
notes: "Application created",
|
||||
}).run();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export function updateApplication(
|
||||
export async function updateApplication(
|
||||
id: number,
|
||||
data: Partial<typeof application.$inferInsert>
|
||||
) {
|
||||
return db
|
||||
const [updated] = await db
|
||||
.update(application)
|
||||
.set({ ...data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(application.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function updateApplicationStatus(
|
||||
export async function updateApplicationStatus(
|
||||
id: number,
|
||||
newStatus: string,
|
||||
notes?: string
|
||||
) {
|
||||
const app = db
|
||||
const [app] = await db
|
||||
.select()
|
||||
.from(application)
|
||||
.where(eq(application.id, id))
|
||||
.get();
|
||||
.limit(1);
|
||||
if (!app) return null;
|
||||
|
||||
const updatedApp = db
|
||||
const [updatedApp] = await db
|
||||
.update(application)
|
||||
.set({
|
||||
status: newStatus,
|
||||
|
|
@ -158,24 +224,27 @@ export function updateApplicationStatus(
|
|||
: {}),
|
||||
})
|
||||
.where(eq(application.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
|
||||
db.insert(applicationEvent).values({
|
||||
await db.insert(applicationEvent).values({
|
||||
applicationId: id,
|
||||
fromStatus: app.status,
|
||||
toStatus: newStatus,
|
||||
notes: notes || null,
|
||||
}).run();
|
||||
});
|
||||
|
||||
return updatedApp;
|
||||
}
|
||||
|
||||
export function deleteApplication(id: number) {
|
||||
return db.delete(application).where(eq(application.id, id)).returning().get();
|
||||
export async function deleteApplication(id: number) {
|
||||
const [deleted] = await db
|
||||
.delete(application)
|
||||
.where(eq(application.id, id))
|
||||
.returning();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export function searchApplications(query: string) {
|
||||
export async function searchApplications(query: string) {
|
||||
return db
|
||||
.select({
|
||||
id: application.id,
|
||||
|
|
@ -186,7 +255,6 @@ export function searchApplications(query: string) {
|
|||
.from(application)
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.where(
|
||||
sql`${company.name} LIKE ${"%" + query + "%"} OR ${application.jobTitle} LIKE ${"%" + query + "%"}`
|
||||
)
|
||||
.all();
|
||||
sql`${company.name} ILIKE ${"%" + query + "%"} OR ${application.jobTitle} ILIKE ${"%" + query + "%"}`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { db } from "@/db";
|
||||
import { embeddingChunk } from "@/db/schema";
|
||||
import { eq, and, inArray, count } from "drizzle-orm";
|
||||
import { eq, and, inArray, count, sql } from "drizzle-orm";
|
||||
|
||||
export function insertChunks(
|
||||
export async function insertChunks(
|
||||
chunks: Array<{
|
||||
sourceType: string;
|
||||
sourceId: number;
|
||||
|
|
@ -14,79 +14,97 @@ export function insertChunks(
|
|||
) {
|
||||
if (chunks.length === 0) return;
|
||||
|
||||
const insertStmt = db.insert(embeddingChunk);
|
||||
const values = chunks.map((c) => ({
|
||||
sourceType: c.sourceType,
|
||||
sourceId: c.sourceId,
|
||||
chunkText: c.chunkText,
|
||||
chunkIndex: c.chunkIndex,
|
||||
metadata: c.metadata || null,
|
||||
embedding: JSON.stringify(c.embedding),
|
||||
embedding: c.embedding,
|
||||
}));
|
||||
|
||||
// Insert in batches of 50 to avoid SQLite variable limits
|
||||
for (let i = 0; i < values.length; i += 50) {
|
||||
const batch = values.slice(i, i + 50);
|
||||
db.insert(embeddingChunk).values(batch).run();
|
||||
// Batch to keep individual statements bounded
|
||||
for (let i = 0; i < values.length; i += 100) {
|
||||
const batch = values.slice(i, i + 100);
|
||||
await db.insert(embeddingChunk).values(batch);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteChunksBySource(sourceType: string, sourceId: number) {
|
||||
db.delete(embeddingChunk)
|
||||
export async function deleteChunksBySource(
|
||||
sourceType: string,
|
||||
sourceId: number
|
||||
) {
|
||||
await db
|
||||
.delete(embeddingChunk)
|
||||
.where(
|
||||
and(
|
||||
eq(embeddingChunk.sourceType, sourceType),
|
||||
eq(embeddingChunk.sourceId, sourceId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllChunks(sourceTypes?: string[]) {
|
||||
export async function getAllChunks(sourceTypes?: string[]) {
|
||||
const query = db
|
||||
.select({
|
||||
id: embeddingChunk.id,
|
||||
sourceType: embeddingChunk.sourceType,
|
||||
sourceId: embeddingChunk.sourceId,
|
||||
chunkText: embeddingChunk.chunkText,
|
||||
metadata: embeddingChunk.metadata,
|
||||
embedding: embeddingChunk.embedding,
|
||||
})
|
||||
.from(embeddingChunk);
|
||||
|
||||
const rows = sourceTypes
|
||||
? db
|
||||
.select({
|
||||
id: embeddingChunk.id,
|
||||
sourceType: embeddingChunk.sourceType,
|
||||
sourceId: embeddingChunk.sourceId,
|
||||
chunkText: embeddingChunk.chunkText,
|
||||
metadata: embeddingChunk.metadata,
|
||||
embedding: embeddingChunk.embedding,
|
||||
})
|
||||
.from(embeddingChunk)
|
||||
.where(inArray(embeddingChunk.sourceType, sourceTypes))
|
||||
.all()
|
||||
: db
|
||||
.select({
|
||||
id: embeddingChunk.id,
|
||||
sourceType: embeddingChunk.sourceType,
|
||||
sourceId: embeddingChunk.sourceId,
|
||||
chunkText: embeddingChunk.chunkText,
|
||||
metadata: embeddingChunk.metadata,
|
||||
embedding: embeddingChunk.embedding,
|
||||
})
|
||||
.from(embeddingChunk)
|
||||
.all();
|
||||
? await query.where(inArray(embeddingChunk.sourceType, sourceTypes))
|
||||
: await query;
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
embedding: JSON.parse(r.embedding) as number[],
|
||||
}));
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function getChunkStats() {
|
||||
export async function getChunkStats() {
|
||||
return db
|
||||
.select({
|
||||
sourceType: embeddingChunk.sourceType,
|
||||
count: count(),
|
||||
})
|
||||
.from(embeddingChunk)
|
||||
.groupBy(embeddingChunk.sourceType)
|
||||
.all();
|
||||
.groupBy(embeddingChunk.sourceType);
|
||||
}
|
||||
|
||||
export function getTotalChunkCount() {
|
||||
return (
|
||||
db.select({ count: count() }).from(embeddingChunk).get()?.count || 0
|
||||
);
|
||||
export async function getTotalChunkCount() {
|
||||
const [row] = await db.select({ count: count() }).from(embeddingChunk);
|
||||
return row?.count || 0;
|
||||
}
|
||||
|
||||
// Cosine-similarity search via pgvector's `<=>` operator. Returns top K rows
|
||||
// ordered by similarity, using the HNSW index on embedding_chunk.embedding.
|
||||
// Score is `1 - distance` so 1.0 = identical, 0 = orthogonal (same scale as the
|
||||
// JS cosine the retriever used before).
|
||||
export async function searchSimilarChunks(
|
||||
queryEmbedding: number[],
|
||||
options?: { topK?: number; sourceTypes?: string[] }
|
||||
) {
|
||||
const topK = options?.topK ?? 10;
|
||||
const vec = `[${queryEmbedding.join(",")}]`;
|
||||
|
||||
const distance = sql<number>`${embeddingChunk.embedding} <=> ${vec}::vector`;
|
||||
|
||||
const base = db
|
||||
.select({
|
||||
id: embeddingChunk.id,
|
||||
sourceType: embeddingChunk.sourceType,
|
||||
sourceId: embeddingChunk.sourceId,
|
||||
chunkText: embeddingChunk.chunkText,
|
||||
metadata: embeddingChunk.metadata,
|
||||
score: sql<number>`1 - (${distance})`,
|
||||
})
|
||||
.from(embeddingChunk);
|
||||
|
||||
const filtered = options?.sourceTypes
|
||||
? base.where(inArray(embeddingChunk.sourceType, options.sourceTypes))
|
||||
: base;
|
||||
|
||||
return filtered.orderBy(distance).limit(topK);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,22 +3,26 @@ import {
|
|||
application,
|
||||
applicationEvent,
|
||||
company,
|
||||
generatedCv,
|
||||
generatedCoverLetter,
|
||||
generationFeedback,
|
||||
reminder,
|
||||
} from "@/db/schema";
|
||||
import { eq, desc, sql, count } from "drizzle-orm";
|
||||
import { eq, desc, sql, count, and, inArray } from "drizzle-orm";
|
||||
|
||||
export function getDashboardStats() {
|
||||
const totalApps =
|
||||
db.select({ count: count() }).from(application).get()?.count || 0;
|
||||
export async function getDashboardStats() {
|
||||
const [totalRow] = await db
|
||||
.select({ count: count() })
|
||||
.from(application);
|
||||
const totalApps = totalRow?.count || 0;
|
||||
|
||||
const byStatus = db
|
||||
const byStatus = await db
|
||||
.select({
|
||||
status: application.status,
|
||||
count: count(),
|
||||
})
|
||||
.from(application)
|
||||
.groupBy(application.status)
|
||||
.all();
|
||||
.groupBy(application.status);
|
||||
|
||||
const statusMap: Record<string, number> = {};
|
||||
for (const row of byStatus) {
|
||||
|
|
@ -44,38 +48,10 @@ export function getDashboardStats() {
|
|||
(statusMap["screening"] || 0) +
|
||||
(statusMap["interview"] || 0);
|
||||
|
||||
// Applications over time (by week, last 12 weeks)
|
||||
const byWeek = db
|
||||
.select({
|
||||
week: sql<string>`strftime('%Y-%W', ${application.createdAt})`,
|
||||
weekStart: sql<string>`date(${application.createdAt}, 'weekday 0', '-6 days')`,
|
||||
count: count(),
|
||||
})
|
||||
.from(application)
|
||||
.where(
|
||||
sql`${application.createdAt} >= date('now', '-12 weeks')`
|
||||
)
|
||||
.groupBy(sql`strftime('%Y-%W', ${application.createdAt})`)
|
||||
.orderBy(sql`strftime('%Y-%W', ${application.createdAt})`)
|
||||
.all();
|
||||
|
||||
// Top companies
|
||||
const topCompanies = db
|
||||
.select({
|
||||
name: company.name,
|
||||
count: count(),
|
||||
})
|
||||
.from(application)
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.groupBy(company.id)
|
||||
.orderBy(desc(count()))
|
||||
.limit(5)
|
||||
.all();
|
||||
|
||||
// Recent events
|
||||
const recentEvents = db
|
||||
const recentEvents = await db
|
||||
.select({
|
||||
id: applicationEvent.id,
|
||||
applicationId: applicationEvent.applicationId,
|
||||
toStatus: applicationEvent.toStatus,
|
||||
fromStatus: applicationEvent.fromStatus,
|
||||
createdAt: applicationEvent.createdAt,
|
||||
|
|
@ -86,10 +62,8 @@ export function getDashboardStats() {
|
|||
.innerJoin(application, eq(applicationEvent.applicationId, application.id))
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.orderBy(desc(applicationEvent.createdAt))
|
||||
.limit(10)
|
||||
.all();
|
||||
.limit(10);
|
||||
|
||||
// Funnel: count how many applications ever reached each stage
|
||||
const funnelStages = [
|
||||
"applied",
|
||||
"screening",
|
||||
|
|
@ -98,44 +72,256 @@ export function getDashboardStats() {
|
|||
"accepted",
|
||||
] as const;
|
||||
|
||||
const funnelData = funnelStages.map((stage) => {
|
||||
const reachedCount = db
|
||||
.select({
|
||||
count: sql<number>`count(distinct ${applicationEvent.applicationId})`,
|
||||
})
|
||||
.from(applicationEvent)
|
||||
.where(eq(applicationEvent.toStatus, stage))
|
||||
.get();
|
||||
return { stage, count: reachedCount?.count || 0 };
|
||||
});
|
||||
|
||||
// Daily activity heatmap (last 12 weeks)
|
||||
const dailyActivity = db
|
||||
.select({
|
||||
date: sql<string>`date(${application.createdAt})`,
|
||||
count: count(),
|
||||
const funnelData = await Promise.all(
|
||||
funnelStages.map(async (stage) => {
|
||||
const [reachedCount] = await db
|
||||
.select({
|
||||
count: sql<number>`count(distinct ${applicationEvent.applicationId})::int`,
|
||||
})
|
||||
.from(applicationEvent)
|
||||
.where(eq(applicationEvent.toStatus, stage));
|
||||
return { stage, count: reachedCount?.count || 0 };
|
||||
})
|
||||
.from(application)
|
||||
.where(sql`${application.createdAt} >= date('now', '-84 days')`)
|
||||
.groupBy(sql`date(${application.createdAt})`)
|
||||
.orderBy(sql`date(${application.createdAt})`)
|
||||
.all();
|
||||
);
|
||||
|
||||
// Average response time (days between appliedDate and responseDate)
|
||||
const avgResponse = db
|
||||
// Average response time (days)
|
||||
const [avgResponse] = await db
|
||||
.select({
|
||||
avg: sql<number>`avg(julianday(${application.responseDate}) - julianday(${application.appliedDate}))`,
|
||||
avg: sql<number>`avg(extract(epoch from (${application.responseDate}::timestamp - ${application.appliedDate}::timestamp)) / 86400)`,
|
||||
})
|
||||
.from(application)
|
||||
.where(
|
||||
sql`${application.responseDate} is not null and ${application.appliedDate} is not null`
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
const avgResponseDays = avgResponse?.avg
|
||||
? Math.round(avgResponse.avg)
|
||||
? Math.round(Number(avgResponse.avg))
|
||||
: null;
|
||||
|
||||
// Stale applications (applied > 7 days ago, no status change since)
|
||||
const staleApplications = await db
|
||||
.select({
|
||||
id: application.id,
|
||||
jobTitle: application.jobTitle,
|
||||
companyName: company.name,
|
||||
daysSinceApplied: sql<number>`extract(day from (now() - ${application.appliedDate}::timestamp))::int`,
|
||||
})
|
||||
.from(application)
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.where(
|
||||
and(
|
||||
eq(application.status, "applied"),
|
||||
sql`extract(epoch from (now() - ${application.appliedDate}::timestamp)) / 86400 > 7`
|
||||
)
|
||||
)
|
||||
.orderBy(sql`${application.appliedDate}::date`);
|
||||
|
||||
const interviewPrep = await db
|
||||
.select({
|
||||
id: application.id,
|
||||
jobTitle: application.jobTitle,
|
||||
companyName: company.name,
|
||||
})
|
||||
.from(application)
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.where(inArray(application.status, ["interview", "screening"]));
|
||||
|
||||
const drafts = await db
|
||||
.select({
|
||||
id: application.id,
|
||||
jobTitle: application.jobTitle,
|
||||
companyName: company.name,
|
||||
})
|
||||
.from(application)
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.where(eq(application.status, "draft"));
|
||||
|
||||
const pendingFeedback = await db
|
||||
.select({
|
||||
id: application.id,
|
||||
jobTitle: application.jobTitle,
|
||||
companyName: company.name,
|
||||
})
|
||||
.from(application)
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(application.status, ["rejected", "ghosted"]),
|
||||
sql`${application.id} not in (select application_id from generation_feedback)`
|
||||
)
|
||||
);
|
||||
|
||||
const unapprovedCvsRaw = await db
|
||||
.select({
|
||||
id: generatedCv.id,
|
||||
applicationId: generatedCv.applicationId,
|
||||
version: generatedCv.version,
|
||||
jobTitle: application.jobTitle,
|
||||
companyName: company.name,
|
||||
})
|
||||
.from(generatedCv)
|
||||
.innerJoin(application, eq(generatedCv.applicationId, application.id))
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.where(eq(generatedCv.isApproved, false));
|
||||
const unapprovedCvs = unapprovedCvsRaw.map((r) => ({
|
||||
...r,
|
||||
type: "cv" as const,
|
||||
}));
|
||||
|
||||
const unapprovedClsRaw = await db
|
||||
.select({
|
||||
id: generatedCoverLetter.id,
|
||||
applicationId: generatedCoverLetter.applicationId,
|
||||
version: generatedCoverLetter.version,
|
||||
jobTitle: application.jobTitle,
|
||||
companyName: company.name,
|
||||
})
|
||||
.from(generatedCoverLetter)
|
||||
.innerJoin(
|
||||
application,
|
||||
eq(generatedCoverLetter.applicationId, application.id)
|
||||
)
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.where(eq(generatedCoverLetter.isApproved, false));
|
||||
const unapprovedCls = unapprovedClsRaw.map((r) => ({
|
||||
...r,
|
||||
type: "cl" as const,
|
||||
}));
|
||||
|
||||
const dueReminders = await db
|
||||
.select({
|
||||
id: reminder.id,
|
||||
applicationId: reminder.applicationId,
|
||||
type: reminder.type,
|
||||
title: reminder.title,
|
||||
dueDate: reminder.dueDate,
|
||||
jobTitle: application.jobTitle,
|
||||
companyName: company.name,
|
||||
})
|
||||
.from(reminder)
|
||||
.leftJoin(application, eq(reminder.applicationId, application.id))
|
||||
.leftJoin(company, eq(application.companyId, company.id))
|
||||
.where(
|
||||
and(
|
||||
eq(reminder.isCompleted, false),
|
||||
sql`${reminder.dueDate}::date <= current_date`
|
||||
)
|
||||
)
|
||||
.orderBy(reminder.dueDate);
|
||||
|
||||
const actionItems = {
|
||||
staleApplications,
|
||||
interviewPrep,
|
||||
drafts,
|
||||
pendingFeedback,
|
||||
unapprovedDocs: [...unapprovedCvs, ...unapprovedCls],
|
||||
dueReminders,
|
||||
};
|
||||
|
||||
const allApps = await db
|
||||
.select({
|
||||
jobUrl: application.jobUrl,
|
||||
status: application.status,
|
||||
})
|
||||
.from(application)
|
||||
.where(
|
||||
sql`${application.jobUrl} is not null and ${application.jobUrl} != ''`
|
||||
);
|
||||
|
||||
const domainMap = new Map<string, { total: number; responded: number }>();
|
||||
for (const app of allApps) {
|
||||
let domain = "Direct";
|
||||
try {
|
||||
domain = new URL(app.jobUrl!).hostname.replace("www.", "");
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
const entry = domainMap.get(domain) || { total: 0, responded: 0 };
|
||||
entry.total++;
|
||||
if (!["applied", "draft", "ghosted"].includes(app.status)) {
|
||||
entry.responded++;
|
||||
}
|
||||
domainMap.set(domain, entry);
|
||||
}
|
||||
|
||||
const responseRates = [...domainMap.entries()]
|
||||
.map(([domain, data]) => ({
|
||||
domain,
|
||||
total: data.total,
|
||||
responded: data.responded,
|
||||
rate:
|
||||
data.total > 0 ? Math.round((data.responded / data.total) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 8);
|
||||
|
||||
const activeApps = await db
|
||||
.select({
|
||||
id: application.id,
|
||||
jobTitle: application.jobTitle,
|
||||
status: application.status,
|
||||
companyName: company.name,
|
||||
})
|
||||
.from(application)
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.where(
|
||||
inArray(application.status, [
|
||||
"applied",
|
||||
"screening",
|
||||
"interview",
|
||||
"offer",
|
||||
])
|
||||
);
|
||||
|
||||
const timeInStage = (
|
||||
await Promise.all(
|
||||
activeApps.map(async (app) => {
|
||||
const [lastEvent] = await db
|
||||
.select({ createdAt: applicationEvent.createdAt })
|
||||
.from(applicationEvent)
|
||||
.where(eq(applicationEvent.applicationId, app.id))
|
||||
.orderBy(desc(applicationEvent.createdAt))
|
||||
.limit(1);
|
||||
|
||||
const lastEventDate = lastEvent?.createdAt || new Date().toISOString();
|
||||
const days = Math.floor(
|
||||
(Date.now() - new Date(lastEventDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
return { ...app, daysInStage: days };
|
||||
})
|
||||
)
|
||||
).sort((a, b) => b.daysInStage - a.daysInStage);
|
||||
|
||||
const [thisWeekApps] = await db
|
||||
.select({ count: count() })
|
||||
.from(application)
|
||||
.where(
|
||||
sql`${application.createdAt}::date >= date_trunc('week', now())::date`
|
||||
);
|
||||
const weeklyVelocity = thisWeekApps?.count || 0;
|
||||
|
||||
const cvPerformance = await db
|
||||
.select({
|
||||
cvId: generatedCv.id,
|
||||
version: generatedCv.version,
|
||||
applicationId: generatedCv.applicationId,
|
||||
jobTitle: application.jobTitle,
|
||||
companyName: company.name,
|
||||
outcome: generationFeedback.outcome,
|
||||
qualityRating: generationFeedback.qualityRating,
|
||||
})
|
||||
.from(generatedCv)
|
||||
.innerJoin(application, eq(generatedCv.applicationId, application.id))
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.leftJoin(
|
||||
generationFeedback,
|
||||
eq(generationFeedback.generatedCvId, generatedCv.id)
|
||||
)
|
||||
.orderBy(desc(generatedCv.createdAt))
|
||||
.limit(10);
|
||||
|
||||
return {
|
||||
totalApps,
|
||||
activeCount,
|
||||
|
|
@ -143,10 +329,12 @@ export function getDashboardStats() {
|
|||
offerRate,
|
||||
avgResponseDays,
|
||||
statusBreakdown: byStatus,
|
||||
byWeek,
|
||||
topCompanies,
|
||||
recentEvents,
|
||||
funnelData,
|
||||
dailyActivity,
|
||||
actionItems,
|
||||
responseRates,
|
||||
timeInStage,
|
||||
weeklyVelocity,
|
||||
cvPerformance,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { db } from "@/db";
|
||||
import { generationFeedback, generatedCv, application, company } from "@/db/schema";
|
||||
import {
|
||||
generationFeedback,
|
||||
generatedCv,
|
||||
application,
|
||||
} from "@/db/schema";
|
||||
import { eq, desc, inArray, isNotNull, and } from "drizzle-orm";
|
||||
|
||||
export function getRelevantFeedback() {
|
||||
// Get successful CVs (interview or offer outcomes)
|
||||
const successful = db
|
||||
export async function getRelevantFeedback() {
|
||||
const successfulRaw = await db
|
||||
.select({
|
||||
contentMarkdown: generatedCv.contentMarkdown,
|
||||
outcome: generationFeedback.outcome,
|
||||
|
|
@ -12,21 +15,26 @@ export function getRelevantFeedback() {
|
|||
jobTitle: application.jobTitle,
|
||||
})
|
||||
.from(generationFeedback)
|
||||
.innerJoin(generatedCv, eq(generationFeedback.generatedCvId, generatedCv.id))
|
||||
.innerJoin(application, eq(generationFeedback.applicationId, application.id))
|
||||
.innerJoin(
|
||||
generatedCv,
|
||||
eq(generationFeedback.generatedCvId, generatedCv.id)
|
||||
)
|
||||
.innerJoin(
|
||||
application,
|
||||
eq(generationFeedback.applicationId, application.id)
|
||||
)
|
||||
.where(inArray(generationFeedback.outcome, ["interview", "offer"]))
|
||||
.orderBy(desc(generationFeedback.qualityRating))
|
||||
.limit(5)
|
||||
.all()
|
||||
.map((r) => ({
|
||||
contentMarkdown: r.contentMarkdown,
|
||||
outcome: r.outcome || "interview",
|
||||
qualityRating: r.qualityRating || 3,
|
||||
jobTitle: r.jobTitle,
|
||||
}));
|
||||
.limit(5);
|
||||
|
||||
// Get negative feedback notes
|
||||
const negativePatterns = db
|
||||
const successful = successfulRaw.map((r) => ({
|
||||
contentMarkdown: r.contentMarkdown,
|
||||
outcome: r.outcome || "interview",
|
||||
qualityRating: r.qualityRating || 3,
|
||||
jobTitle: r.jobTitle,
|
||||
}));
|
||||
|
||||
const negativeRaw = await db
|
||||
.select({
|
||||
notes: generationFeedback.feedbackNotes,
|
||||
})
|
||||
|
|
@ -38,15 +46,16 @@ export function getRelevantFeedback() {
|
|||
)
|
||||
)
|
||||
.orderBy(desc(generationFeedback.createdAt))
|
||||
.limit(3)
|
||||
.all()
|
||||
.limit(3);
|
||||
|
||||
const negativePatterns = negativeRaw
|
||||
.map((r) => r.notes)
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
return { successful, negativePatterns };
|
||||
}
|
||||
|
||||
export function createFeedback(data: {
|
||||
export async function createFeedback(data: {
|
||||
generatedCvId?: number;
|
||||
generatedClId?: number;
|
||||
applicationId: number;
|
||||
|
|
@ -54,7 +63,7 @@ export function createFeedback(data: {
|
|||
qualityRating?: number;
|
||||
feedbackNotes?: string;
|
||||
}) {
|
||||
return db
|
||||
const [created] = await db
|
||||
.insert(generationFeedback)
|
||||
.values({
|
||||
generatedCvId: data.generatedCvId || null,
|
||||
|
|
@ -64,40 +73,38 @@ export function createFeedback(data: {
|
|||
qualityRating: data.qualityRating || null,
|
||||
feedbackNotes: data.feedbackNotes || null,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export function updateFeedback(
|
||||
export async function updateFeedback(
|
||||
id: number,
|
||||
data: { outcome?: string; qualityRating?: number; feedbackNotes?: string }
|
||||
) {
|
||||
// Get the applicationId from this feedback entry
|
||||
const entry = db
|
||||
const [entry] = await db
|
||||
.select({ applicationId: generationFeedback.applicationId })
|
||||
.from(generationFeedback)
|
||||
.where(eq(generationFeedback.id, id))
|
||||
.get();
|
||||
.limit(1);
|
||||
|
||||
// Update all feedback entries for the same application
|
||||
if (entry) {
|
||||
db.update(generationFeedback)
|
||||
await db
|
||||
.update(generationFeedback)
|
||||
.set(data)
|
||||
.where(eq(generationFeedback.applicationId, entry.applicationId))
|
||||
.run();
|
||||
.where(eq(generationFeedback.applicationId, entry.applicationId));
|
||||
}
|
||||
|
||||
return db
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(generationFeedback)
|
||||
.where(eq(generationFeedback.id, id))
|
||||
.get();
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getFeedbackByApplicationId(applicationId: number) {
|
||||
export async function getFeedbackByApplicationId(applicationId: number) {
|
||||
return db
|
||||
.select()
|
||||
.from(generationFeedback)
|
||||
.where(eq(generationFeedback.applicationId, applicationId))
|
||||
.all();
|
||||
.where(eq(generationFeedback.applicationId, applicationId));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,22 @@
|
|||
import { db } from "@/db";
|
||||
import { generatedCv, generatedCoverLetter } from "@/db/schema";
|
||||
import { eq, desc, and, sql } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
export function getLatestCvVersion(applicationId: number): number {
|
||||
const result = db
|
||||
export async function getLatestCvVersion(applicationId: number): Promise<number> {
|
||||
const [result] = await db
|
||||
.select({ maxVersion: sql<number>`MAX(${generatedCv.version})` })
|
||||
.from(generatedCv)
|
||||
.where(eq(generatedCv.applicationId, applicationId))
|
||||
.get();
|
||||
return result?.maxVersion || 0;
|
||||
.where(eq(generatedCv.applicationId, applicationId));
|
||||
return Number(result?.maxVersion) || 0;
|
||||
}
|
||||
|
||||
export function createGeneratedCv(data: {
|
||||
export async function createGeneratedCv(data: {
|
||||
applicationId: number;
|
||||
contentMarkdown: string;
|
||||
promptUsed: string;
|
||||
}) {
|
||||
const nextVersion = getLatestCvVersion(data.applicationId) + 1;
|
||||
return db
|
||||
const nextVersion = (await getLatestCvVersion(data.applicationId)) + 1;
|
||||
const [created] = await db
|
||||
.insert(generatedCv)
|
||||
.values({
|
||||
applicationId: data.applicationId,
|
||||
|
|
@ -25,49 +24,52 @@ export function createGeneratedCv(data: {
|
|||
contentMarkdown: data.contentMarkdown,
|
||||
promptUsed: data.promptUsed,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export function getGeneratedCv(id: number) {
|
||||
return db.select().from(generatedCv).where(eq(generatedCv.id, id)).get();
|
||||
export async function getGeneratedCv(id: number) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(generatedCv)
|
||||
.where(eq(generatedCv.id, id))
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function approveGeneratedCv(id: number) {
|
||||
return db
|
||||
export async function approveGeneratedCv(id: number) {
|
||||
const [updated] = await db
|
||||
.update(generatedCv)
|
||||
.set({ isApproved: true })
|
||||
.where(eq(generatedCv.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function updateCvContent(id: number, content: string) {
|
||||
return db
|
||||
export async function updateCvContent(id: number, content: string) {
|
||||
const [updated] = await db
|
||||
.update(generatedCv)
|
||||
.set({ contentMarkdown: content })
|
||||
.where(eq(generatedCv.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Cover Letters
|
||||
export function getLatestClVersion(applicationId: number): number {
|
||||
const result = db
|
||||
export async function getLatestClVersion(applicationId: number): Promise<number> {
|
||||
const [result] = await db
|
||||
.select({ maxVersion: sql<number>`MAX(${generatedCoverLetter.version})` })
|
||||
.from(generatedCoverLetter)
|
||||
.where(eq(generatedCoverLetter.applicationId, applicationId))
|
||||
.get();
|
||||
return result?.maxVersion || 0;
|
||||
.where(eq(generatedCoverLetter.applicationId, applicationId));
|
||||
return Number(result?.maxVersion) || 0;
|
||||
}
|
||||
|
||||
export function createGeneratedCl(data: {
|
||||
export async function createGeneratedCl(data: {
|
||||
applicationId: number;
|
||||
contentMarkdown: string;
|
||||
promptUsed: string;
|
||||
}) {
|
||||
const nextVersion = getLatestClVersion(data.applicationId) + 1;
|
||||
return db
|
||||
const nextVersion = (await getLatestClVersion(data.applicationId)) + 1;
|
||||
const [created] = await db
|
||||
.insert(generatedCoverLetter)
|
||||
.values({
|
||||
applicationId: data.applicationId,
|
||||
|
|
@ -75,28 +77,33 @@ export function createGeneratedCl(data: {
|
|||
contentMarkdown: data.contentMarkdown,
|
||||
promptUsed: data.promptUsed,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export function getGeneratedCl(id: number) {
|
||||
return db.select().from(generatedCoverLetter).where(eq(generatedCoverLetter.id, id)).get();
|
||||
export async function getGeneratedCl(id: number) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(generatedCoverLetter)
|
||||
.where(eq(generatedCoverLetter.id, id))
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function approveCl(id: number) {
|
||||
return db
|
||||
export async function approveCl(id: number) {
|
||||
const [updated] = await db
|
||||
.update(generatedCoverLetter)
|
||||
.set({ isApproved: true })
|
||||
.where(eq(generatedCoverLetter.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function updateClContent(id: number, content: string) {
|
||||
return db
|
||||
export async function updateClContent(id: number, content: string) {
|
||||
const [updated] = await db
|
||||
.update(generatedCoverLetter)
|
||||
.set({ contentMarkdown: content })
|
||||
.where(eq(generatedCoverLetter.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,137 +10,224 @@ import {
|
|||
} from "@/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
|
||||
export function getProfile() {
|
||||
return db.select().from(profile).where(eq(profile.id, 1)).get();
|
||||
export async function getProfile() {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(profile)
|
||||
.where(eq(profile.id, 1))
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function upsertProfile(data: typeof profile.$inferInsert) {
|
||||
const existing = getProfile();
|
||||
export async function upsertProfile(data: typeof profile.$inferInsert) {
|
||||
const existing = await getProfile();
|
||||
if (existing) {
|
||||
return db
|
||||
const [updated] = await db
|
||||
.update(profile)
|
||||
.set({ ...data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(profile.id, 1))
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
return db
|
||||
const [created] = await db
|
||||
.insert(profile)
|
||||
.values({ ...data, id: 1 })
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export function getEducation() {
|
||||
return db.select().from(education).orderBy(asc(education.sortOrder)).all();
|
||||
export async function getEducation() {
|
||||
return db.select().from(education).orderBy(asc(education.sortOrder));
|
||||
}
|
||||
|
||||
export function addEducation(data: typeof education.$inferInsert) {
|
||||
return db.insert(education).values(data).returning().get();
|
||||
export async function addEducation(data: typeof education.$inferInsert) {
|
||||
const [created] = await db.insert(education).values(data).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export function updateEducation(id: number, data: Partial<typeof education.$inferInsert>) {
|
||||
return db
|
||||
export async function updateEducation(
|
||||
id: number,
|
||||
data: Partial<typeof education.$inferInsert>
|
||||
) {
|
||||
const [updated] = await db
|
||||
.update(education)
|
||||
.set({ ...data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(education.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteEducation(id: number) {
|
||||
return db.delete(education).where(eq(education.id, id)).returning().get();
|
||||
export async function deleteEducation(id: number) {
|
||||
const [deleted] = await db
|
||||
.delete(education)
|
||||
.where(eq(education.id, id))
|
||||
.returning();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export function getExperience() {
|
||||
return db.select().from(experience).orderBy(asc(experience.sortOrder)).all();
|
||||
export async function getExperience() {
|
||||
return db.select().from(experience).orderBy(asc(experience.sortOrder));
|
||||
}
|
||||
|
||||
export function addExperience(data: typeof experience.$inferInsert) {
|
||||
return db.insert(experience).values(data).returning().get();
|
||||
export async function addExperience(data: typeof experience.$inferInsert) {
|
||||
const [created] = await db.insert(experience).values(data).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export function updateExperience(id: number, data: Partial<typeof experience.$inferInsert>) {
|
||||
return db
|
||||
export async function updateExperience(
|
||||
id: number,
|
||||
data: Partial<typeof experience.$inferInsert>
|
||||
) {
|
||||
const [updated] = await db
|
||||
.update(experience)
|
||||
.set({ ...data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(experience.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteExperience(id: number) {
|
||||
return db.delete(experience).where(eq(experience.id, id)).returning().get();
|
||||
export async function deleteExperience(id: number) {
|
||||
const [deleted] = await db
|
||||
.delete(experience)
|
||||
.where(eq(experience.id, id))
|
||||
.returning();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export function getSkills() {
|
||||
return db.select().from(skill).orderBy(asc(skill.sortOrder)).all();
|
||||
export async function getSkills() {
|
||||
return db.select().from(skill).orderBy(asc(skill.sortOrder));
|
||||
}
|
||||
|
||||
export function addSkill(data: typeof skill.$inferInsert) {
|
||||
return db.insert(skill).values(data).returning().get();
|
||||
export async function addSkill(data: typeof skill.$inferInsert) {
|
||||
const [created] = await db.insert(skill).values(data).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export function updateSkill(id: number, data: Partial<typeof skill.$inferInsert>) {
|
||||
return db.update(skill).set(data).where(eq(skill.id, id)).returning().get();
|
||||
export async function updateSkill(
|
||||
id: number,
|
||||
data: Partial<typeof skill.$inferInsert>
|
||||
) {
|
||||
const [updated] = await db
|
||||
.update(skill)
|
||||
.set(data)
|
||||
.where(eq(skill.id, id))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteSkill(id: number) {
|
||||
return db.delete(skill).where(eq(skill.id, id)).returning().get();
|
||||
export async function deleteSkill(id: number) {
|
||||
const [deleted] = await db
|
||||
.delete(skill)
|
||||
.where(eq(skill.id, id))
|
||||
.returning();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export function getCertifications() {
|
||||
return db.select().from(certification).orderBy(asc(certification.sortOrder)).all();
|
||||
export async function getCertifications() {
|
||||
return db
|
||||
.select()
|
||||
.from(certification)
|
||||
.orderBy(asc(certification.sortOrder));
|
||||
}
|
||||
|
||||
export function addCertification(data: typeof certification.$inferInsert) {
|
||||
return db.insert(certification).values(data).returning().get();
|
||||
export async function addCertification(
|
||||
data: typeof certification.$inferInsert
|
||||
) {
|
||||
const [created] = await db.insert(certification).values(data).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export function updateCertification(id: number, data: Partial<typeof certification.$inferInsert>) {
|
||||
return db.update(certification).set(data).where(eq(certification.id, id)).returning().get();
|
||||
export async function updateCertification(
|
||||
id: number,
|
||||
data: Partial<typeof certification.$inferInsert>
|
||||
) {
|
||||
const [updated] = await db
|
||||
.update(certification)
|
||||
.set(data)
|
||||
.where(eq(certification.id, id))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteCertification(id: number) {
|
||||
return db.delete(certification).where(eq(certification.id, id)).returning().get();
|
||||
export async function deleteCertification(id: number) {
|
||||
const [deleted] = await db
|
||||
.delete(certification)
|
||||
.where(eq(certification.id, id))
|
||||
.returning();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export function getLanguages() {
|
||||
return db.select().from(language).all();
|
||||
export async function getLanguages() {
|
||||
return db.select().from(language);
|
||||
}
|
||||
|
||||
export function addLanguage(data: typeof language.$inferInsert) {
|
||||
return db.insert(language).values(data).returning().get();
|
||||
export async function addLanguage(data: typeof language.$inferInsert) {
|
||||
const [created] = await db.insert(language).values(data).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export function updateLanguage(id: number, data: Partial<typeof language.$inferInsert>) {
|
||||
return db.update(language).set(data).where(eq(language.id, id)).returning().get();
|
||||
export async function updateLanguage(
|
||||
id: number,
|
||||
data: Partial<typeof language.$inferInsert>
|
||||
) {
|
||||
const [updated] = await db
|
||||
.update(language)
|
||||
.set(data)
|
||||
.where(eq(language.id, id))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteLanguage(id: number) {
|
||||
return db.delete(language).where(eq(language.id, id)).returning().get();
|
||||
export async function deleteLanguage(id: number) {
|
||||
const [deleted] = await db
|
||||
.delete(language)
|
||||
.where(eq(language.id, id))
|
||||
.returning();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export function getDocuments() {
|
||||
return db.select().from(document).all();
|
||||
export async function getDocuments() {
|
||||
return db.select().from(document);
|
||||
}
|
||||
|
||||
export function addDocument(data: typeof document.$inferInsert) {
|
||||
return db.insert(document).values(data).returning().get();
|
||||
export async function addDocument(data: typeof document.$inferInsert) {
|
||||
const [created] = await db.insert(document).values(data).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export function deleteDocument(id: number) {
|
||||
return db.delete(document).where(eq(document.id, id)).returning().get();
|
||||
export async function deleteDocument(id: number) {
|
||||
const [deleted] = await db
|
||||
.delete(document)
|
||||
.where(eq(document.id, id))
|
||||
.returning();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export function getFullProfile() {
|
||||
export async function getFullProfile() {
|
||||
const [
|
||||
profileRow,
|
||||
educationRows,
|
||||
experienceRows,
|
||||
skillRows,
|
||||
certRows,
|
||||
langRows,
|
||||
docRows,
|
||||
] = await Promise.all([
|
||||
getProfile(),
|
||||
getEducation(),
|
||||
getExperience(),
|
||||
getSkills(),
|
||||
getCertifications(),
|
||||
getLanguages(),
|
||||
getDocuments(),
|
||||
]);
|
||||
return {
|
||||
profile: getProfile(),
|
||||
education: getEducation(),
|
||||
experience: getExperience(),
|
||||
skills: getSkills(),
|
||||
certifications: getCertifications(),
|
||||
languages: getLanguages(),
|
||||
documents: getDocuments(),
|
||||
profile: profileRow,
|
||||
education: educationRows,
|
||||
experience: experienceRows,
|
||||
skills: skillRows,
|
||||
certifications: certRows,
|
||||
languages: langRows,
|
||||
documents: docRows,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
259
src/db/schema.ts
259
src/db/schema.ts
|
|
@ -1,17 +1,27 @@
|
|||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
integer,
|
||||
boolean,
|
||||
jsonb,
|
||||
serial,
|
||||
index,
|
||||
primaryKey,
|
||||
vector,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// Stored as text to match SQLite's datetime('now') format so existing code
|
||||
// that compares dates as strings keeps working. Migrate to timestamp() later.
|
||||
const nowText = sql`to_char(now(), 'YYYY-MM-DD HH24:MI:SS')`;
|
||||
|
||||
const timestamps = {
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
createdAt: text("created_at").notNull().default(nowText),
|
||||
updatedAt: text("updated_at").notNull().default(nowText),
|
||||
};
|
||||
|
||||
// Single-user profile (id always 1)
|
||||
export const profile = sqliteTable("profile", {
|
||||
export const profile = pgTable("profile", {
|
||||
id: integer("id").primaryKey().default(1),
|
||||
fullName: text("full_name").notNull(),
|
||||
email: text("email"),
|
||||
|
|
@ -24,8 +34,8 @@ export const profile = sqliteTable("profile", {
|
|||
...timestamps,
|
||||
});
|
||||
|
||||
export const education = sqliteTable("education", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
export const education = pgTable("education", {
|
||||
id: serial("id").primaryKey(),
|
||||
institution: text("institution").notNull(),
|
||||
degree: text("degree").notNull(),
|
||||
fieldOfStudy: text("field_of_study"),
|
||||
|
|
@ -36,30 +46,30 @@ export const education = sqliteTable("education", {
|
|||
...timestamps,
|
||||
});
|
||||
|
||||
export const experience = sqliteTable("experience", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
export const experience = pgTable("experience", {
|
||||
id: serial("id").primaryKey(),
|
||||
company: text("company").notNull(),
|
||||
title: text("title").notNull(),
|
||||
location: text("location"),
|
||||
startDate: text("start_date").notNull(),
|
||||
endDate: text("end_date"),
|
||||
isCurrent: integer("is_current", { mode: "boolean" }).notNull().default(false),
|
||||
isCurrent: boolean("is_current").notNull().default(false),
|
||||
description: text("description"),
|
||||
achievements: text("achievements", { mode: "json" }).$type<string[]>(),
|
||||
achievements: jsonb("achievements").$type<string[]>(),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const skill = sqliteTable("skill", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
export const skill = pgTable("skill", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull().unique(),
|
||||
category: text("category"),
|
||||
proficiency: text("proficiency"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
});
|
||||
|
||||
export const certification = sqliteTable("certification", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
export const certification = pgTable("certification", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
issuer: text("issuer").notNull(),
|
||||
dateObtained: text("date_obtained"),
|
||||
|
|
@ -68,44 +78,40 @@ export const certification = sqliteTable("certification", {
|
|||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
});
|
||||
|
||||
export const language = sqliteTable("language", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
export const language = pgTable("language", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull().unique(),
|
||||
proficiency: text("proficiency").notNull(),
|
||||
});
|
||||
|
||||
export const document = sqliteTable("document", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
export const document = pgTable("document", {
|
||||
id: serial("id").primaryKey(),
|
||||
filename: text("filename").notNull(),
|
||||
originalName: text("original_name").notNull(),
|
||||
mimeType: text("mime_type").notNull(),
|
||||
filePath: text("file_path").notNull(),
|
||||
parsedText: text("parsed_text"),
|
||||
docType: text("doc_type"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
createdAt: text("created_at").notNull().default(nowText),
|
||||
});
|
||||
|
||||
export const company = sqliteTable(
|
||||
export const company = pgTable(
|
||||
"company",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
website: text("website"),
|
||||
industry: text("industry"),
|
||||
notes: text("notes"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
createdAt: text("created_at").notNull().default(nowText),
|
||||
},
|
||||
(table) => [index("idx_company_name").on(table.name)]
|
||||
);
|
||||
|
||||
export const application = sqliteTable(
|
||||
export const application = pgTable(
|
||||
"application",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
id: serial("id").primaryKey(),
|
||||
companyId: integer("company_id")
|
||||
.notNull()
|
||||
.references(() => company.id),
|
||||
|
|
@ -117,6 +123,7 @@ export const application = sqliteTable(
|
|||
appliedDate: text("applied_date"),
|
||||
responseDate: text("response_date"),
|
||||
notes: text("notes"),
|
||||
interviewPrep: text("interview_prep"),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
|
|
@ -126,49 +133,58 @@ export const application = sqliteTable(
|
|||
]
|
||||
);
|
||||
|
||||
export const applicationEvent = sqliteTable(
|
||||
// Unified activity feed for an application: status changes, interview stages,
|
||||
// free-form notes, and contact interactions all live here. Filter by eventType.
|
||||
export const applicationEvent = pgTable(
|
||||
"application_event",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
id: serial("id").primaryKey(),
|
||||
applicationId: integer("application_id")
|
||||
.notNull()
|
||||
.references(() => application.id, { onDelete: "cascade" }),
|
||||
fromStatus: text("from_status"),
|
||||
toStatus: text("to_status").notNull(),
|
||||
eventType: text("event_type").notNull().default("status_change"),
|
||||
// 'status_change' | 'stage' | 'note' | 'contact_interaction'
|
||||
title: text("title"),
|
||||
notes: text("notes"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
fromStatus: text("from_status"),
|
||||
toStatus: text("to_status"),
|
||||
contactId: integer("contact_id").references(() => contact.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
scheduledAt: text("scheduled_at"),
|
||||
completedAt: text("completed_at"),
|
||||
outcome: text("outcome"), // 'passed' | 'rejected' | 'pending' (stage events)
|
||||
createdAt: text("created_at").notNull().default(nowText),
|
||||
},
|
||||
(table) => [index("idx_event_application").on(table.applicationId)]
|
||||
(table) => [
|
||||
index("idx_event_application").on(table.applicationId),
|
||||
index("idx_event_type").on(table.eventType),
|
||||
index("idx_event_scheduled").on(table.scheduledAt),
|
||||
]
|
||||
);
|
||||
|
||||
export const generatedCv = sqliteTable(
|
||||
export const generatedCv = pgTable(
|
||||
"generated_cv",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
id: serial("id").primaryKey(),
|
||||
applicationId: integer("application_id")
|
||||
.notNull()
|
||||
.references(() => application.id, { onDelete: "cascade" }),
|
||||
version: integer("version").notNull().default(1),
|
||||
contentMarkdown: text("content_markdown").notNull(),
|
||||
contentStructured: text("content_structured", { mode: "json" }),
|
||||
contentStructured: jsonb("content_structured"),
|
||||
pdfPath: text("pdf_path"),
|
||||
promptUsed: text("prompt_used"),
|
||||
isApproved: integer("is_approved", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
isApproved: boolean("is_approved").notNull().default(false),
|
||||
createdAt: text("created_at").notNull().default(nowText),
|
||||
},
|
||||
(table) => [index("idx_cv_application").on(table.applicationId)]
|
||||
);
|
||||
|
||||
export const generatedCoverLetter = sqliteTable(
|
||||
export const generatedCoverLetter = pgTable(
|
||||
"generated_cover_letter",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
id: serial("id").primaryKey(),
|
||||
applicationId: integer("application_id")
|
||||
.notNull()
|
||||
.references(() => application.id, { onDelete: "cascade" }),
|
||||
|
|
@ -176,39 +192,95 @@ export const generatedCoverLetter = sqliteTable(
|
|||
contentMarkdown: text("content_markdown").notNull(),
|
||||
pdfPath: text("pdf_path"),
|
||||
promptUsed: text("prompt_used"),
|
||||
isApproved: integer("is_approved", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
isApproved: boolean("is_approved").notNull().default(false),
|
||||
createdAt: text("created_at").notNull().default(nowText),
|
||||
},
|
||||
(table) => [index("idx_cl_application").on(table.applicationId)]
|
||||
);
|
||||
|
||||
export const embeddingChunk = sqliteTable(
|
||||
export const embeddingChunk = pgTable(
|
||||
"embedding_chunk",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sourceType: text("source_type").notNull(), // 'document' | 'experience' | 'application' | 'generated_cv' | 'generated_cl' | 'feedback'
|
||||
id: serial("id").primaryKey(),
|
||||
sourceType: text("source_type").notNull(), // 'document' | 'experience' | 'application' | 'generated_cv' | 'generated_cl' | 'feedback' | 'company_research'
|
||||
sourceId: integer("source_id").notNull(),
|
||||
chunkText: text("chunk_text").notNull(),
|
||||
chunkIndex: integer("chunk_index").notNull().default(0),
|
||||
metadata: text("metadata", { mode: "json" }).$type<
|
||||
Record<string, string>
|
||||
>(),
|
||||
embedding: text("embedding").notNull(), // JSON-serialized number[]
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
metadata: jsonb("metadata").$type<Record<string, string>>(),
|
||||
embedding: vector("embedding", { dimensions: 384 }).notNull(),
|
||||
createdAt: text("created_at").notNull().default(nowText),
|
||||
},
|
||||
(table) => [index("idx_chunk_source").on(table.sourceType, table.sourceId)]
|
||||
(table) => [
|
||||
index("idx_chunk_source").on(table.sourceType, table.sourceId),
|
||||
index("idx_chunk_embedding_cosine").using(
|
||||
"hnsw",
|
||||
table.embedding.op("vector_cosine_ops")
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
export const generationFeedback = sqliteTable(
|
||||
export const reminder = pgTable(
|
||||
"reminder",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
applicationId: integer("application_id").references(() => application.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
type: text("type").notNull(), // 'follow_up' | 'interview_prep' | 'thank_you' | 'custom'
|
||||
title: text("title").notNull(),
|
||||
dueDate: text("due_date").notNull(),
|
||||
notes: text("notes"),
|
||||
isCompleted: boolean("is_completed").notNull().default(false),
|
||||
completedAt: text("completed_at"),
|
||||
createdAt: text("created_at").notNull().default(nowText),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_reminder_due").on(table.dueDate),
|
||||
index("idx_reminder_app").on(table.applicationId),
|
||||
]
|
||||
);
|
||||
|
||||
export const contact = pgTable(
|
||||
"contact",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
companyId: integer("company_id").references(() => company.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Deprecated: use contactApplication junction for many-to-many. Kept for
|
||||
// backward compatibility with existing queries; remove after migrating callers.
|
||||
applicationId: integer("application_id").references(() => application.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
role: text("role"),
|
||||
email: text("email"),
|
||||
linkedinUrl: text("linkedin_url"),
|
||||
phone: text("phone"),
|
||||
notes: text("notes"),
|
||||
lastContactDate: text("last_contact_date"),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
index("idx_contact_company").on(table.companyId),
|
||||
index("idx_contact_application").on(table.applicationId),
|
||||
]
|
||||
);
|
||||
|
||||
export const promptConfig = pgTable("prompt_config", {
|
||||
id: serial("id").primaryKey(),
|
||||
key: text("key").notNull().unique(),
|
||||
label: text("label").notNull(),
|
||||
description: text("description"),
|
||||
systemPrompt: text("system_prompt").notNull(),
|
||||
defaultPrompt: text("default_prompt").notNull(),
|
||||
updatedAt: text("updated_at").notNull().default(nowText),
|
||||
});
|
||||
|
||||
export const generationFeedback = pgTable(
|
||||
"generation_feedback",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
id: serial("id").primaryKey(),
|
||||
generatedCvId: integer("generated_cv_id").references(() => generatedCv.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
|
@ -222,9 +294,50 @@ export const generationFeedback = sqliteTable(
|
|||
outcome: text("outcome"),
|
||||
qualityRating: integer("quality_rating"),
|
||||
feedbackNotes: text("feedback_notes"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
createdAt: text("created_at").notNull().default(nowText),
|
||||
},
|
||||
(table) => [index("idx_feedback_outcome").on(table.outcome)]
|
||||
);
|
||||
|
||||
// Many-to-many: a recruiter/interviewer can appear in multiple applications,
|
||||
// and an application can have several contacts (recruiter, hiring manager, etc).
|
||||
export const contactApplication = pgTable(
|
||||
"contact_application",
|
||||
{
|
||||
contactId: integer("contact_id")
|
||||
.notNull()
|
||||
.references(() => contact.id, { onDelete: "cascade" }),
|
||||
applicationId: integer("application_id")
|
||||
.notNull()
|
||||
.references(() => application.id, { onDelete: "cascade" }),
|
||||
role: text("role"), // 'recruiter' | 'interviewer' | 'hiring_manager' | 'referrer'
|
||||
createdAt: text("created_at").notNull().default(nowText),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.contactId, table.applicationId] }),
|
||||
index("idx_ca_application").on(table.applicationId),
|
||||
]
|
||||
);
|
||||
|
||||
// Per-company research log — news, culture notes, tech stack, glassdoor,
|
||||
// interview experiences, compensation intel. Feeds the company "raio-x" view
|
||||
// and is indexable by the RAG system via embeddingChunk.sourceType='company_research'.
|
||||
export const companyResearch = pgTable(
|
||||
"company_research",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
companyId: integer("company_id")
|
||||
.notNull()
|
||||
.references(() => company.id, { onDelete: "cascade" }),
|
||||
type: text("type").notNull(),
|
||||
// 'news' | 'culture' | 'tech_stack' | 'glassdoor' | 'interview_experience' | 'compensation' | 'general'
|
||||
title: text("title").notNull(),
|
||||
content: text("content").notNull(),
|
||||
sourceUrl: text("source_url"),
|
||||
createdAt: text("created_at").notNull().default(nowText),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_research_company").on(table.companyId),
|
||||
index("idx_research_type").on(table.type),
|
||||
]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,18 +8,18 @@ import type { GenerationContext } from "./prompts";
|
|||
export async function buildContext(
|
||||
applicationId: number
|
||||
): Promise<GenerationContext | null> {
|
||||
const app = getApplicationById(applicationId);
|
||||
const app = await getApplicationById(applicationId);
|
||||
if (!app) return null;
|
||||
|
||||
const { profile: profileData, education, experience, skills, documents } =
|
||||
getFullProfile();
|
||||
await getFullProfile();
|
||||
|
||||
if (!profileData) return null;
|
||||
|
||||
const { successful, negativePatterns } = getRelevantFeedback();
|
||||
const { successful, negativePatterns } = await getRelevantFeedback();
|
||||
|
||||
// Use RAG if we have indexed chunks, otherwise fall back to old concat approach
|
||||
const hasIndex = getTotalChunkCount() > 0;
|
||||
const hasIndex = (await getTotalChunkCount()) > 0;
|
||||
|
||||
let documentTexts: string[] = [];
|
||||
let relevantExperience: string[] = [];
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ import { insertChunks, deleteChunksBySource } from "@/db/queries/chunks";
|
|||
* Index a single document's parsed text.
|
||||
*/
|
||||
export async function indexDocument(documentId: number) {
|
||||
const doc = db
|
||||
const [doc] = await db
|
||||
.select()
|
||||
.from(document)
|
||||
.where(eq(document.id, documentId))
|
||||
.get();
|
||||
.limit(1);
|
||||
|
||||
if (!doc?.parsedText) return;
|
||||
|
||||
|
|
@ -30,8 +30,8 @@ export async function indexDocument(documentId: number) {
|
|||
|
||||
const embeddings = await embed(chunks);
|
||||
|
||||
deleteChunksBySource("document", documentId);
|
||||
insertChunks(
|
||||
await deleteChunksBySource("document", documentId);
|
||||
await insertChunks(
|
||||
chunks.map((text, i) => ({
|
||||
sourceType: "document",
|
||||
sourceId: documentId,
|
||||
|
|
@ -50,11 +50,11 @@ export async function indexDocument(documentId: number) {
|
|||
* Index a single experience entry.
|
||||
*/
|
||||
export async function indexExperience(experienceId: number) {
|
||||
const exp = db
|
||||
const [exp] = await db
|
||||
.select()
|
||||
.from(experience)
|
||||
.where(eq(experience.id, experienceId))
|
||||
.get();
|
||||
.limit(1);
|
||||
|
||||
if (!exp) return;
|
||||
|
||||
|
|
@ -71,8 +71,8 @@ export async function indexExperience(experienceId: number) {
|
|||
|
||||
const embeddings = await embed([text]);
|
||||
|
||||
deleteChunksBySource("experience", experienceId);
|
||||
insertChunks([
|
||||
await deleteChunksBySource("experience", experienceId);
|
||||
await insertChunks([
|
||||
{
|
||||
sourceType: "experience",
|
||||
sourceId: experienceId,
|
||||
|
|
@ -88,7 +88,7 @@ export async function indexExperience(experienceId: number) {
|
|||
* Index an application's job description.
|
||||
*/
|
||||
export async function indexApplication(applicationId: number) {
|
||||
const app = db
|
||||
const [app] = await db
|
||||
.select({
|
||||
id: application.id,
|
||||
jobTitle: application.jobTitle,
|
||||
|
|
@ -98,7 +98,7 @@ export async function indexApplication(applicationId: number) {
|
|||
.from(application)
|
||||
.innerJoin(company, eq(application.companyId, company.id))
|
||||
.where(eq(application.id, applicationId))
|
||||
.get();
|
||||
.limit(1);
|
||||
|
||||
if (!app) return;
|
||||
|
||||
|
|
@ -109,8 +109,8 @@ export async function indexApplication(applicationId: number) {
|
|||
|
||||
const embeddings = await embed(chunks);
|
||||
|
||||
deleteChunksBySource("application", applicationId);
|
||||
insertChunks(
|
||||
await deleteChunksBySource("application", applicationId);
|
||||
await insertChunks(
|
||||
chunks.map((text, i) => ({
|
||||
sourceType: "application",
|
||||
sourceId: applicationId,
|
||||
|
|
@ -129,11 +129,11 @@ export async function indexApplication(applicationId: number) {
|
|||
* Index a generated CV that received positive feedback.
|
||||
*/
|
||||
export async function indexGeneratedCv(cvId: number) {
|
||||
const cv = db
|
||||
const [cv] = await db
|
||||
.select()
|
||||
.from(generatedCv)
|
||||
.where(eq(generatedCv.id, cvId))
|
||||
.get();
|
||||
.limit(1);
|
||||
|
||||
if (!cv) return;
|
||||
|
||||
|
|
@ -142,8 +142,8 @@ export async function indexGeneratedCv(cvId: number) {
|
|||
|
||||
const embeddings = await embed(chunks);
|
||||
|
||||
deleteChunksBySource("generated_cv", cvId);
|
||||
insertChunks(
|
||||
await deleteChunksBySource("generated_cv", cvId);
|
||||
await insertChunks(
|
||||
chunks.map((text, i) => ({
|
||||
sourceType: "generated_cv",
|
||||
sourceId: cvId,
|
||||
|
|
@ -159,11 +159,11 @@ export async function indexGeneratedCv(cvId: number) {
|
|||
* Index a generated cover letter that received positive feedback.
|
||||
*/
|
||||
export async function indexGeneratedCl(clId: number) {
|
||||
const cl = db
|
||||
const [cl] = await db
|
||||
.select()
|
||||
.from(generatedCoverLetter)
|
||||
.where(eq(generatedCoverLetter.id, clId))
|
||||
.get();
|
||||
.limit(1);
|
||||
|
||||
if (!cl) return;
|
||||
|
||||
|
|
@ -172,8 +172,8 @@ export async function indexGeneratedCl(clId: number) {
|
|||
|
||||
const embeddings = await embed(chunks);
|
||||
|
||||
deleteChunksBySource("generated_cl", clId);
|
||||
insertChunks(
|
||||
await deleteChunksBySource("generated_cl", clId);
|
||||
await insertChunks(
|
||||
chunks.map((text, i) => ({
|
||||
sourceType: "generated_cl",
|
||||
sourceId: clId,
|
||||
|
|
@ -203,36 +203,36 @@ export async function reindexAll(): Promise<{
|
|||
generatedCls: 0,
|
||||
};
|
||||
|
||||
// Index all documents
|
||||
const docs = db.select({ id: document.id }).from(document).all();
|
||||
const docs = await db.select({ id: document.id }).from(document);
|
||||
for (const doc of docs) {
|
||||
await indexDocument(doc.id);
|
||||
stats.documents++;
|
||||
}
|
||||
|
||||
// Index all experiences
|
||||
const exps = db.select({ id: experience.id }).from(experience).all();
|
||||
const exps = await db.select({ id: experience.id }).from(experience);
|
||||
for (const exp of exps) {
|
||||
await indexExperience(exp.id);
|
||||
stats.experiences++;
|
||||
}
|
||||
|
||||
// Index all applications
|
||||
const apps = db.select({ id: application.id }).from(application).all();
|
||||
const apps = await db.select({ id: application.id }).from(application);
|
||||
for (const app of apps) {
|
||||
await indexApplication(app.id);
|
||||
stats.applications++;
|
||||
}
|
||||
|
||||
// Index successfully-rated CVs
|
||||
const successfulFeedback = db
|
||||
.select({ cvId: generationFeedback.generatedCvId, clId: generationFeedback.generatedClId })
|
||||
const successfulFeedback = await db
|
||||
.select({
|
||||
cvId: generationFeedback.generatedCvId,
|
||||
clId: generationFeedback.generatedClId,
|
||||
})
|
||||
.from(generationFeedback)
|
||||
.where(inArray(generationFeedback.outcome, ["interview", "offer"]))
|
||||
.all();
|
||||
.where(inArray(generationFeedback.outcome, ["interview", "offer"]));
|
||||
|
||||
const cvIds = new Set(
|
||||
successfulFeedback.map((f) => f.cvId).filter((id): id is number => id !== null)
|
||||
successfulFeedback
|
||||
.map((f) => f.cvId)
|
||||
.filter((id): id is number => id !== null)
|
||||
);
|
||||
for (const cvId of cvIds) {
|
||||
await indexGeneratedCv(cvId);
|
||||
|
|
@ -240,7 +240,9 @@ export async function reindexAll(): Promise<{
|
|||
}
|
||||
|
||||
const clIds = new Set(
|
||||
successfulFeedback.map((f) => f.clId).filter((id): id is number => id !== null)
|
||||
successfulFeedback
|
||||
.map((f) => f.clId)
|
||||
.filter((id): id is number => id !== null)
|
||||
);
|
||||
for (const clId of clIds) {
|
||||
await indexGeneratedCl(clId);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { embedSingle } from "./embedder";
|
||||
import { getAllChunks } from "@/db/queries/chunks";
|
||||
import { searchSimilarChunks } from "@/db/queries/chunks";
|
||||
|
||||
interface RetrievalResult {
|
||||
chunkText: string;
|
||||
|
|
@ -9,21 +9,11 @@ interface RetrievalResult {
|
|||
metadata: Record<string, string> | null;
|
||||
}
|
||||
|
||||
function cosineSimilarity(a: number[], b: number[]): number {
|
||||
let dot = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
return denom === 0 ? 0 : dot / denom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the most relevant chunks for a given query text.
|
||||
*
|
||||
* Uses pgvector's HNSW index on embedding_chunk.embedding for native cosine
|
||||
* similarity search — much faster than fetching all chunks and scoring in JS.
|
||||
*/
|
||||
export async function retrieveRelevant(
|
||||
query: string,
|
||||
|
|
@ -37,19 +27,20 @@ export async function retrieveRelevant(
|
|||
const minScore = options?.minScore ?? 0.25;
|
||||
|
||||
const queryEmbedding = await embedSingle(query);
|
||||
const chunks = getAllChunks(options?.sourceTypes);
|
||||
|
||||
if (chunks.length === 0) return [];
|
||||
const rows = await searchSimilarChunks(queryEmbedding, {
|
||||
topK: topK * 2, // overfetch a bit so minScore filtering still yields topK
|
||||
sourceTypes: options?.sourceTypes,
|
||||
});
|
||||
|
||||
const scored = chunks.map((chunk) => ({
|
||||
chunkText: chunk.chunkText,
|
||||
sourceType: chunk.sourceType,
|
||||
sourceId: chunk.sourceId,
|
||||
metadata: chunk.metadata,
|
||||
score: cosineSimilarity(queryEmbedding, chunk.embedding),
|
||||
}));
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
return scored.filter((r) => r.score >= minScore).slice(0, topK);
|
||||
return rows
|
||||
.map((r) => ({
|
||||
chunkText: r.chunkText,
|
||||
sourceType: r.sourceType,
|
||||
sourceId: r.sourceId,
|
||||
metadata: r.metadata,
|
||||
score: Number(r.score),
|
||||
}))
|
||||
.filter((r) => r.score >= minScore)
|
||||
.slice(0, topK);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue