From 982592bba49f9c36f8d3383659b3c42151160904 Mon Sep 17 00:00:00 2001 From: Amit Karmakar Date: Wed, 2 Sep 2026 20:48:18 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20finish=20launch=20=E2=80=94=20Postgres,?= =?UTF-8?q?=20packages=20UI,=20CI,=20LinkedIn=20paste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch Prisma to PostgreSQL with migrations and docker-compose, add apply-packages page, password reset UX, health check, LinkedIn paste import, GitHub Actions CI/deploy wiring, and update deploy docs. --- .env.example | 5 +- .github/workflows/ci.yml | 63 ++++ DEPLOY.md | 80 +++-- README.md | 60 ++-- docker-compose.yml | 20 ++ package.json | 5 +- .../migration.sql | 314 ++++++++++++++++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 3 +- src/app/api/health/route.ts | 31 ++ src/app/api/resumes/route.ts | 20 ++ src/app/app/packages/page.tsx | 144 ++++++++ src/app/app/resume/page.tsx | 81 ++++- src/app/login/page.tsx | 159 +++++++-- src/components/app-sidebar.tsx | 2 + src/lib/linkedin-import.ts | 112 +++++++ 16 files changed, 986 insertions(+), 116 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docker-compose.yml create mode 100644 prisma/migrations/20260902204449_init_postgres/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/app/packages/page.tsx create mode 100644 src/lib/linkedin-import.ts diff --git a/.env.example b/.env.example index 2b07a5f..9be1c6c 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ -# Database — SQLite for local; switch prisma provider to postgresql for production -DATABASE_URL="file:./dev.db" -# DATABASE_URL="postgresql://user:pass@host:5432/applypilot?sslmode=require" +# Database — PostgreSQL (local docker-compose or managed Neon/Supabase/RDS) +DATABASE_URL="postgresql://applypilot:applypilot@localhost:5432/applypilot?schema=public" # Auth — required in production (32+ random chars) AUTH_SECRET="replace-with-long-random-secret" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ab2357f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main, "cursor/**"] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: applypilot + POSTGRES_PASSWORD: applypilot + POSTGRES_DB: applypilot + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U applypilot -d applypilot" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgresql://applypilot:applypilot@localhost:5432/applypilot?schema=public + AUTH_SECRET: ci-test-secret-not-for-production-use + NEXT_PUBLIC_APP_URL: http://localhost:3000 + ALLOW_DEMO_LOGIN: "true" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm ci + - run: npx prisma migrate deploy + - run: npm run db:seed + - run: npm run build + + deploy: + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Deploy to Vercel (optional) + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + run: | + if [ -z "$VERCEL_TOKEN" ] || [ -z "$VERCEL_ORG_ID" ] || [ -z "$VERCEL_PROJECT_ID" ]; then + echo "Skipping deploy. Add repo secrets VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." + exit 0 + fi + npm i -g vercel@39 + vercel pull --yes --environment=production --token "$VERCEL_TOKEN" + vercel build --prod --token "$VERCEL_TOKEN" + vercel deploy --prebuilt --prod --token "$VERCEL_TOKEN" diff --git a/DEPLOY.md b/DEPLOY.md index 0dcb3c0..a7798d9 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,52 +1,47 @@ # Production launch -ApplyPilot ships as a Next.js app. Local default uses **SQLite**. Production should use **Postgres**. +ApplyPilot is a Next.js app on **PostgreSQL**. ## 1. Database -Local: +### Local (Docker) ```bash +docker compose up -d cp .env.example .env -# DATABASE_URL="file:./dev.db" -npm run db:reset +npm install +npx prisma migrate deploy +npm run db:seed +npm run dev ``` -Production (Neon / Supabase / RDS): +### Local (Postgres already installed) -1. In `prisma/schema.prisma`, change: - -```prisma -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} +```bash +# DATABASE_URL=postgresql://applypilot:applypilot@localhost:5432/applypilot?schema=public +npx prisma migrate deploy && npm run db:seed ``` -2. Set `DATABASE_URL` to your Postgres connection string. -3. Run: +### Production (Neon / Supabase / RDS) -```bash -npx prisma db push -# or: npx prisma migrate deploy -npm run db:seed # optional — skip demo user in prod if desired -``` +1. Create a Postgres database. +2. Set `DATABASE_URL` (use pooled URL + `?sslmode=require` as required by your host). +3. Deploy — `npm run build` runs `prisma migrate deploy` automatically. ## 2. Environment -Required for production: - | Variable | Purpose | |----------|---------| +| `DATABASE_URL` | Postgres connection string | | `AUTH_SECRET` | HMAC session signing (32+ chars) | -| `DATABASE_URL` | Postgres URL | -| `NEXT_PUBLIC_APP_URL` | Canonical site URL | +| `NEXT_PUBLIC_APP_URL` | Canonical site URL (`https://…`) | | `OPENAI_API_KEY` | Live AI | -| `REQUIRE_OPENAI=true` | Refuse heuristic AI fallbacks | +| `REQUIRE_OPENAI` | `true` in production | | `STRIPE_SECRET_KEY` | Billing | | `STRIPE_WEBHOOK_SECRET` | Checkout fulfillment | +| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe.js (optional UI) | | `STRIPE_PRICE_PRO_MONTHLY` | Pro price id | -| `STRIPE_PRICE_CREDITS_*` | Credit pack price ids | +| `STRIPE_PRICE_CREDITS_25/50/100/250` | Credit pack price ids | | `RESEND_API_KEY` | Transactional email | | `EMAIL_FROM` | From address | | `CRON_SECRET` | Protect `/api/cron` | @@ -55,38 +50,37 @@ Demo login is **disabled** when `NODE_ENV=production`. ## 3. Stripe -1. Create products: Pro monthly subscription + one-time credit packs (25/50/100/250). +1. Create products: Pro monthly + one-time credit packs (25/50/100/250). 2. Put price IDs in env. -3. Webhook endpoint: `POST /api/billing/webhook` +3. Webhook: `POST /api/billing/webhook` Events: `checkout.session.completed`, `customer.subscription.deleted`. ## 4. Cron -`vercel.json` schedules: +`vercel.json` schedules job sync, apply-package queue, alerts, and follow-ups. -- Job sync every 6h -- Apply-package queue every 15m -- Job alerts daily -- Follow-up reminders daily - -Authorize with `Authorization: Bearer $CRON_SECRET` (Vercel Cron sends this when configured) or `?secret=`. - -Manual: +Authorize with `Authorization: Bearer $CRON_SECRET`. ```bash curl -X POST "$APP_URL/api/cron?job=all" -H "Authorization: Bearer $CRON_SECRET" +curl "$APP_URL/api/health" ``` -## 5. Deploy +## 5. Deploy (Vercel) + +1. Import `letslego/applypilot` in Vercel. +2. Add all env vars (including `DATABASE_URL` to a Neon/Supabase DB). +3. Set build command to default `npm run build`. +4. Add GitHub secrets for CI auto-deploy (optional): + - `VERCEL_TOKEN` + - `VERCEL_ORG_ID` + - `VERCEL_PROJECT_ID` +5. After first deploy, seed if needed: ```bash -npm run build -npm start -# or: vercel --prod +DATABASE_URL=... npm run db:seed ``` -Health checks: `/` · `/api/jobs/sync` (GET) · Stripe webhook · signup → verify email → checkout. - ## Ethical boundary -Auto-Apply creates **packages** (tailored resume, cover letter, answer bank snapshot, employer `applyUrl`). Users confirm submission on the employer site. LinkedIn/Indeed/Glassdoor scraping and stealth auto-submit are intentionally out of scope. +Auto-Apply creates **packages** (tailored resume, cover letter, answer bank, employer `applyUrl`). Users confirm submission on the employer site. LinkedIn/Indeed scraping and stealth auto-submit are out of scope. LinkedIn **paste import** only parses text the user pastes. diff --git a/README.md b/README.md index 9ba599b..44dc526 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,45 @@ # ApplyPilot -AI job-application co-pilot inspired by [AIApply](https://aiapply.co/) — with feature parity plus extras from Teal, Jobscan, LoopCV, and Simplify. +AI job-application co-pilot inspired by [AIApply](https://aiapply.co/). **Repo:** https://github.com/letslego/applypilot -## Quick start (local) +## Quick start ```bash +docker compose up -d # Postgres cp .env.example .env npm install -npm run db:reset +npx prisma migrate deploy +npm run db:seed npm run dev ``` -Open http://localhost:3000 +Open http://localhost:3000 — demo login (dev only): `demo@applypilot.com` / `demo1234` -**Demo login (non-production only):** `demo@applypilot.com` / `demo1234` +## Production -## Production launch +See **[DEPLOY.md](./DEPLOY.md)** for Postgres, Stripe, Resend, OpenAI, cron, and Vercel. -See **[DEPLOY.md](./DEPLOY.md)** for Postgres, Stripe, Resend, OpenAI, cron, and env setup. - -```bash -npm run build && npm start -``` +Health check: `GET /api/health` ## Features | Area | Capability | |------|------------| -| Marketing | Brand-first landing, pricing, features, FAQ | -| Auth | HMAC-signed sessions, email verify, password reset | -| Billing | Stripe Checkout (Pro + credit packs) + webhooks | -| Documents | AI resume builder, tailor, translate, DOCX/PDF export | -| Cover letters | Per-job generation | -| ATS scanner | Score, keyword heatmap, rewrite tips | -| Job board | Seeded roles + live ATS/public feed sync | -| Auto-Apply | Prefs, hybrid/auto modes, credit wallet, **employer apply packages** | -| Tracker | Kanban pipeline + follow-up reminders | -| Interviews | Mock interview + Interview Buddy (practice coach) | -| Alerts | Job match emails + in-app notifications | +| Auth | Signed sessions, email verify, password reset | +| Billing | Stripe Checkout + webhooks | +| Documents | Resume builder, LinkedIn paste import, DOCX/PDF export | +| ATS | Score, keyword heatmap, rewrite tips | +| Jobs | Live Greenhouse/Ashby/Remotive/RemoteOK/Arbeitnow sync | +| Auto-Apply | Credit packages + employer apply packages | +| Alerts | Job match + follow-up emails | | Legal | Privacy, Terms, AI disclosure | -Auto-Apply builds tailored packages and employer apply links — you confirm submission. Optional live LLM via `OPENAI_API_KEY`. Set `REQUIRE_OPENAI=true` in production. - -## Job data sources - -Legal ingest (no LinkedIn/Indeed/Glassdoor scraping): - -| Source | Endpoint style | -|--------|----------------| -| Greenhouse | `boards-api.greenhouse.io/v1/boards/{token}/jobs` | -| Ashby | `api.ashbyhq.com/posting-api/job-board/{org}` | -| Remotive | `remotive.com/api/remote-jobs` | -| RemoteOK | `remoteok.com/api` | -| Arbeitnow | `arbeitnow.com/api/job-board-api` | +## Scripts ```bash -npm run jobs:sync:quick # fast subset -npm run jobs:sync # fuller pull -npm run cron:all # sync + queue + alerts + follow-ups +npm run jobs:sync:quick +npm run cron:all +npm run build ``` - -Or click **Sync live jobs** on `/app/jobs` while logged in. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9d9d5a4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: applypilot + POSTGRES_PASSWORD: applypilot + POSTGRES_DB: applypilot + ports: + - "5432:5432" + volumes: + - applypilot_pg:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U applypilot -d applypilot"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + applypilot_pg: diff --git a/package.json b/package.json index 76cd6be..d91c15d 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,13 @@ "private": true, "scripts": { "dev": "next dev", - "build": "prisma generate && next build", + "build": "prisma generate && prisma migrate deploy && next build", "start": "next start", "lint": "next lint", "db:push": "prisma db push", + "db:migrate": "prisma migrate deploy", "db:seed": "tsx prisma/seed.ts", - "db:reset": "prisma db push --force-reset && tsx prisma/seed.ts", + "db:reset": "prisma migrate reset --force", "jobs:sync": "tsx scripts/sync-jobs.ts", "jobs:sync:quick": "tsx scripts/sync-jobs.ts --quick", "cron:all": "tsx scripts/run-cron.ts", diff --git a/prisma/migrations/20260902204449_init_postgres/migration.sql b/prisma/migrations/20260902204449_init_postgres/migration.sql new file mode 100644 index 0000000..92c6753 --- /dev/null +++ b/prisma/migrations/20260902204449_init_postgres/migration.sql @@ -0,0 +1,314 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "name" TEXT NOT NULL, + "plan" TEXT NOT NULL DEFAULT 'free', + "credits" INTEGER NOT NULL DEFAULT 5, + "role" TEXT NOT NULL DEFAULT 'user', + "emailVerified" TIMESTAMP(3), + "stripeCustomerId" TEXT, + "stripeSubscriptionId" TEXT, + "resetToken" TEXT, + "resetTokenExpires" TIMESTAMP(3), + "verifyToken" TEXT, + "verifyTokenExpires" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Profile" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "headline" TEXT, + "location" TEXT, + "linkedinUrl" TEXT, + "phone" TEXT, + "summary" TEXT, + "skills" TEXT NOT NULL DEFAULT '[]', + "yearsExperience" INTEGER NOT NULL DEFAULT 0, + "desiredRoles" TEXT NOT NULL DEFAULT '[]', + "desiredLocations" TEXT NOT NULL DEFAULT '[]', + "salaryMin" INTEGER, + "salaryMax" INTEGER, + "workAuth" TEXT, + "willingRemote" BOOLEAN NOT NULL DEFAULT true, + "willingRelocate" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Profile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Resume" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "isMaster" BOOLEAN NOT NULL DEFAULT false, + "language" TEXT NOT NULL DEFAULT 'en', + "content" TEXT NOT NULL, + "tailoredFor" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Resume_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CoverLetter" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "resumeId" TEXT, + "jobId" TEXT, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CoverLetter_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Job" ( + "id" TEXT NOT NULL, + "externalId" TEXT, + "title" TEXT NOT NULL, + "company" TEXT NOT NULL, + "location" TEXT NOT NULL, + "remoteType" TEXT NOT NULL DEFAULT 'hybrid', + "salaryMin" INTEGER, + "salaryMax" INTEGER, + "currency" TEXT NOT NULL DEFAULT 'USD', + "description" TEXT NOT NULL, + "requirements" TEXT NOT NULL, + "skills" TEXT NOT NULL, + "department" TEXT, + "seniority" TEXT, + "source" TEXT NOT NULL DEFAULT 'ApplyPilot Board', + "postedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "url" TEXT, + "applyUrl" TEXT, + + CONSTRAINT "Job_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Application" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "jobId" TEXT NOT NULL, + "resumeId" TEXT, + "status" TEXT NOT NULL DEFAULT 'saved', + "matchScore" INTEGER NOT NULL DEFAULT 0, + "mode" TEXT NOT NULL DEFAULT 'manual', + "coverLetterText" TEXT, + "notes" TEXT, + "followUpAt" TIMESTAMP(3), + "appliedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Application_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ApplyPackage" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "applyUrl" TEXT, + "resumeJson" TEXT NOT NULL, + "coverLetter" TEXT NOT NULL, + "answersJson" TEXT NOT NULL DEFAULT '{}', + "status" TEXT NOT NULL DEFAULT 'ready', + "notes" TEXT, + "submittedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ApplyPackage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CreditLedger" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "delta" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "stripeSessionId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CreditLedger_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AutoApplyPrefs" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "mode" TEXT NOT NULL DEFAULT 'hybrid', + "roles" TEXT NOT NULL DEFAULT '[]', + "locations" TEXT NOT NULL DEFAULT '[]', + "excludeCompanies" TEXT NOT NULL DEFAULT '[]', + "minMatchScore" INTEGER NOT NULL DEFAULT 70, + "remoteOnly" BOOLEAN NOT NULL DEFAULT false, + "salaryMin" INTEGER, + "dailyLimit" INTEGER NOT NULL DEFAULT 25, + "alertsEnabled" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "AutoApplyPrefs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "InterviewSession" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "jobTitle" TEXT NOT NULL, + "company" TEXT, + "kind" TEXT NOT NULL DEFAULT 'mock', + "transcript" TEXT NOT NULL DEFAULT '[]', + "feedback" TEXT, + "score" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "InterviewSession_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AnswerBankEntry" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "label" TEXT NOT NULL, + "question" TEXT NOT NULL, + "answer" TEXT NOT NULL, + "category" TEXT NOT NULL DEFAULT 'general', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AnswerBankEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SavedSearch" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "query" TEXT NOT NULL, + "alertEnabled" BOOLEAN NOT NULL DEFAULT true, + "lastAlertAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SavedSearch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OutreachDraft" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "channel" TEXT NOT NULL DEFAULT 'email', + "recipient" TEXT, + "subject" TEXT, + "body" TEXT NOT NULL, + "company" TEXT, + "role" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OutreachDraft_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "read" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SyncRun" ( + "id" TEXT NOT NULL, + "source" TEXT NOT NULL, + "ok" BOOLEAN NOT NULL, + "upserted" INTEGER NOT NULL DEFAULT 0, + "message" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SyncRun_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Profile_userId_key" ON "Profile"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Job_externalId_key" ON "Job"("externalId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Application_userId_jobId_key" ON "Application"("userId", "jobId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApplyPackage_applicationId_key" ON "ApplyPackage"("applicationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AutoApplyPrefs_userId_key" ON "AutoApplyPrefs"("userId"); + +-- AddForeignKey +ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Resume" ADD CONSTRAINT "Resume_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoverLetter" ADD CONSTRAINT "CoverLetter_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoverLetter" ADD CONSTRAINT "CoverLetter_resumeId_fkey" FOREIGN KEY ("resumeId") REFERENCES "Resume"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoverLetter" ADD CONSTRAINT "CoverLetter_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Application" ADD CONSTRAINT "Application_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Application" ADD CONSTRAINT "Application_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Application" ADD CONSTRAINT "Application_resumeId_fkey" FOREIGN KEY ("resumeId") REFERENCES "Resume"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApplyPackage" ADD CONSTRAINT "ApplyPackage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApplyPackage" ADD CONSTRAINT "ApplyPackage_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AutoApplyPrefs" ADD CONSTRAINT "AutoApplyPrefs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "InterviewSession" ADD CONSTRAINT "InterviewSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AnswerBankEntry" ADD CONSTRAINT "AnswerBankEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SavedSearch" ADD CONSTRAINT "SavedSearch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OutreachDraft" ADD CONSTRAINT "OutreachDraft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 989be6d..abc0ec8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2,9 +2,8 @@ generator client { provider = "prisma-client-js" } -// Local/demo: sqlite. Production: change provider to "postgresql" and set DATABASE_URL. datasource db { - provider = "sqlite" + provider = "postgresql" url = env("DATABASE_URL") } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..ad2c373 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { hasEmail, hasOpenAI, hasStripe } from "@/lib/env"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + let dbOk = false; + try { + await prisma.$queryRaw`SELECT 1`; + dbOk = true; + } catch { + dbOk = false; + } + + const jobs = dbOk ? await prisma.job.count() : 0; + const body = { + ok: dbOk, + service: "applypilot", + time: new Date().toISOString(), + database: dbOk ? "up" : "down", + jobs, + integrations: { + openai: hasOpenAI(), + stripe: hasStripe(), + email: hasEmail(), + }, + }; + + return NextResponse.json(body, { status: dbOk ? 200 : 503 }); +} diff --git a/src/app/api/resumes/route.ts b/src/app/api/resumes/route.ts index 5e94ae1..43379ac 100644 --- a/src/app/api/resumes/route.ts +++ b/src/app/api/resumes/route.ts @@ -165,5 +165,25 @@ export async function POST(req: NextRequest) { return NextResponse.json({ coverLetter: created }); } + if (action === "import-linkedin") { + const { parseLinkedInPaste } = await import("@/lib/linkedin-import"); + const master = await prisma.resume.findFirst({ + where: { userId: user.id, isMaster: true }, + }); + const fallback = master + ? (JSON.parse(master.content) as ResumeContent) + : undefined; + const parsed = parseLinkedInPaste(String(body.text || ""), fallback); + const created = await prisma.resume.create({ + data: { + userId: user.id, + title: body.title || "Imported from LinkedIn paste", + content: JSON.stringify(parsed), + isMaster: false, + }, + }); + return NextResponse.json({ resume: { ...created, content: parsed } }); + } + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); } diff --git a/src/app/app/packages/page.tsx b/src/app/app/packages/page.tsx new file mode 100644 index 0000000..d1ad321 --- /dev/null +++ b/src/app/app/packages/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { ExternalLink, PackageCheck } from "lucide-react"; +import { Badge, Button, Card, PageHeader } from "@/components/ui"; +import { formatDate } from "@/lib/utils"; + +type ApplyPkg = { + id: string; + status: string; + applyUrl: string | null; + coverLetter: string; + createdAt: string; + application: { + matchScore: number; + status: string; + job: { title: string; company: string; location: string }; + }; +}; + +export default function PackagesPage() { + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + const [activeId, setActiveId] = useState(null); + + const refresh = useCallback(async () => { + const res = await fetch("/api/apply-packages"); + if (res.ok) { + const data = await res.json(); + setPackages(data.packages || []); + } + setLoading(false); + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + async function mark(id: string, action: "mark-opened" | "mark-submitted") { + await fetch("/api/apply-packages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, id }), + }); + await refresh(); + } + + const active = packages.find((p) => p.id === activeId) || packages[0]; + + if (loading) return

Loading packages…

; + + return ( +
+ + {packages.length === 0 ? ( + +

+ No packages yet. Run Auto-Apply to generate employer-ready packages. +

+ + Go to Auto-Apply + +
+ ) : ( +
+ + {packages.map((p) => ( + + ))} + + {active ? ( + +
+
+

+ {active.application.job.title} +

+

+ {active.application.job.company} · {active.application.job.location} +

+

+ Created {formatDate(active.createdAt)} +

+
+
+ {active.applyUrl ? ( + void mark(active.id, "mark-opened")} + > + + + ) : null} + +
+
+
+

+ Cover letter +

+
+                  {active.coverLetter}
+                
+
+
+ ) : null} +
+ )} +
+ ); +} diff --git a/src/app/app/resume/page.tsx b/src/app/app/resume/page.tsx index 1ac9e66..e876745 100644 --- a/src/app/app/resume/page.tsx +++ b/src/app/app/resume/page.tsx @@ -15,7 +15,7 @@ import { DocumentsSelect } from "@/components/documents-shared"; import type { ResumeContent } from "@/data/demo-resume"; import { DEMO_RESUME } from "@/data/demo-resume"; import { formatDate } from "@/lib/utils"; -import { Languages, Loader2, Printer, Save, Sparkles, Wand2, X } from "lucide-react"; +import { Languages, Loader2, Printer, Save, Sparkles, Wand2, X, Import } from "lucide-react"; type ResumeRow = { id: string; @@ -58,6 +58,8 @@ export default function ResumeBuilderPage() { const [error, setError] = useState(null); const [tailorJobId, setTailorJobId] = useState(""); const [translateLang, setTranslateLang] = useState("es"); + const [showImport, setShowImport] = useState(false); + const [importText, setImportText] = useState(""); const load = useCallback(async () => { setLoading(true); @@ -208,6 +210,35 @@ export default function ResumeBuilderPage() { } } + async function importLinkedIn() { + setBusy("import"); + setError(null); + setMessage(null); + try { + const res = await fetch("/api/resumes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "import-linkedin", text: importText }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Import failed"); + setShowImport(false); + setImportText(""); + setMessage("Imported LinkedIn paste into a new resume variant."); + await load(); + if (data.resume?.id) { + setSelectedId(data.resume.id); + setTitle(data.resume.title); + setContent(data.resume.content); + setSkillsText(data.resume.content.skills.join(", ")); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Import failed"); + } finally { + setBusy(null); + } + } + async function tailorResume() { if (!selectedId || !tailorJobId) { setError("Pick a resume and a job to tailor against"); @@ -303,6 +334,19 @@ export default function ResumeBuilderPage() { subtitle="Edit your master profile, tailor for a role, or translate — then print a clean ATS-friendly PDF." actions={
+ + + + +
+

+ Paste your public profile text (About, Experience, Education, Skills). We do not + scrape LinkedIn — only content you paste. +

+