diff --git a/.env.example b/.env.example index 69d526d..e526eb7 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,17 @@ RXRESUME_PASSWORD=your_password_here BASIC_AUTH_USER= BASIC_AUTH_PASSWORD= +# ============================================================================= +# Gmail OAuth (Tracking Inbox) - optional +# ============================================================================= +# Required to connect Gmail from the UI. +GMAIL_OAUTH_CLIENT_ID= +GMAIL_OAUTH_CLIENT_SECRET= + +# Optional override for OAuth callback URL. +# If unset, defaults to /oauth/gmail/callback +# GMAIL_OAUTH_REDIRECT_URI=http://localhost:3005/oauth/gmail/callback + # ============================================================================= # UKVisaJobs (UK visa sponsorship jobs) - optional # ============================================================================= diff --git a/README.md b/README.md index f3e57f3..8b12433 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ AI-powered job discovery and application pipeline. Automatically finds jobs, sco 3. **Tailor**: Generates a custom resume summary for top-tier matches. 4. **Export**: Uses [RxResume v4](https://v4.rxresu.me) to create tailored PDFs. 5. **Manage**: Review and mark jobs as "Applied" via the dashboard (calls webhooks for lifecycle events). +6. **Track**: Connect your Gmail to automatically track post-application emails (interviews, offers, rejections) via the **Tracking Inbox**. The Smart Router AI matches emails to your applied jobs and updates job application status automatically. ## Example of generating a tailored resume for a job @@ -37,6 +38,19 @@ https://github.com/user-attachments/assets/06e5e782-47f5-42d0-8b28-b89102d7ea1b - Run the pipeline to fetch jobs → score → tailor → export PDFs - Review jobs in the dashboard and mark stages +- (Optional) Connect Gmail to enable automatic post-application email tracking + +### Post-Application Tracking (Optional) + +Once you've applied to jobs, connect your Gmail account to automatically track responses: + +1. Go to **Tracking Inbox** in the dashboard +2. Click **Connect Gmail** and authorize access +3. The Smart Router AI will analyze incoming emails and match them to your applied jobs +4. High-confidence matches (95%+) are auto-linked; others appear in your Inbox for review +5. Interview invites, offers, and rejections automatically update your job timeline + +See `documentation/self-hosting.md` for Gmail OAuth setup instructions. ### Quick Start (commands) @@ -76,6 +90,8 @@ The app will guide you through setup on first launch. The onboarding wizard help - Technical breakdowns here: `documentation/extractors/README.md` - Orchestrator docs here: `documentation/orchestrator.md` +- Post-application tracking: `documentation/post-application-tracking.md` +- Full documentation index: `documentation/README.md` - Persistent data lives in `./data` (bind-mounted into the container). ## Notes / sharp edges diff --git a/documentation/README.md b/documentation/README.md new file mode 100644 index 0000000..8664415 --- /dev/null +++ b/documentation/README.md @@ -0,0 +1,85 @@ +# JobOps Documentation + +Welcome to the JobOps documentation. This folder contains comprehensive guides for setting up, configuring, and using JobOps. + +## Getting Started + +- **[Self-Hosting Guide](./self-hosting.md)** - Deploy JobOps with Docker Compose + - Docker setup instructions + - Gmail OAuth configuration for email tracking + - Environment variables reference + - Demo mode deployment + +## Feature Documentation + +- **[Orchestrator](./orchestrator.md)** - Core job workflow and PDF generation + - Job states explained (discovered, ready, applied, etc.) + - The "Ready" flow (manual vs auto) + - PDF generation and regeneration + - Post-application tracking overview + +- **[Post-Application Tracking](./post-application-tracking.md)** - Email-to-job matching + - How the Smart Router AI works + - Gmail integration setup + - Using the Tracking Inbox + - Privacy and security details + - API reference + +## Extractors + +JobOps uses specialized extractors to gather jobs from different sources: + +- **[Extractors Overview](./extractors/README.md)** - Architecture and how extractors work +- **[Gradcracker](./extractors/gradcracker.md)** - UK graduate jobs and internships +- **[UKVisaJobs](./extractors/ukvisajobs.md)** - UK visa sponsorship jobs +- **[JobSpy](./extractors/jobspy.md)** - Multi-platform job aggregator (Indeed, LinkedIn, etc.) +- **[Manual Import](./extractors/manual.md)** - Import jobs from URLs or text + +## Quick Reference + +### Main Components + +- **Orchestrator** - Main application (UI, API, database) +- **Extractors** - Specialized job crawlers +- **Shared** - Common types and utilities + +### Key Features + +1. **Job Discovery** - Automatically find jobs from multiple sources +2. **AI Scoring** - Rank jobs by suitability for your profile +3. **Resume Tailoring** - Generate custom resumes for each job +4. **PDF Export** - Create tailored PDFs via RxResume integration +5. **Application Tracking** - Monitor your applied jobs +6. **Email Tracking** - Auto-track post-application responses (interviews, offers, rejections) + +### Documentation Structure + +``` +documentation/ +├── self-hosting.md # Deployment guide +├── orchestrator.md # Core workflow documentation +├── post-application-tracking.md # Email tracking feature +└── extractors/ # Job source extractors + ├── README.md + ├── gradcracker.md + ├── jobspy.md + ├── manual.md + ├── ukvisajobs.md + └── gradcracker.md +``` + +## Contributing to Documentation + +When adding new features: + +1. Update the relevant feature documentation +2. Add API endpoint documentation to orchestrator README +3. Update this index if adding new docs +4. Include mermaid diagrams for complex workflows +5. Provide practical examples + +## Support + +- Open an [issue](https://github.com/DaKheera47/job-ops/issues) for documentation errors +- Check existing docs before asking questions +- See main README for general project info diff --git a/documentation/orchestrator.md b/documentation/orchestrator.md index 43601ad..a73fec3 100644 --- a/documentation/orchestrator.md +++ b/documentation/orchestrator.md @@ -15,14 +15,14 @@ This doc explains how the orchestrator thinks about job states, how the "Ready" There are two main ways a job becomes Ready: -1) **Manual flow (most common)** +1. **Manual flow (most common)** - A job starts in `discovered`. - You open it in the Discovered panel, decide to Tailor. - In Tailor mode you can edit job description (optional), tailored summary, tailored headline, tailored skills, and project picks. - You click **Finalize & Move to Ready**. - This runs summarization (if needed), generates the PDF, and sets status to `ready`. -2) **Auto flow (pipeline top picks)** +2. **Auto flow (pipeline top picks)** - The pipeline scores all discovered jobs. - It auto-processes the top N above the score threshold. - Those jobs go directly to `ready` with PDFs generated. @@ -58,13 +58,13 @@ If the job description or tailoring changes, regenerate the PDF so it stays in s ### Typical UI flow -1) Edit job description or tailoring in the Discovered/Tailor view, or use ?Edit job description? in Ready. -2) If you want AI to re-tailor based on the updated JD, click **Generate draft** (Discovered) or **AI Summarize** (editor). -3) Click **Finalize & Move to Ready** (if still in Discovered) or **Regenerate PDF** (if already Ready). +1. Edit job description or tailoring in the Discovered/Tailor view, or use ?Edit job description? in Ready. +2. If you want AI to re-tailor based on the updated JD, click **Generate draft** (Discovered) or **AI Summarize** (editor). +3. Click **Finalize & Move to Ready** (if still in Discovered) or **Regenerate PDF** (if already Ready). ### API flow (for automation) -1) Update the data: +1. Update the data: ```bash PATCH /api/jobs/:id @@ -77,18 +77,44 @@ PATCH /api/jobs/:id } ``` -2) (Optional) re-run AI tailoring based on the new JD: +2. (Optional) re-run AI tailoring based on the new JD: ```bash POST /api/jobs/:id/summarize?force=true ``` -3) Generate the PDF using current stored fields: +3. Generate the PDF using current stored fields: ```bash POST /api/jobs/:id/generate-pdf ``` +## Post-Application Tracking (Tracking Inbox) + +After you've applied to jobs, the Tracking Inbox feature automatically monitors your Gmail for responses: + +### How it works + +1. **Gmail Sync**: Periodically checks your Gmail for recruitment-related emails +2. **Smart Router AI**: Analyzes each email for: + - Relevance (is it about job applications?) + - Job matching (which applied job is this about?) + - Message type (interview, offer, rejection, update) + - Confidence score (0-100%) + +3. **Automatic Processing**: + - **95-100% confidence**: Auto-linked to the matched job, timeline updated + - **50-94% confidence**: Goes to Inbox for review with suggested match + - **<50% confidence**: Goes to Inbox as "orphan" if relevant; ignored if not + +4. **User Review**: Items in the Inbox wait for your approve/ignore decision + +### Gmail Setup + +1. Configure Gmail OAuth credentials (see `self-hosting.md`) +2. In the UI: Tracking Inbox → Connect Gmail +3. Authorize read-only Gmail access + ## Notes and gotchas - `processing` is transient. If PDF generation fails, the job is reverted back to `discovered`. diff --git a/documentation/post-application-tracking.md b/documentation/post-application-tracking.md new file mode 100644 index 0000000..4dba4ce --- /dev/null +++ b/documentation/post-application-tracking.md @@ -0,0 +1,241 @@ +# Post-Application Email Tracking + +The Post-Application Tracking feature (also called "Tracking Inbox") automatically monitors your Gmail for job application responses and updates your job timeline accordingly. + +## Overview + +After you've applied to jobs, keeping track of responses can be tedious. This feature automates that process by: + +1. **Scanning your Gmail** for recruitment-related emails +2. **Matching emails** to your tracked job applications using AI +3. **Updating your timeline** with interview invites, offers, rejections, and updates +4. **Asking for your review** when the AI is uncertain + +## How It Works + +### The Smart Router Flow + +```mermaid +flowchart TD + A[Recruitment email arrives in Gmail] --> B[Smart Router AI analyzes content] + B --> C{How confident is the match?} + + C -->|95-100%| D[Auto-linked to job] + D --> E[Timeline updated automatically] + + C -->|50-94%| F[Goes to Inbox for review
with suggested job match] + + C -->|<50%| G{Is it relevant?} + G -->|Yes| H[Goes to Inbox as orphan
relevant but job unclear] + G -->|No| I[Ignored - not job-related] + + F --> J{You review in Inbox} + H --> J + J -->|Approve| K[Linked to selected job
Timeline updated] + J -->|Ignore| L[Marked as not relevant] +``` + +### What the AI Analyzes + +For each email, the Smart Router evaluates: + +- **Content Relevance**: Is this email about a job application lifecycle? +- **Job Matching**: Which of your "Applied" or "Processing" jobs does this relate to? +- **Message Type**: + - Interview invitation (phone screen, technical, onsite) + - Offer received + - Rejection/withdrawal + - General update/status change +- **Confidence Score**: 0-100% certainty of the match + +### Processing Outcomes + +| Confidence | Processing | Your Action Required | +| ----------------------- | ------------------------------ | ---------------------------------------- | +| **95-100%** | Auto-linked to job | None - appears in timeline automatically | +| **50-94%** | Pending review with suggestion | Quick approve/ignore in Inbox | +| **<50% (relevant)** | Pending review as orphan | Approve with manual job selection | +| **<50% (not relevant)** | Ignored | None - filtered out | + +## Setup + +### Prerequisites + +1. **Gmail account** with job application emails +2. **Google OAuth credentials** (see below) + +### Step 1: Create Google OAuth Credentials + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select existing +3. Enable the **Gmail API** +4. Configure **OAuth consent screen**: + - User Type: External + - Fill in app name, user support email, developer contact + - Add scope: `https://www.googleapis.com/auth/gmail.readonly` + - Add test users (your email) +5. Create **OAuth 2.0 Client ID**: + - Application type: Web application + - Authorized redirect URIs: + - `http://localhost:3005/oauth/gmail/callback` (local) + - `https://your-domain.com/oauth/gmail/callback` (production) +6. Copy the **Client ID** and **Client Secret** + +### Step 2: Configure Environment Variables + +Set these in your JobOps environment: + +```bash +GMAIL_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com +GMAIL_OAUTH_CLIENT_SECRET=your-client-secret +# Optional - defaults to /oauth/gmail/callback on current host +GMAIL_OAUTH_REDIRECT_URI=https://your-domain.com/oauth/gmail/callback +``` + +### Step 3: Connect Gmail in the UI + +1. Restart JobOps with the new environment variables +2. Navigate to **Tracking Inbox** in the dashboard +3. Click **Connect Gmail** +4. Authorize JobOps to access your Gmail (read-only scope) +5. You're connected! The system will now sync emails automatically + +## Using the Tracking Inbox + +### Reviewing Pending Items + +When emails need your review, they appear in the **Inbox**: + +1. Go to **Tracking Inbox** → **Inbox** tab +2. Each item shows: + - Sender and subject + - AI confidence score + - Suggested job match (if available) + - Message type (interview, offer, etc.) +3. Choose an action: + - **Approve**: Links to the suggested job (or select a different one) + - **Ignore**: Marks as not relevant + +### Understanding Confidence Scores + +- **Green (95-100%)**: High confidence, auto-processed +- **Yellow (50-94%)**: Moderate confidence, needs review +- **Red (<50%)**: Low confidence or unclear match + +### Timeline Updates + +When you approve an email (or it's auto-approved), the system: + +1. Creates a timeline event for the job +2. Updates the job stage (e.g., "Interview Scheduled", "Offer Received") +3. Records the event date from the email + +## Privacy & Security + +### What Data is Sent to AI + +Only minimal job metadata is sent for matching: + +- Company name +- Job title +- Snippets of email content + +### Gmail Permissions + +- **Scope**: `gmail.readonly` only +- **Access**: Read-only, cannot send/delete emails +- **Data Storage**: Email metadata stored locally in your SQLite database + +### Data Retention + +- Sync history retained for debugging +- You can disconnect Gmail at any time +- All email data is local to your instance + +## Troubleshooting + +### Common Issues + +**"No refresh token" error** + +- Disconnect and reconnect Gmail +- This forces a fresh consent flow + +**Emails not appearing** + +- Check sync run history in Tracking Inbox → Runs +- Verify Gmail OAuth credentials are correct +- Ensure email subjects match recruitment keywords + +**Wrong job matches** + +- This is expected for low-confidence matches +- Use the Inbox to correct matches + +### Viewing Sync History + +Go to **Tracking Inbox** → **Runs** to see: + +- When syncs ran +- How many messages were discovered +- How many were auto-linked vs. pending review +- Any errors that occurred + +## Configuration + +### Environment Variables + +| Variable | Required | Description | +| --------------------------- | -------- | ------------------------------ | +| `GMAIL_OAUTH_CLIENT_ID` | Yes | Google OAuth client ID | +| `GMAIL_OAUTH_CLIENT_SECRET` | Yes | Google OAuth client secret | +| `GMAIL_OAUTH_REDIRECT_URI` | No | Custom redirect URI (optional) | + +### Advanced Settings + +Currently, the feature uses sensible defaults: + +- Searches last 30 days of emails +- Looks for recruitment-related keywords in subjects +- Processes up to 100 messages per sync +- Runs automatically when you open the Tracking Inbox + +## API Reference + +### REST Endpoints + +| Method | Endpoint | Description | +| ------ | ----------------------------------------- | --------------------- | +| GET | `/api/post-application/inbox` | List pending messages | +| POST | `/api/post-application/inbox/:id/approve` | Approve message | +| POST | `/api/post-application/inbox/:id/deny` | Ignore message | +| GET | `/api/post-application/runs` | List sync runs | +| POST | `/api/post-application/gmail/connect` | Start OAuth flow | +| GET | `/api/post-application/gmail/callback` | OAuth callback | + +### Example: Approve an Inbox Item + +```bash +curl -X POST http://localhost:3005/api/post-application/inbox/msg_123/approve \ + -H "Content-Type: application/json" \ + -d '{ + "jobId": "job_456", + "note": "Phone screen scheduled" + }' +``` + +## Best Practices + +1. **Review regularly**: Check your Inbox weekly to stay on top of pending matches +2. **Be decisive**: Approve or ignore items quickly to keep your Inbox clean +3. **Correct mismatches**: If the AI suggests the wrong job, select the correct one when approving +4. **Monitor sync runs**: Check the Runs tab occasionally to ensure syncing is working +5. **Privacy first**: Remember only minimal job data is sent to AI - your email content stays private + +## Future Enhancements + +Potential improvements planned: + +- Multiple email provider support (Outlook, etc.) +- IMAP support for non-Gmail accounts +- Calendar integration for interview scheduling diff --git a/documentation/self-hosting.md b/documentation/self-hosting.md index c6fc30c..a343a9e 100644 --- a/documentation/self-hosting.md +++ b/documentation/self-hosting.md @@ -14,13 +14,14 @@ No environment variables are strictly required to start. Simply run: docker compose up -d ``` -This pulls the pre-built image from **GitHub Container Registry (GHCR)** and starts the API, UI, and scrapers in a single container. The image is multi-arch (supports `amd64` and `arm64`), making it compatible with Apple Silicon and Raspberry Pi. +This pulls the pre-built image from **GitHub Container Registry (GHCR)** and starts the API, UI, and scrapers in a single container. The image is multi-arch (supports `amd64` and `arm64`), making it compatible with Apple Silicon and Raspberry Pi. If you want to build it yourself, you can run `docker compose up -d --build`. ## 2) Access the app and Onboard Open your browser to: + - **Dashboard**: http://localhost:3005 On first launch, you will be greeted by an **Onboarding Wizard**. The app will help you validate and save your configuration: @@ -33,9 +34,79 @@ The app saves these to its persistent database, so you don't need to manage `.en Upgrade note: `OPENROUTER_API_KEY` is deprecated. Existing OpenRouter keys are automatically migrated/copied to `LLM_API_KEY` so you don't lose them. +## Gmail OAuth (Post-Application Inbox) + +If you want to connect Gmail in the Tracking Inbox page, configure Google OAuth credentials for the API server. + +### 1) Create Google OAuth credentials + +In Google Cloud: + +1. Open your project (or create one), then configure the OAuth consent screen. +2. Enable the Gmail API. +3. Create an OAuth client ID (`Web application` type). +4. Add an authorized redirect URI: + - `http://localhost:3005/oauth/gmail/callback` (default local setup) + - or your deployed app URL, for example `https://your-domain.com/oauth/gmail/callback` + +### 2) Configure environment variables + +Set these on the JobOps container: + +- `GMAIL_OAUTH_CLIENT_ID` (required) +- `GMAIL_OAUTH_CLIENT_SECRET` (required) +- `GMAIL_OAUTH_REDIRECT_URI` (optional, recommended for production) + - If omitted, JobOps derives it from the incoming request host as `/oauth/gmail/callback`. + +### 3) Restart and connect + +After setting env vars, restart the container and use `Tracking Inbox -> Connect Gmail`. + +Notes: + +- JobOps requests `gmail.readonly` scope. +- If Google returns no refresh token, disconnect and re-connect to force a fresh consent flow. + +## Email-to-Job Matching Overview + +When Gmail sync runs, your emails are automatically analyzed and routed. Here's what happens: + +```mermaid +flowchart TD + A[Recruitment email arrives in Gmail] --> B[Smart Router AI analyzes content] + B --> C{How confident is the match?} + + C -->|95-100%| D[Auto-linked to job] + D --> E[Timeline updated automatically] + + C -->|50-94%| F[Goes to Inbox for review
with suggested job match] + + C -->|<50%| G{Is it relevant?} + G -->|Yes| H[Goes to Inbox as orphan
relevant but job unclear] + G -->|No| I[Ignored - not job-related] + + F --> J{You review in Inbox} + H --> J + J -->|Approve| K[Linked to selected job
Timeline updated] + J -->|Ignore| L[Marked as not relevant] +``` + +**What the AI looks for:** +- Content relevance - Is this about job applications? +- Job matching - Which of your tracked jobs is this about? +- Message type - Interview, offer, rejection, or update? + +**Your control:** +- High-confidence matches (95%+) happen automatically +- Everything else appears in your Inbox for a quick yes/no decision +- You can always correct the job match when approving + +**Privacy note:** Only job ID, company name, and title are sent to the AI for matching. Full email content stays local. + ## Persistent data `./data` is bind-mounted into the container. It stores: + - SQLite DB: `data/jobs.db` (contains your API keys and configuration) - Generated PDFs: `data/pdfs/` - Template resume selection: Stored internally after selection. @@ -45,6 +116,7 @@ Upgrade note: `OPENROUTER_API_KEY` is deprecated. Existing OpenRouter keys are a For a public sandbox website, set `DEMO_MODE=true` on the container. Behavior in demo mode: + - **Works (local demo DB):** browsing, filtering, job status updates, timeline edits. - **Simulated (no external side effects):** pipeline run, job summarize/process/rescore/pdf/apply, onboarding validations. - **Blocked:** settings writes, database clear, backup create/delete, status bulk deletes. diff --git a/orchestrator/README.md b/orchestrator/README.md index d501d04..6ef8f3e 100644 --- a/orchestrator/README.md +++ b/orchestrator/README.md @@ -78,6 +78,17 @@ orchestrator/ | POST | `/api/pipeline/run` | Trigger pipeline manually | | POST | `/api/webhook/trigger` | Webhook for n8n (use `WEBHOOK_SECRET`) | +### Post-Application Tracking + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/post-application/inbox` | List pending messages for review | +| POST | `/api/post-application/inbox/:id/approve` | Approve and link to job | +| POST | `/api/post-application/inbox/:id/deny` | Ignore message | +| GET | `/api/post-application/runs` | List sync run history | +| POST | `/api/post-application/gmail/connect` | Initiate Gmail OAuth flow | +| GET | `/api/post-application/gmail/callback` | Gmail OAuth callback | + ## Daily Flow 1. **17:00 - n8n triggers pipeline:** @@ -94,6 +105,11 @@ orchestrator/ - Download PDF and apply manually - Click "Mark Applied" to mark application status +3. **Track responses (optional):** + - Connect Gmail in Tracking Inbox settings + - Automatic email monitoring for interview invites, offers, rejections + - Review and approve/ignore matched emails in the Inbox + ## n8n Setup Create a workflow with: diff --git a/orchestrator/package.json b/orchestrator/package.json index 7196db0..3d7ed48 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -55,6 +55,7 @@ "drizzle-orm": "^0.38.2", "express": "^4.18.2", "get-tsconfig": "^4.10.0", + "html-to-text": "^9.0.5", "jsdom": "^25.0.1", "lucide-react": "^0.561.0", "next-themes": "^0.4.6", @@ -79,6 +80,7 @@ "@types/better-sqlite3": "^7.6.8", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/html-to-text": "^9.0.4", "@types/jsdom": "^27.0.0", "@types/node": "^22.10.1", "@types/react": "18.3.12", diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 5209f52..2ea1265 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -10,10 +10,12 @@ import { Toaster } from "@/components/ui/sonner"; import { BasicAuthPrompt } from "./components/BasicAuthPrompt"; import { OnboardingGate } from "./components/OnboardingGate"; import { useDemoInfo } from "./hooks/useDemoInfo"; +import { GmailOauthCallbackPage } from "./pages/GmailOauthCallbackPage"; import { HomePage } from "./pages/HomePage"; import { JobPage } from "./pages/JobPage"; import { OrchestratorPage } from "./pages/OrchestratorPage"; import { SettingsPage } from "./pages/SettingsPage"; +import { TrackingInboxPage } from "./pages/TrackingInboxPage"; import { VisaSponsorsPage } from "./pages/VisaSponsorsPage"; /** Backwards-compatibility redirects: old URL paths -> new URL paths */ @@ -76,9 +78,14 @@ export const App: React.FC = () => { {/* Application routes */} } /> + } + /> } /> } /> } /> + } /> } /> ; +}): Promise { + const provider = input.provider ?? "gmail"; + return fetchApi( + `/post-application/providers/${provider}/actions/connect`, + { + method: "POST", + body: JSON.stringify({ + ...(input.accountKey ? { accountKey: input.accountKey } : {}), + ...(input.payload ? { payload: input.payload } : {}), + }), + }, + ); +} + +export async function postApplicationGmailOauthStart(input?: { + accountKey?: string; +}): Promise<{ + provider: "gmail"; + accountKey: string; + authorizationUrl: string; + state: string; +}> { + const params = new URLSearchParams(); + if (input?.accountKey) params.set("accountKey", input.accountKey); + const query = params.toString(); + return fetchApi<{ + provider: "gmail"; + accountKey: string; + authorizationUrl: string; + state: string; + }>( + `/post-application/providers/gmail/oauth/start${query ? `?${query}` : ""}`, + ); +} + +export async function postApplicationGmailOauthExchange(input: { + accountKey?: string; + state: string; + code: string; +}): Promise { + return fetchApi( + "/post-application/providers/gmail/oauth/exchange", + { + method: "POST", + body: JSON.stringify({ + ...(input.accountKey ? { accountKey: input.accountKey } : {}), + state: input.state, + code: input.code, + }), + }, + ); +} + +export async function postApplicationProviderStatus(input?: { + provider?: PostApplicationProvider; + accountKey?: string; +}): Promise { + const provider = input?.provider ?? "gmail"; + return fetchApi( + `/post-application/providers/${provider}/actions/status`, + { + method: "POST", + body: JSON.stringify({ + ...(input?.accountKey ? { accountKey: input.accountKey } : {}), + }), + }, + ); +} + +export async function postApplicationProviderSync(input?: { + provider?: PostApplicationProvider; + accountKey?: string; + maxMessages?: number; + searchDays?: number; +}): Promise { + const provider = input?.provider ?? "gmail"; + return fetchApi( + `/post-application/providers/${provider}/actions/sync`, + { + method: "POST", + body: JSON.stringify({ + ...(input?.accountKey ? { accountKey: input.accountKey } : {}), + ...(typeof input?.maxMessages === "number" + ? { maxMessages: input.maxMessages } + : {}), + ...(typeof input?.searchDays === "number" + ? { searchDays: input.searchDays } + : {}), + }), + }, + ); +} + +export async function postApplicationProviderDisconnect(input?: { + provider?: PostApplicationProvider; + accountKey?: string; +}): Promise { + const provider = input?.provider ?? "gmail"; + return fetchApi( + `/post-application/providers/${provider}/actions/disconnect`, + { + method: "POST", + body: JSON.stringify({ + ...(input?.accountKey ? { accountKey: input.accountKey } : {}), + }), + }, + ); +} + +export async function getPostApplicationInbox(input?: { + provider?: PostApplicationProvider; + accountKey?: string; + limit?: number; +}): Promise<{ items: PostApplicationInboxItem[]; total: number }> { + const params = new URLSearchParams(); + params.set("provider", input?.provider ?? "gmail"); + params.set("accountKey", input?.accountKey ?? "default"); + if (typeof input?.limit === "number") + params.set("limit", String(input.limit)); + const query = params.toString(); + return fetchApi<{ items: PostApplicationInboxItem[]; total: number }>( + `/post-application/inbox?${query}`, + ); +} + +export async function approvePostApplicationInboxItem(input: { + messageId: string; + provider?: PostApplicationProvider; + accountKey?: string; + jobId?: string; + stageTarget?: PostApplicationRouterStageTarget; + toStage?: ApplicationStage; + note?: string; + decidedBy?: string; +}): Promise<{ + message: PostApplicationInboxItem["message"]; + stageEventId: string | null; +}> { + return fetchApi<{ + message: PostApplicationInboxItem["message"]; + stageEventId: string | null; + }>(`/post-application/inbox/${encodeURIComponent(input.messageId)}/approve`, { + method: "POST", + body: JSON.stringify({ + provider: input.provider ?? "gmail", + accountKey: input.accountKey ?? "default", + ...(input.jobId ? { jobId: input.jobId } : {}), + ...(input.stageTarget ? { stageTarget: input.stageTarget } : {}), + ...(input.toStage ? { toStage: input.toStage } : {}), + ...(input.note ? { note: input.note } : {}), + ...(input.decidedBy ? { decidedBy: input.decidedBy } : {}), + }), + }); +} + +export async function denyPostApplicationInboxItem(input: { + messageId: string; + provider?: PostApplicationProvider; + accountKey?: string; + decidedBy?: string; +}): Promise<{ + message: PostApplicationInboxItem["message"]; +}> { + return fetchApi<{ message: PostApplicationInboxItem["message"] }>( + `/post-application/inbox/${encodeURIComponent(input.messageId)}/deny`, + { + method: "POST", + body: JSON.stringify({ + provider: input.provider ?? "gmail", + accountKey: input.accountKey ?? "default", + ...(input.decidedBy ? { decidedBy: input.decidedBy } : {}), + }), + }, + ); +} + +export async function bulkPostApplicationInboxAction(input: { + action: BulkPostApplicationAction; + provider?: PostApplicationProvider; + accountKey?: string; + decidedBy?: string; +}): Promise { + return fetchApi( + "/post-application/inbox/bulk", + { + method: "POST", + body: JSON.stringify({ + action: input.action, + provider: input.provider ?? "gmail", + accountKey: input.accountKey ?? "default", + ...(input.decidedBy ? { decidedBy: input.decidedBy } : {}), + }), + }, + ); +} + +export async function getPostApplicationRuns(input?: { + provider?: PostApplicationProvider; + accountKey?: string; + limit?: number; +}): Promise<{ runs: PostApplicationSyncRun[]; total: number }> { + const params = new URLSearchParams(); + params.set("provider", input?.provider ?? "gmail"); + params.set("accountKey", input?.accountKey ?? "default"); + if (typeof input?.limit === "number") + params.set("limit", String(input.limit)); + const query = params.toString(); + return fetchApi<{ runs: PostApplicationSyncRun[]; total: number }>( + `/post-application/runs?${query}`, + ); +} + +export async function getPostApplicationRunMessages(input: { + runId: string; + provider?: PostApplicationProvider; + accountKey?: string; + limit?: number; +}): Promise<{ + run: PostApplicationSyncRun; + items: PostApplicationInboxItem[]; + total: number; +}> { + const params = new URLSearchParams(); + params.set("provider", input.provider ?? "gmail"); + params.set("accountKey", input.accountKey ?? "default"); + if (typeof input.limit === "number") params.set("limit", String(input.limit)); + const query = params.toString(); + return fetchApi<{ + run: PostApplicationSyncRun; + items: PostApplicationInboxItem[]; + total: number; + }>( + `/post-application/runs/${encodeURIComponent(input.runId)}/messages?${query}`, + ); +} + export async function getDemoInfo(): Promise { return fetchApi("/demo/info"); } diff --git a/orchestrator/src/client/components/navigation.ts b/orchestrator/src/client/components/navigation.ts index 0507e6e..fa76c45 100644 --- a/orchestrator/src/client/components/navigation.ts +++ b/orchestrator/src/client/components/navigation.ts @@ -1,4 +1,4 @@ -import { Home, LayoutDashboard, Settings, Shield } from "lucide-react"; +import { Home, Inbox, LayoutDashboard, Settings, Shield } from "lucide-react"; export type NavLink = { to: string; @@ -20,6 +20,7 @@ export const NAV_LINKS: NavLink[] = [ "/jobs/all", ], }, + { to: "/tracking-inbox", label: "Tracking Inbox", icon: Inbox }, { to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield }, { to: "/settings", label: "Settings", icon: Settings }, ]; diff --git a/orchestrator/src/client/pages/GmailOauthCallbackPage.tsx b/orchestrator/src/client/pages/GmailOauthCallbackPage.tsx new file mode 100644 index 0000000..f803e15 --- /dev/null +++ b/orchestrator/src/client/pages/GmailOauthCallbackPage.tsx @@ -0,0 +1,38 @@ +import type React from "react"; +import { useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; + +export const GmailOauthCallbackPage: React.FC = () => { + const [searchParams] = useSearchParams(); + + useEffect(() => { + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + + if (window.opener && !window.opener.closed) { + window.opener.postMessage( + { + type: "gmail-oauth-result", + code, + state, + error, + }, + window.location.origin, + ); + } + + window.close(); + }, [searchParams]); + + return ( +
+
+

Completing Gmail connection…

+

+ You can close this window if it does not close automatically. +

+
+
+ ); +}; diff --git a/orchestrator/src/client/pages/TrackingInboxPage.test.tsx b/orchestrator/src/client/pages/TrackingInboxPage.test.tsx new file mode 100644 index 0000000..29d1ed6 --- /dev/null +++ b/orchestrator/src/client/pages/TrackingInboxPage.test.tsx @@ -0,0 +1,246 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "../api"; +import { TrackingInboxPage } from "./TrackingInboxPage"; + +vi.mock("../api", () => ({ + postApplicationProviderStatus: vi.fn(), + getPostApplicationInbox: vi.fn(), + getPostApplicationRuns: vi.fn(), + getJobs: vi.fn(), + approvePostApplicationInboxItem: vi.fn(), + denyPostApplicationInboxItem: vi.fn(), + getPostApplicationRunMessages: vi.fn(), + postApplicationGmailOauthStart: vi.fn(), + postApplicationGmailOauthExchange: vi.fn(), + postApplicationProviderSync: vi.fn(), + postApplicationProviderDisconnect: vi.fn(), +})); + +function makeInboxItem() { + return { + message: { + id: "msg-1", + provider: "gmail" as const, + accountKey: "default", + integrationId: null, + syncRunId: null, + externalMessageId: "ext-1", + externalThreadId: null, + fromAddress: "jobs@example.com", + fromDomain: "example.com", + senderName: "Recruiting", + subject: "Interview invite", + receivedAt: Date.now(), + snippet: "Let's schedule", + classificationLabel: "interview", + classificationConfidence: 0.95, + classificationPayload: null, + relevanceLlmScore: 95, + relevanceDecision: "relevant" as const, + matchedJobId: "job-2", + matchConfidence: 95, + stageTarget: "technical_interview" as const, + messageType: "interview" as const, + stageEventPayload: null, + processingStatus: "pending_user" as const, + decidedAt: null, + decidedBy: null, + errorCode: null, + errorMessage: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + matchedJob: { + id: "job-2", + title: "Software Engineer", + employer: "Example", + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(api.postApplicationProviderStatus).mockResolvedValue({ + provider: "gmail", + action: "status", + accountKey: "default", + status: { + provider: "gmail", + accountKey: "default", + connected: true, + integration: { + id: "int-1", + provider: "gmail", + accountKey: "default", + displayName: null, + status: "connected", + credentials: null, + lastConnectedAt: null, + lastSyncedAt: null, + lastError: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }, + }); + vi.mocked(api.getPostApplicationInbox).mockResolvedValue({ + items: [makeInboxItem()], + total: 1, + }); + vi.mocked(api.getPostApplicationRuns).mockResolvedValue({ + runs: [], + total: 0, + }); + vi.mocked(api.getJobs).mockResolvedValue({ + jobs: [ + { + id: "job-1", + source: "manual", + title: "Software Engineer I", + employer: "Example", + jobUrl: "https://example.com/job-1", + applicationLink: null, + datePosted: null, + deadline: null, + salary: null, + location: null, + status: "applied", + suitabilityScore: null, + sponsorMatchScore: null, + jobType: null, + jobFunction: null, + salaryMinAmount: null, + salaryMaxAmount: null, + salaryCurrency: null, + discoveredAt: new Date().toISOString(), + appliedAt: null, + updatedAt: new Date().toISOString(), + }, + { + id: "job-2", + source: "manual", + title: "Software Engineer II", + employer: "Example", + jobUrl: "https://example.com/job-2", + applicationLink: null, + datePosted: null, + deadline: null, + salary: null, + location: null, + status: "applied", + suitabilityScore: null, + sponsorMatchScore: null, + jobType: null, + jobFunction: null, + salaryMinAmount: null, + salaryMaxAmount: null, + salaryCurrency: null, + discoveredAt: new Date().toISOString(), + appliedAt: null, + updatedAt: new Date().toISOString(), + }, + ], + total: 2, + byStatus: { + discovered: 0, + processing: 0, + ready: 0, + applied: 2, + skipped: 0, + expired: 0, + }, + revision: "r1", + } as Awaited>); + vi.mocked(api.approvePostApplicationInboxItem).mockResolvedValue({ + message: makeInboxItem().message, + stageEventId: "evt-1", + }); + vi.mocked(api.denyPostApplicationInboxItem).mockResolvedValue({ + message: { + ...makeInboxItem().message, + processingStatus: "ignored", + matchedJobId: null, + }, + }); + vi.mocked(api.getPostApplicationRunMessages).mockResolvedValue({ + run: { + id: "run-1", + provider: "gmail", + accountKey: "default", + integrationId: null, + status: "completed", + startedAt: Date.now(), + completedAt: Date.now(), + messagesDiscovered: 1, + messagesRelevant: 1, + messagesClassified: 1, + messagesMatched: 1, + messagesApproved: 0, + messagesDenied: 0, + messagesErrored: 0, + errorCode: null, + errorMessage: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + items: [makeInboxItem()], + total: 1, + }); +}); + +describe("TrackingInboxPage", () => { + it("renders pending messages", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("Interview invite")).toBeInTheDocument(); + }); + }); + + it("submits approve action", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("Interview invite")).toBeInTheDocument(); + }); + + fireEvent.click( + screen.getByRole("button", { name: "Confirm email-job match" }), + ); + + await waitFor(() => { + expect(api.approvePostApplicationInboxItem).toHaveBeenCalled(); + }); + expect(api.approvePostApplicationInboxItem).toHaveBeenCalledWith( + expect.objectContaining({ + jobId: "job-2", + }), + ); + }); + + it("loads dropdown jobs excluding discovered status", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(api.getJobs).toHaveBeenCalledWith({ + statuses: ["applied"], + view: "list", + }); + }); + }); +}); diff --git a/orchestrator/src/client/pages/TrackingInboxPage.tsx b/orchestrator/src/client/pages/TrackingInboxPage.tsx new file mode 100644 index 0000000..16a83d2 --- /dev/null +++ b/orchestrator/src/client/pages/TrackingInboxPage.tsx @@ -0,0 +1,867 @@ +import type { + JobListItem, + PostApplicationInboxItem, + PostApplicationProvider, + PostApplicationSyncRun, +} from "@shared/types"; +import { POST_APPLICATION_PROVIDERS } from "@shared/types"; +import { + CheckCircle, + Inbox, + Link2, + Loader2, + RefreshCcw, + Unplug, + Upload, + XCircle, +} from "lucide-react"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { formatDateTime } from "@/lib/utils"; +import * as api from "../api"; +import { EmptyState, PageHeader, PageMain } from "../components"; +import { EmailViewerList } from "./tracking-inbox/EmailViewerList"; + +const PROVIDER_OPTIONS: PostApplicationProvider[] = [ + ...POST_APPLICATION_PROVIDERS, +]; +const GMAIL_OAUTH_RESULT_TYPE = "gmail-oauth-result"; +const GMAIL_OAUTH_TIMEOUT_MS = 3 * 60 * 1000; + +type GmailOauthResultMessage = { + type: string; + state?: string; + code?: string; + error?: string; +}; + +function formatEpochMs(value?: number | null): string { + if (!value) return "n/a"; + return formatDateTime(new Date(value).toISOString()) ?? "n/a"; +} + +export const TrackingInboxPage: React.FC = () => { + const [provider, setProvider] = useState("gmail"); + const [accountKey, setAccountKey] = useState("default"); + const [maxMessages, setMaxMessages] = useState("100"); + const [searchDays, setSearchDays] = useState("90"); + + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isActionLoading, setIsActionLoading] = useState(false); + const [activeAction, setActiveAction] = useState< + "connect" | "sync" | "disconnect" | null + >(null); + + const [status, setStatus] = useState< + | Awaited>["status"] + | null + >(null); + const [inbox, setInbox] = useState([]); + const [runs, setRuns] = useState([]); + const [isRunModalOpen, setIsRunModalOpen] = useState(false); + const [isRunMessagesLoading, setIsRunMessagesLoading] = useState(false); + const [selectedRun, setSelectedRun] = useState( + null, + ); + const [selectedRunItems, setSelectedRunItems] = useState< + PostApplicationInboxItem[] + >([]); + + const [appliedJobByMessageId, setAppliedJobByMessageId] = useState< + Record + >({}); + const [appliedJobs, setAppliedJobs] = useState([]); + const [isAppliedJobsLoading, setIsAppliedJobsLoading] = useState(false); + const [hasAttemptedAppliedJobsLoad, setHasAttemptedAppliedJobsLoad] = + useState(false); + + const [bulkActionDialog, setBulkActionDialog] = useState<{ + isOpen: boolean; + action: "approve" | "deny" | null; + itemCount: number; + }>({ isOpen: false, action: null, itemCount: 0 }); + + const loadAppliedJobs = useCallback(async () => { + if (hasAttemptedAppliedJobsLoad || isAppliedJobsLoading) return; + setHasAttemptedAppliedJobsLoad(true); + setIsAppliedJobsLoading(true); + try { + const response = await api.getJobs({ + statuses: ["applied"], + view: "list", + }); + setAppliedJobs(response.jobs.filter((job) => job.status === "applied")); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load jobs"; + toast.error(message); + } finally { + setIsAppliedJobsLoading(false); + } + }, [hasAttemptedAppliedJobsLoad, isAppliedJobsLoading]); + + const loadAll = useCallback(async () => { + const [statusRes, inboxRes, runsRes] = await Promise.all([ + api.postApplicationProviderStatus({ provider, accountKey }), + api.getPostApplicationInbox({ provider, accountKey, limit: 100 }), + api.getPostApplicationRuns({ provider, accountKey, limit: 20 }), + ]); + + setStatus(statusRes.status); + setInbox(inboxRes.items); + setRuns(runsRes.runs); + }, [provider, accountKey]); + + const refresh = useCallback(async () => { + setIsRefreshing(true); + try { + await loadAll(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to refresh tracking inbox"; + toast.error(message); + } finally { + setIsRefreshing(false); + setIsLoading(false); + } + }, [loadAll]); + + useEffect(() => { + setIsLoading(true); + void refresh(); + }, [refresh]); + + useEffect(() => { + if (!provider || !accountKey) return; + setAppliedJobs([]); + setAppliedJobByMessageId({}); + setHasAttemptedAppliedJobsLoad(false); + }, [provider, accountKey]); + + const hasReviewItems = useMemo( + () => inbox.length > 0 || selectedRunItems.length > 0, + [inbox.length, selectedRunItems.length], + ); + + useEffect(() => { + if (!hasReviewItems) return; + void loadAppliedJobs(); + }, [hasReviewItems, loadAppliedJobs]); + + useEffect(() => { + const defaultAppliedJobId = appliedJobs[0]?.id ?? ""; + setAppliedJobByMessageId((previous) => { + const next = { ...previous }; + for (const item of [...inbox, ...selectedRunItems]) { + const selectedJobId = next[item.message.id]; + const hasValidSelection = appliedJobs.some( + (appliedJob) => appliedJob.id === selectedJobId, + ); + if (!selectedJobId || !hasValidSelection) { + const matchedJobId = item.message.matchedJobId ?? ""; + const hasValidMatchedJob = appliedJobs.some( + (appliedJob) => appliedJob.id === matchedJobId, + ); + next[item.message.id] = hasValidMatchedJob + ? matchedJobId + : defaultAppliedJobId; + } + } + return next; + }); + }, [appliedJobs, inbox, selectedRunItems]); + + const waitForGmailOauthResult = useCallback( + ( + expectedState: string, + popup: Window, + ): Promise<{ code?: string; error?: string }> => { + return new Promise((resolve, reject) => { + let settled = false; + + const close = () => { + window.clearTimeout(timeoutId); + window.clearInterval(closedCheckId); + window.removeEventListener("message", onMessage); + }; + + const finishResolve = (value: { code?: string; error?: string }) => { + if (settled) return; + settled = true; + close(); + try { + popup.close(); + } catch { + // Ignore cross-window close errors. + } + resolve(value); + }; + + const finishReject = (message: string) => { + if (settled) return; + settled = true; + close(); + reject(new Error(message)); + }; + + const onMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + const data = event.data as GmailOauthResultMessage | undefined; + if (!data || data.type !== GMAIL_OAUTH_RESULT_TYPE) return; + if (data.state !== expectedState) return; + finishResolve({ + ...(data.code ? { code: data.code } : {}), + ...(data.error ? { error: data.error } : {}), + }); + }; + + const timeoutId = window.setTimeout(() => { + finishReject("Timed out waiting for Gmail OAuth response."); + }, GMAIL_OAUTH_TIMEOUT_MS); + + const closedCheckId = window.setInterval(() => { + if (!popup.closed) return; + finishReject("Gmail OAuth window was closed before completion."); + }, 250); + + window.addEventListener("message", onMessage); + }); + }, + [], + ); + + const runProviderAction = useCallback( + async (action: "connect" | "sync" | "disconnect") => { + setIsActionLoading(true); + setActiveAction(action); + let syncToastId: string | number | null = null; + try { + if (action === "connect") { + if (provider !== "gmail") { + toast.error( + `${provider} connect is not implemented yet. Use Gmail for now.`, + ); + return; + } + + const oauthStart = await api.postApplicationGmailOauthStart({ + accountKey, + }); + const popup = window.open( + oauthStart.authorizationUrl, + "gmail-oauth-connect", + "popup,width=520,height=720", + ); + if (!popup) { + toast.error( + "Browser blocked the Gmail OAuth popup. Allow popups and retry.", + ); + return; + } + + const oauthResult = await waitForGmailOauthResult( + oauthStart.state, + popup, + ); + if (oauthResult.error) { + throw new Error(`Gmail OAuth failed: ${oauthResult.error}`); + } + if (!oauthResult.code) { + throw new Error( + "Gmail OAuth did not return an authorization code.", + ); + } + + await api.postApplicationGmailOauthExchange({ + accountKey, + state: oauthStart.state, + code: oauthResult.code, + }); + toast.success("Provider connected"); + } else if (action === "sync") { + const parsedMaxMessages = Number.parseInt(maxMessages, 10); + const parsedSearchDays = Number.parseInt(searchDays, 10); + if ( + !Number.isFinite(parsedMaxMessages) || + parsedMaxMessages < 1 || + parsedMaxMessages > 500 || + !Number.isFinite(parsedSearchDays) || + parsedSearchDays < 1 || + parsedSearchDays > 365 + ) { + toast.error( + "Max messages must be 1-500 and search days must be 1-365 before syncing.", + ); + return; + } + syncToastId = toast.loading( + "Sync in progress. This may take up to a couple of minutes.", + ); + + await api.postApplicationProviderSync({ + provider, + accountKey, + maxMessages: parsedMaxMessages, + searchDays: parsedSearchDays, + }); + toast.success("Sync completed", { + ...(syncToastId ? { id: syncToastId } : {}), + }); + } else { + await api.postApplicationProviderDisconnect({ provider, accountKey }); + toast.success("Provider disconnected"); + } + + await refresh(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : `Failed to ${action} provider connection`; + if (syncToastId) { + toast.error(message, { id: syncToastId }); + } else { + toast.error(message); + } + } finally { + setActiveAction(null); + setIsActionLoading(false); + } + }, + [ + accountKey, + maxMessages, + provider, + refresh, + searchDays, + waitForGmailOauthResult, + ], + ); + + const handleDecision = useCallback( + async (item: PostApplicationInboxItem, decision: "approve" | "deny") => { + const selectedJobId = + appliedJobByMessageId[item.message.id] || item.message.matchedJobId; + + if (decision === "approve" && !selectedJobId) { + toast.error("Select an applied job before making a decision."); + return; + } + + setIsActionLoading(true); + try { + if (decision === "approve") { + await api.approvePostApplicationInboxItem({ + messageId: item.message.id, + provider, + accountKey, + jobId: selectedJobId ?? undefined, + stageTarget: item.message.stageTarget ?? undefined, + }); + toast.success("Message linked"); + } else { + await api.denyPostApplicationInboxItem({ + messageId: item.message.id, + provider, + accountKey, + }); + toast.success("Message ignored"); + } + + await refresh(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : `Failed to ${decision} message`; + toast.error(message); + } finally { + setIsActionLoading(false); + } + }, + [accountKey, appliedJobByMessageId, provider, refresh], + ); + + const handleBulkAction = useCallback( + async (action: "approve" | "deny") => { + if (inbox.length === 0) return; + + setIsActionLoading(true); + setBulkActionDialog({ isOpen: false, action: null, itemCount: 0 }); + + try { + const result = await api.bulkPostApplicationInboxAction({ + action, + provider, + accountKey, + }); + + const { succeeded, failed, skipped } = result; + const actionLabel = action === "approve" ? "approved" : "ignored"; + + if (failed === 0 && skipped === 0) { + toast.success(`All ${succeeded} messages ${actionLabel}`); + } else if (failed === 0) { + toast.success( + `${succeeded} messages ${actionLabel}, ${skipped} skipped (no suggested match)`, + ); + } else { + toast.error( + `${succeeded} ${actionLabel}, ${failed} failed, ${skipped} skipped`, + ); + } + + await refresh(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : `Failed to ${action} messages`; + toast.error(message); + } finally { + setIsActionLoading(false); + } + }, + [accountKey, inbox.length, provider, refresh], + ); + + const openBulkActionDialog = useCallback( + (action: "approve" | "deny") => { + const eligibleCount = + action === "approve" + ? inbox.filter((item) => item.matchedJob).length + : inbox.length; + + if (eligibleCount === 0) { + toast.error( + action === "approve" + ? "No messages with suggested job matches to approve" + : "No messages to ignore", + ); + return; + } + + setBulkActionDialog({ + isOpen: true, + action, + itemCount: eligibleCount, + }); + }, + [inbox], + ); + + const handleOpenRunMessages = useCallback( + async (run: PostApplicationSyncRun) => { + setSelectedRun(run); + setSelectedRunItems([]); + setIsRunModalOpen(true); + setIsRunMessagesLoading(true); + + try { + const response = await api.getPostApplicationRunMessages({ + runId: run.id, + provider, + accountKey, + }); + setSelectedRun(response.run); + setSelectedRunItems(response.items); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to load messages for selected sync run"; + toast.error(message); + } finally { + setIsRunMessagesLoading(false); + } + }, + [accountKey, provider], + ); + + const pendingCount = inbox.length; + const isConnected = Boolean(status?.connected); + const connectionLabel = useMemo(() => { + if (!status) return "Unknown"; + if (!status.connected) return "Disconnected"; + if (status.integration?.status === "error") return "Error"; + return "Connected"; + }, [status]); + + const handleAppliedJobChange = useCallback( + (messageId: string, value: string) => { + setAppliedJobByMessageId((previous) => ({ + ...previous, + [messageId]: value, + })); + }, + [], + ); + + return ( + <> + void refresh()} + disabled={isRefreshing || isLoading} + className="gap-2" + > + {isRefreshing ? ( + + ) : ( + + )} + Refresh + + } + /> + + +
+
+

+ Application Inbox Matching +

+
+

+ Connect your inbox to ingest related emails, review the suggested + job matches, and approve or deny to automatically update your + tracking timeline. +

+
+ + + + Provider Controls + + +
+
+ + +
+ +
+ + setAccountKey(event.target.value)} + /> +
+
+

+ Gmail connect uses Google OAuth popup and stores credentials + server-side. No manual refresh token paste is needed. +

+ +
+
+ + setMaxMessages(event.target.value)} + /> +
+
+ + setSearchDays(event.target.value)} + /> +
+
+ {!isConnected ? ( + + ) : null} + + {isConnected ? ( + + ) : null} +
+
+ +
+ + {connectionLabel} + + + Pending review:{" "} + {pendingCount} + + {status?.integration?.lastSyncedAt ? ( + + Last synced: {formatEpochMs(status.integration.lastSyncedAt)} + + ) : null} +
+
+
+ + + + Pending Review Queue + {inbox.length > 0 && ( +
+ + +
+ )} +
+ + {isLoading ? ( +
+ + Loading inbox... +
+ ) : inbox.length === 0 ? ( + + ) : ( + + void handleDecision(item, decision) + } + isActionLoading={isActionLoading} + isAppliedJobsLoading={isAppliedJobsLoading} + /> + )} +
+
+ + + + Recent Sync Runs + + + {runs.length === 0 ? ( +

No sync runs yet.

+ ) : ( +
+ {runs.map((run) => ( + + ))} +
+ )} +
+
+
+ + { + setIsRunModalOpen(open); + if (!open) { + setSelectedRunItems([]); + setSelectedRun(null); + } + }} + > + + + Run Messages + + {selectedRun + ? `Run ${selectedRun.id} • discovered ${selectedRun.messagesDiscovered} • relevant ${selectedRun.messagesRelevant} • matched ${selectedRun.messagesMatched}` + : "Review all messages captured in this sync run, including partial matches."} + + + +
+ {isRunMessagesLoading ? ( +
+ + Loading run messages... +
+ ) : selectedRunItems.length === 0 ? ( +

+ No messages found for this run. +

+ ) : ( + + void handleDecision(item, decision) + } + isActionLoading={isActionLoading} + isAppliedJobsLoading={isAppliedJobsLoading} + /> + )} +
+
+
+ + + setBulkActionDialog((previous) => ({ ...previous, isOpen: open })) + } + > + + + + {bulkActionDialog.action === "approve" + ? "Approve All Messages?" + : "Ignore All Messages?"} + + + {bulkActionDialog.action === "approve" + ? `This will approve ${bulkActionDialog.itemCount} message${bulkActionDialog.itemCount === 1 ? "" : "s"} with suggested job matches. Messages without matches will be skipped.` + : `This will ignore all ${bulkActionDialog.itemCount} pending message${bulkActionDialog.itemCount === 1 ? "" : "s"}.`} + + + + Cancel + { + if (bulkActionDialog.action) { + void handleBulkAction(bulkActionDialog.action); + } + }} + > + {bulkActionDialog.action === "approve" + ? "Approve All" + : "Ignore All"} + + + + + + ); +}; diff --git a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx index 351c95a..ea2d24a 100644 --- a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx +++ b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx @@ -1,4 +1,3 @@ -import * as PopoverPrimitive from "@radix-ui/react-popover"; import { formatCountryLabel, isSourceAllowedForCountry, @@ -6,7 +5,7 @@ import { SUPPORTED_COUNTRY_KEYS, } from "@shared/location-support.js"; import type { AppSettings, JobSource } from "@shared/types"; -import { Check, ChevronsUpDown, Loader2, Sparkles, X } from "lucide-react"; +import { Loader2, Sparkles, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { @@ -17,17 +16,9 @@ import { } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Popover, PopoverTrigger } from "@/components/ui/popover"; +import { SearchableDropdown } from "@/components/ui/searchable-dropdown"; import { Separator } from "@/components/ui/separator"; import { Tooltip, @@ -35,7 +26,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { cn, sourceLabel } from "@/lib/utils"; +import { sourceLabel } from "@/lib/utils"; import { AUTOMATIC_PRESETS, type AutomaticPresetId, @@ -131,7 +122,6 @@ export const AutomaticRunTab: React.FC = ({ }) => { const [isSaving, setIsSaving] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); - const [countryMenuOpen, setCountryMenuOpen] = useState(false); const { watch, reset, setValue, getValues } = useForm( { defaultValues: { @@ -193,7 +183,6 @@ export const AutomaticRunTab: React.FC = ({ searchTermDraft: "", }); setAdvancedOpen(false); - setCountryMenuOpen(false); }, [open, settings, reset]); const addSearchTerms = (input: string) => { @@ -311,7 +300,14 @@ export const AutomaticRunTab: React.FC = ({ } }; - const countryOptions = SUPPORTED_COUNTRY_KEYS; + const countryOptions = useMemo( + () => + SUPPORTED_COUNTRY_KEYS.map((country) => ({ + value: country, + label: formatCountryLabel(country), + })), + [], + ); return (
@@ -357,69 +353,20 @@ export const AutomaticRunTab: React.FC = ({
- - - - - - - - event.stopPropagation()} - > - No matching countries. - - {countryOptions.map((country) => { - const selected = values.country === country; - const label = formatCountryLabel(country); - return ( - { - setValue("country", country, { - shouldDirty: true, - }); - setCountryMenuOpen(false); - }} - > - {label} - - - ); - })} - - - - - + + setValue("country", country, { + shouldDirty: true, + }) + } + placeholder="Select country" + searchPlaceholder="Search country..." + emptyText="No matching countries." + triggerClassName="h-9 w-full md:max-w-xs" + ariaLabel={formatCountryLabel(values.country)} + />
void; + onApprove: () => void; + onDeny: () => void; + isActionLoading: boolean; + isAppliedJobsLoading: boolean; +}; + +export type EmailViewerListProps = { + items: PostApplicationInboxItem[]; + appliedJobs: JobListItem[]; + appliedJobByMessageId: Record; + onAppliedJobChange: (messageId: string, value: string) => void; + onDecision: ( + item: PostApplicationInboxItem, + decision: "approve" | "deny", + ) => void; + isActionLoading: boolean; + isAppliedJobsLoading: boolean; +}; + +function formatEpochMs(value?: number | null): string { + if (!value) return "n/a"; + return formatDateTime(new Date(value).toISOString()) ?? "n/a"; +} + +function getSenderLabel( + senderName: string | null, + fromAddress: string, +): string { + const preferred = (senderName ?? "").trim(); + if (preferred) return preferred; + const trimmed = fromAddress.trim(); + if (!trimmed) return "Unknown sender"; + const bracketIndex = trimmed.indexOf("<"); + if (bracketIndex > 0) { + return trimmed.slice(0, bracketIndex).trim() || trimmed; + } + return trimmed; +} + +function scoreTextClass(score: number | null): string { + if (score === null) return "text-muted-foreground/60"; + if (score >= 95) return "text-emerald-400/90"; + if (score >= 50) return "text-foreground/70"; + return "text-muted-foreground/60"; +} + +function formatAppliedJobLabel(job: JobListItem): string { + const employer = job.employer.trim(); + const title = job.title.trim(); + if (employer && title) return `${employer} - ${title}`; + if (title) return title; + if (employer) return employer; + return job.id; +} + +const EmailViewerRow: React.FC = ({ + item, + jobs, + selectedAppliedJobId, + onAppliedJobChange, + onApprove, + onDeny, + isActionLoading, + isAppliedJobsLoading, +}) => { + const score = item.message.matchConfidence; + const isActionable = item.message.processingStatus === "pending_user"; + const canDecide = isActionable && !!selectedAppliedJobId; + const appliedJobOptions = jobs.map((job) => ({ + value: job.id, + label: formatAppliedJobLabel(job), + searchText: `${job.employer} ${job.title} ${job.location ?? ""}`.trim(), + })); + + return ( +
+
+
+
+ +
+
+

+ {getSenderLabel( + item.message.senderName, + item.message.fromAddress, + )} +

+

+ {item.message.fromAddress} ·{" "} + {formatEpochMs(item.message.receivedAt)} +

+
+
+ +

{item.message.subject}

+ {item.message.matchedJobId ? null : ( +

+ Relevant email with no reliable job match. Please select the correct + job. +

+ )} +
+ +
+ + + + {score === null ? "n/a" : `${Math.round(score)}%`} + + +
+ + +
+
+
+ ); +}; + +export const EmailViewerList: React.FC = ({ + items, + appliedJobs, + appliedJobByMessageId, + onAppliedJobChange, + onDecision, + isActionLoading, + isAppliedJobsLoading, +}) => { + return ( +
+ {items.map((item) => { + const selectedAppliedJobId = + appliedJobByMessageId[item.message.id] || + item.message.matchedJobId || + ""; + + return ( + + onAppliedJobChange(item.message.id, value) + } + onApprove={() => onDecision(item, "approve")} + onDeny={() => onDecision(item, "deny")} + isActionLoading={isActionLoading} + isAppliedJobsLoading={isAppliedJobsLoading} + /> + ); + })} +
+ ); +}; diff --git a/orchestrator/src/components/ui/searchable-dropdown.tsx b/orchestrator/src/components/ui/searchable-dropdown.tsx new file mode 100644 index 0000000..b05d347 --- /dev/null +++ b/orchestrator/src/components/ui/searchable-dropdown.tsx @@ -0,0 +1,121 @@ +import { Check, ChevronsUpDown } from "lucide-react"; +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +export interface SearchableDropdownOption { + value: string; + label: string; + searchText?: string; + disabled?: boolean; +} + +interface SearchableDropdownProps { + value: string; + options: SearchableDropdownOption[]; + onValueChange: (value: string) => void; + placeholder: string; + searchPlaceholder?: string; + emptyText?: string; + ariaLabel?: string; + disabled?: boolean; + triggerClassName?: string; + contentClassName?: string; + listClassName?: string; +} + +export const SearchableDropdown: React.FC = ({ + value, + options, + onValueChange, + placeholder, + searchPlaceholder = "Search...", + emptyText = "No results found.", + ariaLabel, + disabled = false, + triggerClassName, + contentClassName, + listClassName, +}) => { + const [open, setOpen] = React.useState(false); + const selectedOption = options.find((option) => option.value === value); + const triggerLabel = selectedOption?.label ?? placeholder; + + return ( + + + + + + + + event.stopPropagation()} + > + {emptyText} + + {options.map((option) => { + const selected = value === option.value; + const searchableValue = [ + option.label, + option.searchText ?? "", + option.value, + ] + .join(" ") + .trim(); + + return ( + { + onValueChange(option.value); + setOpen(false); + }} + > + {option.label} + + + ); + })} + + + + + + ); +}; diff --git a/orchestrator/src/server/api/routes.ts b/orchestrator/src/server/api/routes.ts index c0d7a7a..bfc67ac 100644 --- a/orchestrator/src/server/api/routes.ts +++ b/orchestrator/src/server/api/routes.ts @@ -10,6 +10,8 @@ import { jobsRouter } from "./routes/jobs"; import { manualJobsRouter } from "./routes/manual-jobs"; import { onboardingRouter } from "./routes/onboarding"; import { pipelineRouter } from "./routes/pipeline"; +import { postApplicationProvidersRouter } from "./routes/post-application-providers"; +import { postApplicationReviewRouter } from "./routes/post-application-review"; import { profileRouter } from "./routes/profile"; import { settingsRouter } from "./routes/settings"; import { visaSponsorsRouter } from "./routes/visa-sponsors"; @@ -21,6 +23,8 @@ apiRouter.use("/jobs", jobsRouter); apiRouter.use("/demo", demoRouter); apiRouter.use("/settings", settingsRouter); apiRouter.use("/pipeline", pipelineRouter); +apiRouter.use("/post-application", postApplicationProvidersRouter); +apiRouter.use("/post-application", postApplicationReviewRouter); apiRouter.use("/manual-jobs", manualJobsRouter); apiRouter.use("/webhook", webhookRouter); apiRouter.use("/profile", profileRouter); diff --git a/orchestrator/src/server/api/routes/post-application-providers.test.ts b/orchestrator/src/server/api/routes/post-application-providers.test.ts new file mode 100644 index 0000000..6538c17 --- /dev/null +++ b/orchestrator/src/server/api/routes/post-application-providers.test.ts @@ -0,0 +1,332 @@ +import type { Server } from "node:http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startServer, stopServer } from "./test-utils"; + +vi.mock("../../services/post-application/providers", () => ({ + executePostApplicationProviderAction: vi.fn(), +})); + +describe.sequential("Post-Application Provider actions API", () => { + let server: Server; + let baseUrl: string; + let closeDb: () => void; + let tempDir: string; + const originalClientId = process.env.GMAIL_OAUTH_CLIENT_ID; + const originalClientSecret = process.env.GMAIL_OAUTH_CLIENT_SECRET; + const originalRedirectUri = process.env.GMAIL_OAUTH_REDIRECT_URI; + const originalOauthStateMaxEntries = + process.env.POST_APPLICATION_OAUTH_STATE_MAX_ENTRIES; + const originalOauthStateTtlMs = + process.env.POST_APPLICATION_OAUTH_STATE_TTL_MS; + + beforeEach(async () => { + ({ server, baseUrl, closeDb, tempDir } = await startServer()); + }); + + afterEach(async () => { + process.env.GMAIL_OAUTH_CLIENT_ID = originalClientId; + process.env.GMAIL_OAUTH_CLIENT_SECRET = originalClientSecret; + process.env.GMAIL_OAUTH_REDIRECT_URI = originalRedirectUri; + process.env.POST_APPLICATION_OAUTH_STATE_MAX_ENTRIES = + originalOauthStateMaxEntries; + process.env.POST_APPLICATION_OAUTH_STATE_TTL_MS = originalOauthStateTtlMs; + await stopServer({ server, closeDb, tempDir }); + vi.clearAllMocks(); + }); + + it("dispatches provider status action and returns unified success contract", async () => { + const { executePostApplicationProviderAction } = await import( + "../../services/post-application/providers" + ); + vi.mocked(executePostApplicationProviderAction).mockResolvedValueOnce({ + provider: "gmail", + action: "status", + accountKey: "primary", + status: { + provider: "gmail", + accountKey: "primary", + connected: false, + integration: null, + }, + message: "Provider ready", + }); + + const res = await fetch( + `${baseUrl}/api/post-application/providers/gmail/actions/status`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-request-id": "req-post-app-1", + }, + body: JSON.stringify({ accountKey: "primary" }), + }, + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(res.headers.get("x-request-id")).toBe("req-post-app-1"); + expect(body.ok).toBe(true); + expect(body.data).toEqual({ + provider: "gmail", + action: "status", + accountKey: "primary", + status: { + provider: "gmail", + accountKey: "primary", + connected: false, + integration: null, + }, + message: "Provider ready", + }); + expect(body.meta.requestId).toBe("req-post-app-1"); + expect(executePostApplicationProviderAction).toHaveBeenCalledWith({ + provider: "gmail", + action: "status", + accountKey: "primary", + connectPayload: undefined, + syncPayload: undefined, + initiatedBy: null, + }); + }); + + it("defaults to account key 'default' when omitted", async () => { + const { executePostApplicationProviderAction } = await import( + "../../services/post-application/providers" + ); + vi.mocked(executePostApplicationProviderAction).mockResolvedValueOnce({ + provider: "gmail", + action: "connect", + accountKey: "default", + status: { + provider: "gmail", + accountKey: "default", + connected: true, + integration: null, + }, + }); + + const res = await fetch( + `${baseUrl}/api/post-application/providers/gmail/actions/connect`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + payload: { + refreshToken: "redacted-token", + }, + }), + }, + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(executePostApplicationProviderAction).toHaveBeenCalledWith({ + provider: "gmail", + action: "connect", + accountKey: "default", + connectPayload: { + payload: { + refreshToken: "redacted-token", + }, + }, + syncPayload: undefined, + initiatedBy: null, + }); + }); + + it("returns 400 INVALID_REQUEST for unsupported actions", async () => { + const res = await fetch( + `${baseUrl}/api/post-application/providers/gmail/actions/invalid`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }, + ); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("INVALID_REQUEST"); + expect(typeof body.meta.requestId).toBe("string"); + }); + + it("maps provider service errors to standardized error responses", async () => { + const { executePostApplicationProviderAction } = await import( + "../../services/post-application/providers" + ); + const { AppError } = await import("@infra/errors"); + vi.mocked(executePostApplicationProviderAction).mockRejectedValueOnce( + new AppError({ + status: 503, + code: "SERVICE_UNAVAILABLE", + message: "Provider temporarily unavailable", + }), + ); + + const res = await fetch( + `${baseUrl}/api/post-application/providers/gmail/actions/sync`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountKey: "primary", maxMessages: 20 }), + }, + ); + const body = await res.json(); + + expect(res.status).toBe(503); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("SERVICE_UNAVAILABLE"); + expect(body.error.message).toBe("Provider temporarily unavailable"); + expect(typeof body.meta.requestId).toBe("string"); + }); + + it("starts gmail oauth flow and returns authorization url", async () => { + process.env.GMAIL_OAUTH_CLIENT_ID = "client-id"; + process.env.GMAIL_OAUTH_CLIENT_SECRET = "client-secret"; + process.env.GMAIL_OAUTH_REDIRECT_URI = `${baseUrl}/oauth/gmail/callback`; + + const res = await fetch( + `${baseUrl}/api/post-application/providers/gmail/oauth/start?accountKey=primary`, + { + headers: { + "x-request-id": "req-post-app-oauth-start", + }, + }, + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(res.headers.get("x-request-id")).toBe("req-post-app-oauth-start"); + expect(body.ok).toBe(true); + expect(body.data.provider).toBe("gmail"); + expect(body.data.accountKey).toBe("primary"); + expect(typeof body.data.state).toBe("string"); + expect(body.data.authorizationUrl).toContain( + "https://accounts.google.com/o/oauth2/v2/auth", + ); + expect(body.data.authorizationUrl).toContain("response_type=code"); + expect(body.meta.requestId).toBe("req-post-app-oauth-start"); + }); + + it("returns 400 INVALID_REQUEST when oauth exchange state is invalid", async () => { + process.env.GMAIL_OAUTH_CLIENT_ID = "client-id"; + process.env.GMAIL_OAUTH_CLIENT_SECRET = "client-secret"; + process.env.GMAIL_OAUTH_REDIRECT_URI = `${baseUrl}/oauth/gmail/callback`; + + const res = await fetch( + `${baseUrl}/api/post-application/providers/gmail/oauth/exchange`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + accountKey: "default", + state: "missing-state", + code: "oauth-code", + }), + }, + ); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("INVALID_REQUEST"); + expect(body.error.message).toContain("invalid or expired"); + expect(typeof body.meta.requestId).toBe("string"); + }); + + it("expires oauth states based on configured ttl", async () => { + process.env.GMAIL_OAUTH_CLIENT_ID = "client-id"; + process.env.GMAIL_OAUTH_CLIENT_SECRET = "client-secret"; + process.env.GMAIL_OAUTH_REDIRECT_URI = `${baseUrl}/oauth/gmail/callback`; + process.env.POST_APPLICATION_OAUTH_STATE_TTL_MS = "1"; + + const startRes = await fetch( + `${baseUrl}/api/post-application/providers/gmail/oauth/start`, + ); + const startBody = await startRes.json(); + expect(startRes.status).toBe(200); + + await new Promise((resolve) => setTimeout(resolve, 5)); + + const exchangeRes = await fetch( + `${baseUrl}/api/post-application/providers/gmail/oauth/exchange`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accountKey: "default", + state: startBody.data.state, + code: "oauth-code", + }), + }, + ); + const exchangeBody = await exchangeRes.json(); + expect(exchangeRes.status).toBe(400); + expect(exchangeBody.ok).toBe(false); + expect(exchangeBody.error.code).toBe("INVALID_REQUEST"); + expect(exchangeBody.error.message).toContain("invalid or expired"); + }); + + it("evicts oldest oauth state when store reaches max entries", async () => { + process.env.GMAIL_OAUTH_CLIENT_ID = "client-id"; + process.env.GMAIL_OAUTH_CLIENT_SECRET = "client-secret"; + process.env.GMAIL_OAUTH_REDIRECT_URI = `${baseUrl}/oauth/gmail/callback`; + process.env.POST_APPLICATION_OAUTH_STATE_MAX_ENTRIES = "2"; + + const firstStart = await fetch( + `${baseUrl}/api/post-application/providers/gmail/oauth/start?accountKey=first`, + ); + const firstBody = await firstStart.json(); + expect(firstStart.status).toBe(200); + + const secondStart = await fetch( + `${baseUrl}/api/post-application/providers/gmail/oauth/start?accountKey=second`, + ); + expect(secondStart.status).toBe(200); + + const thirdStart = await fetch( + `${baseUrl}/api/post-application/providers/gmail/oauth/start?accountKey=third`, + ); + expect(thirdStart.status).toBe(200); + + const exchangeRes = await fetch( + `${baseUrl}/api/post-application/providers/gmail/oauth/exchange`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accountKey: "first", + state: firstBody.data.state, + code: "oauth-code", + }), + }, + ); + const exchangeBody = await exchangeRes.json(); + expect(exchangeRes.status).toBe(400); + expect(exchangeBody.ok).toBe(false); + expect(exchangeBody.error.code).toBe("INVALID_REQUEST"); + expect(exchangeBody.error.message).toContain("invalid or expired"); + }); + + it("returns 503 SERVICE_UNAVAILABLE when gmail oauth config is missing", async () => { + delete process.env.GMAIL_OAUTH_CLIENT_ID; + delete process.env.GMAIL_OAUTH_CLIENT_SECRET; + delete process.env.GMAIL_OAUTH_REDIRECT_URI; + + const res = await fetch( + `${baseUrl}/api/post-application/providers/gmail/oauth/start`, + ); + const body = await res.json(); + + expect(res.status).toBe(503); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("SERVICE_UNAVAILABLE"); + expect(body.error.message).toContain("Gmail OAuth is not configured"); + expect(typeof body.meta.requestId).toBe("string"); + }); +}); diff --git a/orchestrator/src/server/api/routes/post-application-providers.ts b/orchestrator/src/server/api/routes/post-application-providers.ts new file mode 100644 index 0000000..df5806f --- /dev/null +++ b/orchestrator/src/server/api/routes/post-application-providers.ts @@ -0,0 +1,398 @@ +import { randomUUID } from "node:crypto"; +import { badRequest, serviceUnavailable, upstreamError } from "@infra/errors"; +import { asyncRoute, fail, ok } from "@infra/http"; +import { logger } from "@infra/logger"; +import { + POST_APPLICATION_PROVIDER_ACTIONS, + POST_APPLICATION_PROVIDERS, +} from "@shared/types"; +import { type Request, type Response, Router } from "express"; +import { z } from "zod"; +import { executePostApplicationProviderAction } from "../../services/post-application/providers"; + +const providerActionParamsSchema = z.object({ + provider: z.enum(POST_APPLICATION_PROVIDERS), + action: z.enum(POST_APPLICATION_PROVIDER_ACTIONS), +}); + +const accountBodySchema = z.object({ + accountKey: z.string().min(1).max(255).optional(), +}); + +const connectBodySchema = accountBodySchema.extend({ + payload: z.record(z.string(), z.unknown()).optional(), +}); + +const syncBodySchema = accountBodySchema.extend({ + maxMessages: z.number().int().min(1).max(500).optional(), + searchDays: z.number().int().min(1).max(365).optional(), +}); + +const oauthStartQuerySchema = z.object({ + accountKey: z.string().min(1).max(255).optional(), +}); + +const oauthExchangeBodySchema = z.object({ + accountKey: z.string().min(1).max(255).optional(), + state: z.string().min(1), + code: z.string().min(1), +}); + +export const postApplicationProvidersRouter = Router(); + +const GMAIL_OAUTH_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; +const oauthStateStore = new Map< + string, + { accountKey: string; redirectUri: string; createdAt: number } +>(); + +function getOauthStateTtlMs(): number { + const parsed = Number.parseInt( + process.env.POST_APPLICATION_OAUTH_STATE_TTL_MS ?? "", + 10, + ); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 10 * 60 * 1000; +} + +function getOauthStateMaxEntries(): number { + const parsed = Number.parseInt( + process.env.POST_APPLICATION_OAUTH_STATE_MAX_ENTRIES ?? "", + 10, + ); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 1000; +} + +function cleanupOauthState(): void { + const now = Date.now(); + const ttlMs = getOauthStateTtlMs(); + for (const [state, entry] of oauthStateStore.entries()) { + if (now - entry.createdAt > ttlMs) { + oauthStateStore.delete(state); + } + } +} + +function enforceOauthStateStoreLimit(): void { + const maxEntries = getOauthStateMaxEntries(); + if (oauthStateStore.size < maxEntries) return; + + const overflowCount = oauthStateStore.size - maxEntries + 1; + const sortedEntries = Array.from(oauthStateStore.entries()).sort( + (a, b) => a[1].createdAt - b[1].createdAt, + ); + for (const [state] of sortedEntries.slice(0, overflowCount)) { + oauthStateStore.delete(state); + } + logger.warn("Evicted OAuth states to enforce memory limit", { + route: "post-application/providers/gmail/oauth/start", + oauthStateMaxEntries: maxEntries, + evictedCount: overflowCount, + remaining: oauthStateStore.size, + }); +} + +function setOauthState( + state: string, + entry: { accountKey: string; redirectUri: string; createdAt: number }, +): void { + cleanupOauthState(); + enforceOauthStateStoreLimit(); + oauthStateStore.set(state, entry); +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function resolveGmailOauthConfig(req: Request): { + clientId: string; + clientSecret: string; + redirectUri: string; +} { + const clientId = asNonEmptyString(process.env.GMAIL_OAUTH_CLIENT_ID); + const clientSecret = asNonEmptyString(process.env.GMAIL_OAUTH_CLIENT_SECRET); + if (!clientId || !clientSecret) { + throw serviceUnavailable( + "Gmail OAuth is not configured. Missing GMAIL_OAUTH_CLIENT_ID or GMAIL_OAUTH_CLIENT_SECRET.", + ); + } + + const configuredRedirectUri = asNonEmptyString( + process.env.GMAIL_OAUTH_REDIRECT_URI, + ); + const origin = `${req.protocol}://${req.get("host")}`; + const redirectUri = configuredRedirectUri ?? `${origin}/oauth/gmail/callback`; + + return { + clientId, + clientSecret, + redirectUri, + }; +} + +async function exchangeGmailAuthorizationCode(args: { + code: string; + redirectUri: string; + clientId: string; + clientSecret: string; +}): Promise<{ + refreshToken: string; + accessToken?: string; + expiryDate?: number; + scope?: string; + tokenType?: string; +}> { + const body = new URLSearchParams({ + code: args.code, + client_id: args.clientId, + client_secret: args.clientSecret, + redirect_uri: args.redirectUri, + grant_type: "authorization_code", + }); + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const data = (await response.json().catch(() => ({}))) as Record< + string, + unknown + >; + if (!response.ok) { + throw upstreamError("Google OAuth token exchange failed."); + } + + const refreshToken = asNonEmptyString(data.refresh_token); + if (!refreshToken) { + throw upstreamError( + "Google OAuth exchange did not return a refresh token. Re-consent is required.", + ); + } + + const accessToken = asNonEmptyString(data.access_token) ?? undefined; + const expiryIn = Number(data.expires_in); + return { + refreshToken, + ...(accessToken ? { accessToken } : {}), + ...(Number.isFinite(expiryIn) + ? { expiryDate: Date.now() + expiryIn * 1000 } + : {}), + ...(asNonEmptyString(data.scope) ? { scope: String(data.scope) } : {}), + ...(asNonEmptyString(data.token_type) + ? { tokenType: String(data.token_type) } + : {}), + }; +} + +async function fetchGmailUserProfile(accessToken: string): Promise<{ + email?: string; + displayName?: string; +}> { + const response = await fetch( + "https://www.googleapis.com/oauth2/v2/userinfo", + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) return {}; + const data = (await response.json().catch(() => ({}))) as Record< + string, + unknown + >; + const email = asNonEmptyString(data.email) ?? undefined; + const displayName = + asNonEmptyString(data.name) ?? + asNonEmptyString(data.given_name) ?? + undefined; + return { + ...(email ? { email } : {}), + ...(displayName ? { displayName } : {}), + }; +} + +postApplicationProvidersRouter.get( + "/providers/gmail/oauth/start", + asyncRoute(async (req: Request, res: Response) => { + try { + cleanupOauthState(); + const parsed = oauthStartQuerySchema.parse(req.query); + const accountKey = parsed.accountKey ?? "default"; + const oauth = resolveGmailOauthConfig(req); + const state = randomUUID(); + + setOauthState(state, { + accountKey, + redirectUri: oauth.redirectUri, + createdAt: Date.now(), + }); + + const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + authUrl.searchParams.set("client_id", oauth.clientId); + authUrl.searchParams.set("redirect_uri", oauth.redirectUri); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", GMAIL_OAUTH_SCOPE); + authUrl.searchParams.set("access_type", "offline"); + authUrl.searchParams.set("prompt", "consent"); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("include_granted_scopes", "true"); + + ok(res, { + provider: "gmail", + accountKey, + authorizationUrl: authUrl.toString(), + state, + }); + } catch (error) { + if (error instanceof z.ZodError) { + fail(res, badRequest(error.message, error.flatten())); + return; + } + throw error; + } + }), +); + +postApplicationProvidersRouter.post( + "/providers/gmail/oauth/exchange", + asyncRoute(async (req: Request, res: Response) => { + try { + cleanupOauthState(); + const body = oauthExchangeBodySchema.parse(req.body ?? {}); + const accountKey = body.accountKey ?? "default"; + const oauthState = oauthStateStore.get(body.state); + + if (!oauthState) { + fail(res, badRequest("OAuth state is invalid or expired.")); + return; + } + oauthStateStore.delete(body.state); + + if (oauthState.accountKey !== accountKey) { + fail(res, badRequest("OAuth state/account mismatch.")); + return; + } + + const oauth = resolveGmailOauthConfig(req); + const tokenPayload = await exchangeGmailAuthorizationCode({ + code: body.code, + redirectUri: oauthState.redirectUri, + clientId: oauth.clientId, + clientSecret: oauth.clientSecret, + }); + const profile = tokenPayload.accessToken + ? await fetchGmailUserProfile(tokenPayload.accessToken) + : {}; + + const response = await executePostApplicationProviderAction({ + provider: "gmail", + action: "connect", + accountKey, + connectPayload: { + accountKey, + payload: { + ...tokenPayload, + ...profile, + }, + }, + syncPayload: undefined, + initiatedBy: null, + }); + + ok(res, response); + } catch (error) { + if (error instanceof z.ZodError) { + fail(res, badRequest(error.message, error.flatten())); + return; + } + throw error; + } + }), +); + +postApplicationProvidersRouter.post( + "/providers/:provider/actions/:action", + asyncRoute(async (req: Request, res: Response) => { + let provider: (typeof POST_APPLICATION_PROVIDERS)[number]; + let action: (typeof POST_APPLICATION_PROVIDER_ACTIONS)[number]; + + try { + const parsedParams = providerActionParamsSchema.parse(req.params); + provider = parsedParams.provider; + action = parsedParams.action; + } catch (error) { + if (error instanceof z.ZodError) { + fail(res, badRequest(error.message, error.flatten())); + return; + } + throw error; + } + + let accountKey: string; + let connectPayload: + | { + accountKey?: string; + payload?: Record; + } + | undefined; + let syncPayload: + | { + accountKey?: string; + maxMessages?: number; + searchDays?: number; + } + | undefined; + + try { + if (action === "connect") { + const parsedBody = connectBodySchema.parse(req.body ?? {}); + accountKey = parsedBody.accountKey ?? "default"; + connectPayload = { + ...(parsedBody.accountKey + ? { accountKey: parsedBody.accountKey } + : {}), + ...(parsedBody.payload ? { payload: parsedBody.payload } : {}), + }; + } else if (action === "sync") { + const parsedBody = syncBodySchema.parse(req.body ?? {}); + accountKey = parsedBody.accountKey ?? "default"; + syncPayload = { + ...(parsedBody.accountKey + ? { accountKey: parsedBody.accountKey } + : {}), + ...(typeof parsedBody.maxMessages === "number" + ? { maxMessages: parsedBody.maxMessages } + : {}), + ...(typeof parsedBody.searchDays === "number" + ? { searchDays: parsedBody.searchDays } + : {}), + }; + } else { + const parsedBody = accountBodySchema.parse(req.body ?? {}); + accountKey = parsedBody.accountKey ?? "default"; + } + } catch (error) { + if (error instanceof z.ZodError) { + fail(res, badRequest(error.message, error.flatten())); + return; + } + throw error; + } + + const response = await executePostApplicationProviderAction({ + provider, + action, + accountKey, + connectPayload, + syncPayload, + initiatedBy: null, + }); + + ok(res, response); + }), +); diff --git a/orchestrator/src/server/api/routes/post-application-review.test.ts b/orchestrator/src/server/api/routes/post-application-review.test.ts new file mode 100644 index 0000000..57a3f49 --- /dev/null +++ b/orchestrator/src/server/api/routes/post-application-review.test.ts @@ -0,0 +1,303 @@ +import { randomUUID } from "node:crypto"; +import type { Server } from "node:http"; +import type { + PostApplicationMessage, + PostApplicationRouterStageTarget, +} from "@shared/types"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { startServer, stopServer } from "./test-utils"; + +describe.sequential("Post-Application Review Workflow API", () => { + let server: Server; + let baseUrl: string; + let closeDb: () => void; + let tempDir: string; + + beforeEach(async () => { + ({ server, baseUrl, closeDb, tempDir } = await startServer()); + }); + + afterEach(async () => { + await stopServer({ server, closeDb, tempDir }); + }); + + async function seedPendingMessage(input?: { + syncRunId?: string | null; + matchedJobId?: string | null; + stageTarget?: PostApplicationRouterStageTarget; + }): Promise<{ + message: PostApplicationMessage; + jobId: string; + }> { + const { createJob } = await import("../../repositories/jobs"); + const { upsertPostApplicationMessage } = await import( + "../../repositories/post-application-messages" + ); + + const job = await createJob({ + source: "manual", + title: "Front End JavaScript Developer", + employer: "Roku Interactive", + jobUrl: `https://example.com/jobs/${randomUUID()}`, + }); + + const { message } = await upsertPostApplicationMessage({ + provider: "gmail", + accountKey: "default", + integrationId: null, + syncRunId: input?.syncRunId ?? null, + externalMessageId: randomUUID(), + fromAddress: "roku@smartrecruiters.com", + fromDomain: "smartrecruiters.com", + senderName: "Roku", + subject: "Interview invitation", + receivedAt: Date.now(), + snippet: "Please schedule an interview.", + classificationLabel: "interview", + classificationConfidence: 0.97, + classificationPayload: { + reason: "High confidence", + }, + relevanceLlmScore: 97, + relevanceDecision: "relevant", + matchConfidence: 97, + stageTarget: input?.stageTarget ?? "technical_interview", + messageType: + input?.stageTarget === "rejected" || input?.stageTarget === "withdrawn" + ? "rejection" + : "interview", + stageEventPayload: { note: "from test" }, + processingStatus: "pending_user", + matchedJobId: + input?.matchedJobId === undefined ? job.id : input.matchedJobId, + }); + + return { message, jobId: job.id }; + } + + it("lists pending inbox items", async () => { + const { message } = await seedPendingMessage(); + + const res = await fetch( + `${baseUrl}/api/post-application/inbox?provider=gmail&accountKey=default`, + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.data.total).toBe(1); + expect(body.data.items[0].message.id).toBe(message.id); + expect(body.data.items[0].message.processingStatus).toBe("pending_user"); + expect(typeof body.meta.requestId).toBe("string"); + }); + + it("approves an inbox item and writes stage event", async () => { + const { message, jobId } = await seedPendingMessage(); + const { db, schema } = await import("../../db"); + + const res = await fetch( + `${baseUrl}/api/post-application/inbox/${message.id}/approve`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "gmail", + accountKey: "default", + jobId, + decidedBy: "tester", + }), + }, + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.data.message.processingStatus).toBe("manual_linked"); + expect(body.data.message.matchedJobId).toBe(jobId); + + const stageRows = await db.select().from(schema.stageEvents); + expect(stageRows.length).toBeGreaterThan(0); + }); + + it("returns conflict on second approve and increments sync-run approval once", async () => { + const { startPostApplicationSyncRun, getPostApplicationSyncRunById } = + await import("../../repositories/post-application-sync-runs"); + const run = await startPostApplicationSyncRun({ + provider: "gmail", + accountKey: "default", + integrationId: null, + }); + const { message, jobId } = await seedPendingMessage({ syncRunId: run.id }); + + const firstRes = await fetch( + `${baseUrl}/api/post-application/inbox/${message.id}/approve`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "gmail", + accountKey: "default", + jobId, + decidedBy: "tester", + }), + }, + ); + expect(firstRes.status).toBe(200); + + const secondRes = await fetch( + `${baseUrl}/api/post-application/inbox/${message.id}/approve`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "gmail", + accountKey: "default", + jobId, + decidedBy: "tester", + }), + }, + ); + const secondBody = await secondRes.json(); + + expect(secondRes.status).toBe(409); + expect(secondBody.ok).toBe(false); + expect(secondBody.error.code).toBe("CONFLICT"); + + const updatedRun = await getPostApplicationSyncRunById(run.id); + expect(updatedRun?.messagesApproved).toBe(1); + expect(updatedRun?.messagesDenied).toBe(0); + }); + + it("denies an inbox item as ignored", async () => { + const { message } = await seedPendingMessage(); + + const denyRes = await fetch( + `${baseUrl}/api/post-application/inbox/${message.id}/deny`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "gmail", + accountKey: "default", + decidedBy: "tester", + }), + }, + ); + const denyBody = await denyRes.json(); + + expect(denyRes.status).toBe(200); + expect(denyBody.ok).toBe(true); + expect(denyBody.data.message.processingStatus).toBe("ignored"); + expect(denyBody.data.message.matchedJobId).toBeNull(); + }); + + it("counts no-suggested-match approve items as skipped, not failed", async () => { + await seedPendingMessage({ matchedJobId: null }); + + const res = await fetch(`${baseUrl}/api/post-application/inbox/bulk`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "approve", + provider: "gmail", + accountKey: "default", + decidedBy: "tester", + }), + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.data.requested).toBe(1); + expect(body.data.succeeded).toBe(0); + expect(body.data.skipped).toBe(1); + expect(body.data.failed).toBe(0); + expect(body.data.results[0].ok).toBe(false); + expect(body.data.results[0].error.code).toBe("NO_SUGGESTED_MATCH"); + }); + + it("lists messages for a sync run", async () => { + const { startPostApplicationSyncRun } = await import( + "../../repositories/post-application-sync-runs" + ); + const run = await startPostApplicationSyncRun({ + provider: "gmail", + accountKey: "default", + integrationId: null, + }); + const { message } = await seedPendingMessage({ syncRunId: run.id }); + + const res = await fetch( + `${baseUrl}/api/post-application/runs/${run.id}/messages?provider=gmail&accountKey=default`, + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.data.run.id).toBe(run.id); + expect(body.data.total).toBe(1); + expect(body.data.items[0].message.id).toBe(message.id); + }); + + it("approves rejected target and sets closed stage with rejected outcome", async () => { + const { message, jobId } = await seedPendingMessage({ + stageTarget: "rejected", + }); + const { db, schema } = await import("../../db"); + + const res = await fetch( + `${baseUrl}/api/post-application/inbox/${message.id}/approve`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "gmail", + accountKey: "default", + jobId, + }), + }, + ); + + expect(res.status).toBe(200); + + const stageRows = await db.select().from(schema.stageEvents); + expect(stageRows.at(-1)?.toStage).toBe("closed"); + expect(stageRows.at(-1)?.outcome).toBe("rejected"); + + const jobRow = (await db.select().from(schema.jobs)).find( + (job) => job.id === jobId, + ); + expect(jobRow?.outcome).toBe("rejected"); + }); + + it("approves withdrawn target and sets closed stage with withdrawn outcome", async () => { + const { message, jobId } = await seedPendingMessage({ + stageTarget: "withdrawn", + }); + const { db, schema } = await import("../../db"); + + const res = await fetch( + `${baseUrl}/api/post-application/inbox/${message.id}/approve`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "gmail", + accountKey: "default", + jobId, + }), + }, + ); + + expect(res.status).toBe(200); + + const stageRows = await db.select().from(schema.stageEvents); + expect(stageRows.at(-1)?.toStage).toBe("closed"); + expect(stageRows.at(-1)?.outcome).toBe("withdrawn"); + + const jobRow = (await db.select().from(schema.jobs)).find( + (job) => job.id === jobId, + ); + expect(jobRow?.outcome).toBe("withdrawn"); + }); +}); diff --git a/orchestrator/src/server/api/routes/post-application-review.ts b/orchestrator/src/server/api/routes/post-application-review.ts new file mode 100644 index 0000000..e76ce44 --- /dev/null +++ b/orchestrator/src/server/api/routes/post-application-review.ts @@ -0,0 +1,203 @@ +import { badRequest } from "@infra/errors"; +import { asyncRoute, fail, ok } from "@infra/http"; +import { + APPLICATION_STAGES, + POST_APPLICATION_PROVIDERS, + POST_APPLICATION_ROUTER_STAGE_TARGETS, +} from "@shared/types"; +import { type Request, type Response, Router } from "express"; +import { z } from "zod"; +import { + approvePostApplicationInboxItem, + bulkPostApplicationInboxAction, + denyPostApplicationInboxItem, + listPostApplicationInbox, + listPostApplicationReviewRuns, + listPostApplicationRunMessages, +} from "../../services/post-application/review"; + +const listQuerySchema = z.object({ + provider: z.enum(POST_APPLICATION_PROVIDERS).default("gmail"), + accountKey: z.string().min(1).max(255).default("default"), + limit: z.coerce.number().int().min(1).max(200).optional(), +}); + +const inboxParamsSchema = z.object({ + messageId: z.string().uuid(), +}); + +const runParamsSchema = z.object({ + runId: z.string().uuid(), +}); + +const approveBodySchema = z.object({ + provider: z.enum(POST_APPLICATION_PROVIDERS).default("gmail"), + accountKey: z.string().min(1).max(255).default("default"), + jobId: z.string().uuid().optional(), + stageTarget: z.enum(POST_APPLICATION_ROUTER_STAGE_TARGETS).optional(), + toStage: z.enum(APPLICATION_STAGES).optional(), + note: z.string().max(2000).optional(), + decidedBy: z.string().max(255).optional(), +}); + +const denyBodySchema = z.object({ + provider: z.enum(POST_APPLICATION_PROVIDERS).default("gmail"), + accountKey: z.string().min(1).max(255).default("default"), + decidedBy: z.string().max(255).optional(), +}); + +const bulkActionBodySchema = z.object({ + action: z.enum(["approve", "deny"]), + provider: z.enum(POST_APPLICATION_PROVIDERS).default("gmail"), + accountKey: z.string().min(1).max(255).default("default"), + decidedBy: z.string().max(255).optional(), +}); + +export const postApplicationReviewRouter = Router(); + +postApplicationReviewRouter.get( + "/inbox", + asyncRoute(async (req: Request, res: Response) => { + try { + const query = listQuerySchema.parse(req.query); + const items = await listPostApplicationInbox({ + provider: query.provider, + accountKey: query.accountKey, + ...(typeof query.limit === "number" ? { limit: query.limit } : {}), + }); + ok(res, { items, total: items.length }); + } catch (error) { + if (error instanceof z.ZodError) { + fail(res, badRequest(error.message, error.flatten())); + return; + } + throw error; + } + }), +); + +postApplicationReviewRouter.get( + "/runs", + asyncRoute(async (req: Request, res: Response) => { + try { + const query = listQuerySchema.parse(req.query); + const runs = await listPostApplicationReviewRuns({ + provider: query.provider, + accountKey: query.accountKey, + ...(typeof query.limit === "number" ? { limit: query.limit } : {}), + }); + ok(res, { runs, total: runs.length }); + } catch (error) { + if (error instanceof z.ZodError) { + fail(res, badRequest(error.message, error.flatten())); + return; + } + throw error; + } + }), +); + +postApplicationReviewRouter.get( + "/runs/:runId/messages", + asyncRoute(async (req: Request, res: Response) => { + try { + const query = listQuerySchema.parse(req.query); + const { runId } = runParamsSchema.parse(req.params); + const result = await listPostApplicationRunMessages({ + provider: query.provider, + accountKey: query.accountKey, + runId, + ...(typeof query.limit === "number" ? { limit: query.limit } : {}), + }); + ok(res, { + run: result.run, + items: result.items, + total: result.items.length, + }); + } catch (error) { + if (error instanceof z.ZodError) { + fail(res, badRequest(error.message, error.flatten())); + return; + } + throw error; + } + }), +); + +postApplicationReviewRouter.post( + "/inbox/:messageId/approve", + asyncRoute(async (req: Request, res: Response) => { + try { + const { messageId } = inboxParamsSchema.parse(req.params); + const input = approveBodySchema.parse(req.body ?? {}); + + const result = await approvePostApplicationInboxItem({ + messageId, + provider: input.provider, + accountKey: input.accountKey, + jobId: input.jobId, + stageTarget: input.stageTarget, + toStage: input.toStage, + note: input.note, + decidedBy: input.decidedBy ?? null, + }); + + ok(res, result); + } catch (error) { + if (error instanceof z.ZodError) { + fail(res, badRequest(error.message, error.flatten())); + return; + } + throw error; + } + }), +); + +postApplicationReviewRouter.post( + "/inbox/:messageId/deny", + asyncRoute(async (req: Request, res: Response) => { + try { + const { messageId } = inboxParamsSchema.parse(req.params); + const input = denyBodySchema.parse(req.body ?? {}); + + const result = await denyPostApplicationInboxItem({ + messageId, + provider: input.provider, + accountKey: input.accountKey, + decidedBy: input.decidedBy ?? null, + }); + + ok(res, result); + } catch (error) { + if (error instanceof z.ZodError) { + fail(res, badRequest(error.message, error.flatten())); + return; + } + throw error; + } + }), +); + +postApplicationReviewRouter.post( + "/inbox/bulk", + asyncRoute(async (req: Request, res: Response) => { + try { + const input = bulkActionBodySchema.parse(req.body ?? {}); + + const result = await bulkPostApplicationInboxAction({ + action: input.action, + provider: input.provider, + accountKey: input.accountKey, + decidedBy: input.decidedBy ?? null, + }); + + ok(res, result); + } catch (error) { + if (error instanceof z.ZodError) { + fail(res, badRequest(error.message, error.flatten())); + return; + } + throw error; + } + }), +); diff --git a/orchestrator/src/server/db/migrate.ts b/orchestrator/src/server/db/migrate.ts index c57798a..7f94aac 100644 --- a/orchestrator/src/server/db/migrate.ts +++ b/orchestrator/src/server/db/migrate.ts @@ -126,6 +126,79 @@ const migrations = [ FOREIGN KEY (application_id) REFERENCES jobs(id) ON DELETE CASCADE )`, + `CREATE TABLE IF NOT EXISTS post_application_integrations ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL CHECK(provider IN ('gmail', 'imap')), + account_key TEXT NOT NULL DEFAULT 'default', + display_name TEXT, + status TEXT NOT NULL DEFAULT 'disconnected' CHECK(status IN ('disconnected', 'connected', 'error')), + credentials TEXT, + last_connected_at INTEGER, + last_synced_at INTEGER, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(provider, account_key) + )`, + + `CREATE TABLE IF NOT EXISTS post_application_sync_runs ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL CHECK(provider IN ('gmail', 'imap')), + account_key TEXT NOT NULL DEFAULT 'default', + integration_id TEXT, + status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'completed', 'failed', 'cancelled')), + started_at INTEGER NOT NULL, + completed_at INTEGER, + messages_discovered INTEGER NOT NULL DEFAULT 0, + messages_relevant INTEGER NOT NULL DEFAULT 0, + messages_classified INTEGER NOT NULL DEFAULT 0, + messages_matched INTEGER NOT NULL DEFAULT 0, + messages_approved INTEGER NOT NULL DEFAULT 0, + messages_denied INTEGER NOT NULL DEFAULT 0, + messages_errored INTEGER NOT NULL DEFAULT 0, + error_code TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (integration_id) REFERENCES post_application_integrations(id) ON DELETE SET NULL + )`, + + `CREATE TABLE IF NOT EXISTS post_application_messages ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL CHECK(provider IN ('gmail', 'imap')), + account_key TEXT NOT NULL DEFAULT 'default', + integration_id TEXT, + sync_run_id TEXT, + external_message_id TEXT NOT NULL, + external_thread_id TEXT, + from_address TEXT NOT NULL DEFAULT '', + from_domain TEXT, + sender_name TEXT, + subject TEXT NOT NULL DEFAULT '', + received_at INTEGER NOT NULL, + snippet TEXT NOT NULL DEFAULT '', + classification_label TEXT, + classification_confidence REAL, + classification_payload TEXT, + relevance_llm_score REAL, + relevance_decision TEXT NOT NULL DEFAULT 'needs_llm' CHECK(relevance_decision IN ('relevant', 'not_relevant', 'needs_llm')), + match_confidence INTEGER, + message_type TEXT NOT NULL DEFAULT 'other' CHECK(message_type IN ('interview', 'rejection', 'offer', 'update', 'other')), + stage_event_payload TEXT, + processing_status TEXT NOT NULL DEFAULT 'pending_user' CHECK(processing_status IN ('auto_linked', 'pending_user', 'manual_linked', 'ignored')), + matched_job_id TEXT, + decided_at INTEGER, + decided_by TEXT, + error_code TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (integration_id) REFERENCES post_application_integrations(id) ON DELETE SET NULL, + FOREIGN KEY (sync_run_id) REFERENCES post_application_sync_runs(id) ON DELETE SET NULL, + FOREIGN KEY (matched_job_id) REFERENCES jobs(id) ON DELETE SET NULL, + UNIQUE(provider, account_key, external_message_id) + )`, + // Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run) `INSERT OR REPLACE INTO settings(key, value, created_at, updated_at) SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`, @@ -187,6 +260,31 @@ const migrations = [ `ALTER TABLE stage_events ADD COLUMN title TEXT NOT NULL DEFAULT ''`, `ALTER TABLE stage_events ADD COLUMN group_id TEXT`, + // Smart-router columns for existing databases. + `ALTER TABLE post_application_messages ADD COLUMN match_confidence INTEGER`, + `ALTER TABLE post_application_messages ADD COLUMN message_type TEXT NOT NULL DEFAULT 'other' CHECK(message_type IN ('interview', 'rejection', 'offer', 'update', 'other'))`, + `ALTER TABLE post_application_messages ADD COLUMN stage_event_payload TEXT`, + `ALTER TABLE post_application_messages ADD COLUMN processing_status TEXT NOT NULL DEFAULT 'pending_user' CHECK(processing_status IN ('auto_linked', 'pending_user', 'manual_linked', 'ignored'))`, + `UPDATE post_application_messages + SET match_confidence = CAST(round(COALESCE(relevance_llm_score, 0)) AS INTEGER) + WHERE match_confidence IS NULL`, + `UPDATE post_application_messages + SET message_type = CASE + WHEN lower(COALESCE(classification_label, '')) LIKE '%interview%' THEN 'interview' + WHEN lower(COALESCE(classification_label, '')) LIKE '%offer%' THEN 'offer' + WHEN lower(COALESCE(classification_label, '')) LIKE '%reject%' THEN 'rejection' + WHEN lower(COALESCE(classification_label, '')) IN ('false positive', 'did not apply - inbound request') THEN 'other' + ELSE 'update' + END`, + `UPDATE post_application_messages + SET processing_status = CASE + WHEN review_status = 'approved' THEN 'manual_linked' + WHEN review_status IN ('pending_review', 'no_reliable_match') THEN 'pending_user' + ELSE 'ignored' + END`, + `DROP TABLE IF EXISTS post_application_message_candidates`, + `DROP TABLE IF EXISTS post_application_message_links`, + // Ensure pipeline_runs status supports "cancelled" for existing databases. `CREATE TABLE IF NOT EXISTS pipeline_runs_new ( id TEXT PRIMARY KEY, @@ -212,6 +310,8 @@ const migrations = [ `CREATE INDEX IF NOT EXISTS idx_tasks_application_id ON tasks(application_id)`, `CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date)`, `CREATE INDEX IF NOT EXISTS idx_interviews_application_id ON interviews(application_id)`, + `CREATE INDEX IF NOT EXISTS idx_post_app_sync_runs_provider_account_started_at ON post_application_sync_runs(provider, account_key, started_at)`, + `CREATE INDEX IF NOT EXISTS idx_post_app_messages_provider_account_processing_status ON post_application_messages(provider, account_key, processing_status)`, // Backfill: Create "Applied" events for legacy jobs that have applied_at set but no event entry `INSERT INTO stage_events (id, application_id, title, from_stage, to_stage, occurred_at, metadata) @@ -239,6 +339,9 @@ for (const migration of migrations) { const isDuplicateColumn = (migration.toLowerCase().includes("alter table jobs add column") || migration.toLowerCase().includes("alter table tasks add column") || + migration + .toLowerCase() + .includes("alter table post_application_messages add column") || migration .toLowerCase() .includes("alter table stage_events add column")) && @@ -249,6 +352,14 @@ for (const migration of migrations) { continue; } + const isLegacyBackfillOnFreshSchema = + migration.toLowerCase().includes("update post_application_messages") && + message.toLowerCase().includes("no such column"); + if (isLegacyBackfillOnFreshSchema) { + console.log("↩️ Migration skipped (legacy backfill not applicable)"); + continue; + } + // Optional performance-only migration: if this fails we should still boot // existing databases and continue without the index. const isOptionalOptimizationMigration = migration.includes( diff --git a/orchestrator/src/server/db/schema.ts b/orchestrator/src/server/db/schema.ts index 7e48c33..809bc15 100644 --- a/orchestrator/src/server/db/schema.ts +++ b/orchestrator/src/server/db/schema.ts @@ -8,9 +8,22 @@ import { APPLICATION_TASK_TYPES, INTERVIEW_OUTCOMES, INTERVIEW_TYPES, + POST_APPLICATION_INTEGRATION_STATUSES, + POST_APPLICATION_MESSAGE_TYPES, + POST_APPLICATION_PROCESSING_STATUSES, + POST_APPLICATION_PROVIDERS, + POST_APPLICATION_RELEVANCE_DECISIONS, + POST_APPLICATION_SYNC_RUN_STATUSES, } from "@shared/types"; import { sql } from "drizzle-orm"; -import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + index, + integer, + real, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; export const jobs = sqliteTable("jobs", { id: text("id").primaryKey(), @@ -163,6 +176,129 @@ export const settings = sqliteTable("settings", { updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), }); +export const postApplicationIntegrations = sqliteTable( + "post_application_integrations", + { + id: text("id").primaryKey(), + provider: text("provider", { enum: POST_APPLICATION_PROVIDERS }).notNull(), + accountKey: text("account_key").notNull().default("default"), + displayName: text("display_name"), + status: text("status", { enum: POST_APPLICATION_INTEGRATION_STATUSES }) + .notNull() + .default("disconnected"), + credentials: text("credentials", { mode: "json" }), + lastConnectedAt: integer("last_connected_at", { mode: "number" }), + lastSyncedAt: integer("last_synced_at", { mode: "number" }), + lastError: text("last_error"), + createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), + updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), + }, + (table) => ({ + providerAccountUnique: uniqueIndex( + "idx_post_app_integrations_provider_account_unique", + ).on(table.provider, table.accountKey), + }), +); + +export const postApplicationSyncRuns = sqliteTable( + "post_application_sync_runs", + { + id: text("id").primaryKey(), + provider: text("provider", { enum: POST_APPLICATION_PROVIDERS }).notNull(), + accountKey: text("account_key").notNull().default("default"), + integrationId: text("integration_id").references( + () => postApplicationIntegrations.id, + { onDelete: "set null" }, + ), + status: text("status", { enum: POST_APPLICATION_SYNC_RUN_STATUSES }) + .notNull() + .default("running"), + startedAt: integer("started_at", { mode: "number" }).notNull(), + completedAt: integer("completed_at", { mode: "number" }), + messagesDiscovered: integer("messages_discovered").notNull().default(0), + messagesRelevant: integer("messages_relevant").notNull().default(0), + messagesClassified: integer("messages_classified").notNull().default(0), + messagesMatched: integer("messages_matched").notNull().default(0), + messagesApproved: integer("messages_approved").notNull().default(0), + messagesDenied: integer("messages_denied").notNull().default(0), + messagesErrored: integer("messages_errored").notNull().default(0), + errorCode: text("error_code"), + errorMessage: text("error_message"), + createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), + updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), + }, + (table) => ({ + providerAccountStartedAtIndex: index( + "idx_post_app_sync_runs_provider_account_started_at", + ).on(table.provider, table.accountKey, table.startedAt), + }), +); + +export const postApplicationMessages = sqliteTable( + "post_application_messages", + { + id: text("id").primaryKey(), + provider: text("provider", { enum: POST_APPLICATION_PROVIDERS }).notNull(), + accountKey: text("account_key").notNull().default("default"), + integrationId: text("integration_id").references( + () => postApplicationIntegrations.id, + { onDelete: "set null" }, + ), + syncRunId: text("sync_run_id").references( + () => postApplicationSyncRuns.id, + { + onDelete: "set null", + }, + ), + externalMessageId: text("external_message_id").notNull(), + externalThreadId: text("external_thread_id"), + fromAddress: text("from_address").notNull().default(""), + fromDomain: text("from_domain"), + senderName: text("sender_name"), + subject: text("subject").notNull().default(""), + receivedAt: integer("received_at", { mode: "number" }).notNull(), + snippet: text("snippet").notNull().default(""), + classificationLabel: text("classification_label"), + classificationConfidence: real("classification_confidence"), + classificationPayload: text("classification_payload", { mode: "json" }), + relevanceLlmScore: real("relevance_llm_score"), + relevanceDecision: text("relevance_decision", { + enum: POST_APPLICATION_RELEVANCE_DECISIONS, + }) + .notNull() + .default("needs_llm"), + matchConfidence: integer("match_confidence"), + messageType: text("message_type", { + enum: POST_APPLICATION_MESSAGE_TYPES, + }) + .notNull() + .default("other"), + stageEventPayload: text("stage_event_payload", { mode: "json" }), + processingStatus: text("processing_status", { + enum: POST_APPLICATION_PROCESSING_STATUSES, + }) + .notNull() + .default("pending_user"), + matchedJobId: text("matched_job_id").references(() => jobs.id, { + onDelete: "set null", + }), + decidedAt: integer("decided_at", { mode: "number" }), + decidedBy: text("decided_by"), + errorCode: text("error_code"), + errorMessage: text("error_message"), + createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), + updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), + }, + (table) => ({ + providerAccountExternalMessageUnique: uniqueIndex( + "idx_post_app_messages_provider_account_external_unique", + ).on(table.provider, table.accountKey, table.externalMessageId), + providerAccountReviewStatusIndex: index( + "idx_post_app_messages_provider_account_processing_status", + ).on(table.provider, table.accountKey, table.processingStatus), + }), +); + export type JobRow = typeof jobs.$inferSelect; export type NewJobRow = typeof jobs.$inferInsert; export type StageEventRow = typeof stageEvents.$inferSelect; @@ -175,3 +311,15 @@ export type PipelineRunRow = typeof pipelineRuns.$inferSelect; export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert; export type SettingsRow = typeof settings.$inferSelect; export type NewSettingsRow = typeof settings.$inferInsert; +export type PostApplicationIntegrationRow = + typeof postApplicationIntegrations.$inferSelect; +export type NewPostApplicationIntegrationRow = + typeof postApplicationIntegrations.$inferInsert; +export type PostApplicationSyncRunRow = + typeof postApplicationSyncRuns.$inferSelect; +export type NewPostApplicationSyncRunRow = + typeof postApplicationSyncRuns.$inferInsert; +export type PostApplicationMessageRow = + typeof postApplicationMessages.$inferSelect; +export type NewPostApplicationMessageRow = + typeof postApplicationMessages.$inferInsert; diff --git a/orchestrator/src/server/repositories/jobs.ts b/orchestrator/src/server/repositories/jobs.ts index ebbe352..ecba1cc 100644 --- a/orchestrator/src/server/repositories/jobs.ts +++ b/orchestrator/src/server/repositories/jobs.ts @@ -127,6 +127,25 @@ export async function getJobById(id: string): Promise { return row ? mapRowToJob(row) : null; } +export async function listJobSummariesByIds(jobIds: string[]): Promise< + Array<{ + id: string; + title: string; + employer: string; + }> +> { + if (jobIds.length === 0) return []; + + return db + .select({ + id: jobs.id, + title: jobs.title, + employer: jobs.employer, + }) + .from(jobs) + .where(inArray(jobs.id, jobIds)); +} + /** * Get a job by its URL (for deduplication). */ diff --git a/orchestrator/src/server/repositories/post-application-integrations.ts b/orchestrator/src/server/repositories/post-application-integrations.ts new file mode 100644 index 0000000..3b9feb5 --- /dev/null +++ b/orchestrator/src/server/repositories/post-application-integrations.ts @@ -0,0 +1,180 @@ +import { randomUUID } from "node:crypto"; +import type { + PostApplicationIntegration, + PostApplicationIntegrationStatus, + PostApplicationProvider, +} from "@shared/types"; +import { and, eq } from "drizzle-orm"; +import { db, schema } from "../db"; + +const { postApplicationIntegrations } = schema; + +type IntegrationCredentials = Record; + +type UpsertConnectedIntegrationInput = { + provider: PostApplicationProvider; + accountKey: string; + displayName?: string | null; + credentials: IntegrationCredentials; +}; + +type UpdatePostApplicationIntegrationSyncStateInput = { + provider: PostApplicationProvider; + accountKey: string; + lastSyncedAt?: number | null; + lastError?: string | null; + credentials?: IntegrationCredentials | null; + status?: PostApplicationIntegrationStatus; +}; + +function asCredentials(value: unknown): IntegrationCredentials | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as IntegrationCredentials; +} + +function mapRowToIntegration( + row: typeof postApplicationIntegrations.$inferSelect, +): PostApplicationIntegration { + return { + id: row.id, + provider: row.provider, + accountKey: row.accountKey, + displayName: row.displayName, + status: row.status as PostApplicationIntegrationStatus, + credentials: asCredentials(row.credentials), + lastConnectedAt: row.lastConnectedAt, + lastSyncedAt: row.lastSyncedAt, + lastError: row.lastError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export async function getPostApplicationIntegration( + provider: PostApplicationProvider, + accountKey: string, +): Promise { + const [row] = await db + .select() + .from(postApplicationIntegrations) + .where( + and( + eq(postApplicationIntegrations.provider, provider), + eq(postApplicationIntegrations.accountKey, accountKey), + ), + ); + + return row ? mapRowToIntegration(row) : null; +} + +export async function upsertConnectedPostApplicationIntegration( + input: UpsertConnectedIntegrationInput, +): Promise { + const nowEpoch = Date.now(); + const nowIso = new Date(nowEpoch).toISOString(); + const existing = await getPostApplicationIntegration( + input.provider, + input.accountKey, + ); + + if (existing) { + await db + .update(postApplicationIntegrations) + .set({ + displayName: input.displayName ?? existing.displayName, + status: "connected", + credentials: input.credentials, + lastConnectedAt: nowEpoch, + lastError: null, + updatedAt: nowIso, + }) + .where(eq(postApplicationIntegrations.id, existing.id)); + + const updated = await getPostApplicationIntegration( + input.provider, + input.accountKey, + ); + if (!updated) { + throw new Error( + `Failed to load updated integration ${input.provider}/${input.accountKey}.`, + ); + } + return updated; + } + + const id = randomUUID(); + await db.insert(postApplicationIntegrations).values({ + id, + provider: input.provider, + accountKey: input.accountKey, + displayName: input.displayName ?? null, + status: "connected", + credentials: input.credentials, + lastConnectedAt: nowEpoch, + lastError: null, + createdAt: nowIso, + updatedAt: nowIso, + }); + + const created = await getPostApplicationIntegration( + input.provider, + input.accountKey, + ); + if (!created) { + throw new Error( + `Failed to load created integration ${input.provider}/${input.accountKey}.`, + ); + } + return created; +} + +export async function disconnectPostApplicationIntegration( + provider: PostApplicationProvider, + accountKey: string, +): Promise { + const existing = await getPostApplicationIntegration(provider, accountKey); + if (!existing) return null; + + const nowIso = new Date().toISOString(); + await db + .update(postApplicationIntegrations) + .set({ + status: "disconnected", + credentials: null, + lastError: null, + updatedAt: nowIso, + }) + .where(eq(postApplicationIntegrations.id, existing.id)); + + return getPostApplicationIntegration(provider, accountKey); +} + +export async function updatePostApplicationIntegrationSyncState( + input: UpdatePostApplicationIntegrationSyncStateInput, +): Promise { + const existing = await getPostApplicationIntegration( + input.provider, + input.accountKey, + ); + if (!existing) return null; + + const nowIso = new Date().toISOString(); + await db + .update(postApplicationIntegrations) + .set({ + ...(input.status ? { status: input.status } : {}), + ...(input.lastSyncedAt !== undefined + ? { lastSyncedAt: input.lastSyncedAt } + : {}), + ...(input.lastError !== undefined ? { lastError: input.lastError } : {}), + ...(input.credentials !== undefined + ? { credentials: input.credentials } + : {}), + updatedAt: nowIso, + }) + .where(eq(postApplicationIntegrations.id, existing.id)); + + return getPostApplicationIntegration(input.provider, input.accountKey); +} diff --git a/orchestrator/src/server/repositories/post-application-messages.test.ts b/orchestrator/src/server/repositories/post-application-messages.test.ts new file mode 100644 index 0000000..c755142 --- /dev/null +++ b/orchestrator/src/server/repositories/post-application-messages.test.ts @@ -0,0 +1,130 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe.sequential("post-application message upsert transition semantics", () => { + let tempDir: string; + let upsertPostApplicationMessage: typeof import("./post-application-messages").upsertPostApplicationMessage; + + async function upsertMessage(args: { + externalMessageId: string; + processingStatus: + | "pending_user" + | "auto_linked" + | "manual_linked" + | "ignored"; + }) { + return upsertPostApplicationMessage({ + provider: "gmail", + accountKey: "default", + integrationId: null, + syncRunId: null, + externalMessageId: args.externalMessageId, + externalThreadId: "thread-1", + fromAddress: "no-reply@example.com", + fromDomain: "example.com", + senderName: "Example", + subject: "Status update", + receivedAt: Date.now(), + snippet: "snippet", + classificationLabel: "assessment", + classificationConfidence: 0.95, + classificationPayload: { reason: "test" }, + relevanceLlmScore: 95, + relevanceDecision: "relevant", + matchConfidence: 95, + stageTarget: "assessment", + messageType: "update", + stageEventPayload: { note: "test" }, + processingStatus: args.processingStatus, + matchedJobId: null, + }); + } + + beforeEach(async () => { + vi.resetModules(); + tempDir = await mkdtemp(join(tmpdir(), "job-ops-post-app-msgs-")); + process.env.DATA_DIR = tempDir; + process.env.NODE_ENV = "test"; + + await import("../db/migrate"); + ({ upsertPostApplicationMessage } = await import( + "./post-application-messages" + )); + }); + + afterEach(async () => { + const { closeDb } = await import("../db/index"); + closeDb(); + await rm(tempDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it("marks inserted auto_linked rows as transitioned", async () => { + const result = await upsertMessage({ + externalMessageId: randomUUID(), + processingStatus: "auto_linked", + }); + + expect(result.wasCreated).toBe(true); + expect(result.previousProcessingStatus).toBeNull(); + expect(result.autoLinkTransitioned).toBe(true); + expect(result.message.processingStatus).toBe("auto_linked"); + }); + + it("does not transition auto_linked rows on repeated upserts", async () => { + const externalMessageId = randomUUID(); + await upsertMessage({ + externalMessageId, + processingStatus: "auto_linked", + }); + + const second = await upsertMessage({ + externalMessageId, + processingStatus: "auto_linked", + }); + + expect(second.wasCreated).toBe(false); + expect(second.previousProcessingStatus).toBe("auto_linked"); + expect(second.autoLinkTransitioned).toBe(false); + expect(second.message.processingStatus).toBe("auto_linked"); + }); + + it("marks pending_user -> auto_linked as transitioned", async () => { + const externalMessageId = randomUUID(); + await upsertMessage({ + externalMessageId, + processingStatus: "pending_user", + }); + + const second = await upsertMessage({ + externalMessageId, + processingStatus: "auto_linked", + }); + + expect(second.wasCreated).toBe(false); + expect(second.previousProcessingStatus).toBe("pending_user"); + expect(second.autoLinkTransitioned).toBe(true); + expect(second.message.processingStatus).toBe("auto_linked"); + }); + + it("preserves terminal statuses and does not mark auto-link transition", async () => { + const externalMessageId = randomUUID(); + await upsertMessage({ + externalMessageId, + processingStatus: "manual_linked", + }); + + const second = await upsertMessage({ + externalMessageId, + processingStatus: "auto_linked", + }); + + expect(second.wasCreated).toBe(false); + expect(second.previousProcessingStatus).toBe("manual_linked"); + expect(second.autoLinkTransitioned).toBe(false); + expect(second.message.processingStatus).toBe("manual_linked"); + }); +}); diff --git a/orchestrator/src/server/repositories/post-application-messages.ts b/orchestrator/src/server/repositories/post-application-messages.ts new file mode 100644 index 0000000..8fafffc --- /dev/null +++ b/orchestrator/src/server/repositories/post-application-messages.ts @@ -0,0 +1,370 @@ +import { randomUUID } from "node:crypto"; +import type { + PostApplicationMessage, + PostApplicationMessageType, + PostApplicationProcessingStatus, + PostApplicationProvider, + PostApplicationRelevanceDecision, + PostApplicationRouterStageTarget, +} from "@shared/types"; +import { and, desc, eq } from "drizzle-orm"; +import { db, schema } from "../db"; +import { + normalizeStageTarget, + stageTargetFromMessageType, +} from "../services/post-application/stage-target"; + +const { postApplicationMessages } = schema; + +type UpsertPostApplicationMessageInput = { + provider: PostApplicationProvider; + accountKey: string; + integrationId: string | null; + syncRunId: string | null; + externalMessageId: string; + externalThreadId?: string | null; + fromAddress: string; + fromDomain?: string | null; + senderName?: string | null; + subject: string; + receivedAt: number; + snippet: string; + classificationLabel?: string | null; + classificationConfidence?: number | null; + classificationPayload?: Record | null; + relevanceLlmScore?: number | null; + relevanceDecision: PostApplicationRelevanceDecision; + matchConfidence?: number | null; + stageTarget?: PostApplicationRouterStageTarget | null; + messageType: PostApplicationMessageType; + stageEventPayload?: Record | null; + processingStatus: PostApplicationProcessingStatus; + matchedJobId?: string | null; + decidedAt?: number | null; + decidedBy?: string | null; + errorCode?: string | null; + errorMessage?: string | null; +}; + +type UpdatePostApplicationMessageSuggestionInput = { + id: string; + matchedJobId: string | null; + matchConfidence?: number | null; + processingStatus: PostApplicationProcessingStatus; +}; + +type UpdatePostApplicationMessageDecisionInput = { + id: string; + processingStatus: Extract< + PostApplicationProcessingStatus, + "manual_linked" | "ignored" + >; + matchedJobId: string | null; + decidedAt?: number; + decidedBy?: string | null; +}; + +export type UpsertPostApplicationMessageResult = { + message: PostApplicationMessage; + wasCreated: boolean; + previousProcessingStatus: PostApplicationProcessingStatus | null; + autoLinkTransitioned: boolean; +}; + +function isTerminalProcessingStatus( + status: PostApplicationProcessingStatus, +): boolean { + return status !== "pending_user"; +} + +function mapRowToPostApplicationMessage( + row: typeof postApplicationMessages.$inferSelect, +): PostApplicationMessage { + const stageEventPayload = + (row.stageEventPayload as Record | null) ?? null; + const stageTarget = + normalizeStageTarget(stageEventPayload?.suggestedStageTarget) ?? + normalizeStageTarget(row.classificationLabel) ?? + stageTargetFromMessageType(row.messageType as PostApplicationMessageType); + + return { + id: row.id, + provider: row.provider, + accountKey: row.accountKey, + integrationId: row.integrationId, + syncRunId: row.syncRunId, + externalMessageId: row.externalMessageId, + externalThreadId: row.externalThreadId, + fromAddress: row.fromAddress, + fromDomain: row.fromDomain, + senderName: row.senderName, + subject: row.subject, + receivedAt: row.receivedAt, + snippet: row.snippet, + classificationLabel: row.classificationLabel, + classificationConfidence: row.classificationConfidence, + classificationPayload: + (row.classificationPayload as Record | null) ?? null, + relevanceLlmScore: row.relevanceLlmScore, + relevanceDecision: + row.relevanceDecision as PostApplicationRelevanceDecision, + matchedJobId: row.matchedJobId, + matchConfidence: row.matchConfidence, + stageTarget, + messageType: row.messageType as PostApplicationMessageType, + stageEventPayload, + processingStatus: row.processingStatus as PostApplicationProcessingStatus, + decidedAt: row.decidedAt, + decidedBy: row.decidedBy, + errorCode: row.errorCode, + errorMessage: row.errorMessage, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +async function getPostApplicationMessageByExternalId( + provider: PostApplicationProvider, + accountKey: string, + externalMessageId: string, +): Promise { + const [row] = await db + .select() + .from(postApplicationMessages) + .where( + and( + eq(postApplicationMessages.provider, provider), + eq(postApplicationMessages.accountKey, accountKey), + eq(postApplicationMessages.externalMessageId, externalMessageId), + ), + ); + return row ? mapRowToPostApplicationMessage(row) : null; +} + +export async function getPostApplicationMessageById( + id: string, +): Promise { + const [row] = await db + .select() + .from(postApplicationMessages) + .where(eq(postApplicationMessages.id, id)); + return row ? mapRowToPostApplicationMessage(row) : null; +} + +export async function upsertPostApplicationMessage( + input: UpsertPostApplicationMessageInput, +): Promise { + const stageTarget = + input.stageTarget ?? + normalizeStageTarget(input.classificationLabel) ?? + stageTargetFromMessageType(input.messageType); + const stageEventPayload = { + ...(input.stageEventPayload ?? {}), + suggestedStageTarget: stageTarget, + }; + const nowIso = new Date().toISOString(); + const existing = await getPostApplicationMessageByExternalId( + input.provider, + input.accountKey, + input.externalMessageId, + ); + + if (existing) { + const nextProcessingStatus = isTerminalProcessingStatus( + existing.processingStatus, + ) + ? existing.processingStatus + : input.processingStatus; + const autoLinkTransitioned = + existing.processingStatus !== "auto_linked" && + nextProcessingStatus === "auto_linked"; + + await db + .update(postApplicationMessages) + .set({ + integrationId: input.integrationId, + syncRunId: input.syncRunId, + externalThreadId: input.externalThreadId ?? null, + fromAddress: input.fromAddress, + fromDomain: input.fromDomain ?? null, + senderName: input.senderName ?? null, + subject: input.subject, + receivedAt: input.receivedAt, + snippet: input.snippet, + classificationLabel: input.classificationLabel ?? null, + classificationConfidence: input.classificationConfidence ?? null, + classificationPayload: input.classificationPayload ?? null, + relevanceLlmScore: input.relevanceLlmScore ?? null, + relevanceDecision: input.relevanceDecision, + matchConfidence: input.matchConfidence ?? null, + messageType: input.messageType, + stageEventPayload, + processingStatus: nextProcessingStatus, + matchedJobId: input.matchedJobId ?? null, + decidedAt: input.decidedAt ?? null, + decidedBy: input.decidedBy ?? null, + errorCode: input.errorCode ?? null, + errorMessage: input.errorMessage ?? null, + updatedAt: nowIso, + }) + .where(eq(postApplicationMessages.id, existing.id)); + + const updated = await getPostApplicationMessageByExternalId( + input.provider, + input.accountKey, + input.externalMessageId, + ); + if (!updated) { + throw new Error( + `Failed to load updated post-application message ${input.externalMessageId}.`, + ); + } + return { + message: updated, + wasCreated: false, + previousProcessingStatus: existing.processingStatus, + autoLinkTransitioned, + }; + } + + const id = randomUUID(); + await db.insert(postApplicationMessages).values({ + id, + provider: input.provider, + accountKey: input.accountKey, + integrationId: input.integrationId, + syncRunId: input.syncRunId, + externalMessageId: input.externalMessageId, + externalThreadId: input.externalThreadId ?? null, + fromAddress: input.fromAddress, + fromDomain: input.fromDomain ?? null, + senderName: input.senderName ?? null, + subject: input.subject, + receivedAt: input.receivedAt, + snippet: input.snippet, + classificationLabel: input.classificationLabel ?? null, + classificationConfidence: input.classificationConfidence ?? null, + classificationPayload: input.classificationPayload ?? null, + relevanceLlmScore: input.relevanceLlmScore ?? null, + relevanceDecision: input.relevanceDecision, + matchConfidence: input.matchConfidence ?? null, + messageType: input.messageType, + stageEventPayload, + processingStatus: input.processingStatus, + matchedJobId: input.matchedJobId ?? null, + decidedAt: input.decidedAt ?? null, + decidedBy: input.decidedBy ?? null, + errorCode: input.errorCode ?? null, + errorMessage: input.errorMessage ?? null, + createdAt: nowIso, + updatedAt: nowIso, + }); + + const created = await getPostApplicationMessageByExternalId( + input.provider, + input.accountKey, + input.externalMessageId, + ); + if (!created) { + throw new Error( + `Failed to load created post-application message ${input.externalMessageId}.`, + ); + } + return { + message: created, + wasCreated: true, + previousProcessingStatus: null, + autoLinkTransitioned: input.processingStatus === "auto_linked", + }; +} + +export async function updatePostApplicationMessageSuggestion( + input: UpdatePostApplicationMessageSuggestionInput, +): Promise { + const nowIso = new Date().toISOString(); + await db + .update(postApplicationMessages) + .set({ + matchedJobId: input.matchedJobId, + ...(input.matchConfidence !== undefined + ? { matchConfidence: input.matchConfidence } + : {}), + processingStatus: input.processingStatus, + updatedAt: nowIso, + }) + .where(eq(postApplicationMessages.id, input.id)); + + const [row] = await db + .select() + .from(postApplicationMessages) + .where(eq(postApplicationMessages.id, input.id)); + return row ? mapRowToPostApplicationMessage(row) : null; +} + +export async function listPostApplicationMessagesByProcessingStatus( + provider: PostApplicationProvider, + accountKey: string, + processingStatus: PostApplicationProcessingStatus, + limit = 50, +): Promise { + const rows = await db + .select() + .from(postApplicationMessages) + .where( + and( + eq(postApplicationMessages.provider, provider), + eq(postApplicationMessages.accountKey, accountKey), + eq(postApplicationMessages.processingStatus, processingStatus), + ), + ) + .orderBy(desc(postApplicationMessages.receivedAt)) + .limit(limit); + + return rows.map(mapRowToPostApplicationMessage); +} + +export async function listPostApplicationMessagesBySyncRun( + provider: PostApplicationProvider, + accountKey: string, + syncRunId: string, + limit = 300, +): Promise { + const rows = await db + .select() + .from(postApplicationMessages) + .where( + and( + eq(postApplicationMessages.provider, provider), + eq(postApplicationMessages.accountKey, accountKey), + eq(postApplicationMessages.syncRunId, syncRunId), + ), + ) + .orderBy(desc(postApplicationMessages.receivedAt)) + .limit(limit); + + return rows.map(mapRowToPostApplicationMessage); +} + +export async function updatePostApplicationMessageDecision( + input: UpdatePostApplicationMessageDecisionInput, +): Promise { + const decidedAt = input.decidedAt ?? Date.now(); + const nowIso = new Date(decidedAt).toISOString(); + + await db + .update(postApplicationMessages) + .set({ + processingStatus: input.processingStatus, + matchedJobId: input.matchedJobId, + decidedAt, + decidedBy: input.decidedBy ?? null, + updatedAt: nowIso, + }) + .where(eq(postApplicationMessages.id, input.id)); + + const [row] = await db + .select() + .from(postApplicationMessages) + .where(eq(postApplicationMessages.id, input.id)); + return row ? mapRowToPostApplicationMessage(row) : null; +} diff --git a/orchestrator/src/server/repositories/post-application-sync-runs.ts b/orchestrator/src/server/repositories/post-application-sync-runs.ts new file mode 100644 index 0000000..1d1f75d --- /dev/null +++ b/orchestrator/src/server/repositories/post-application-sync-runs.ts @@ -0,0 +1,146 @@ +import { randomUUID } from "node:crypto"; +import type { + PostApplicationProvider, + PostApplicationSyncRun, + PostApplicationSyncRunStatus, +} from "@shared/types"; +import { and, desc, eq } from "drizzle-orm"; +import { db, schema } from "../db"; + +const { postApplicationSyncRuns } = schema; + +type StartPostApplicationSyncRunInput = { + provider: PostApplicationProvider; + accountKey: string; + integrationId: string | null; +}; + +type CompletePostApplicationSyncRunInput = { + id: string; + status: Exclude; + messagesDiscovered: number; + messagesRelevant: number; + messagesClassified: number; + messagesMatched?: number; + messagesApproved?: number; + messagesDenied?: number; + messagesErrored: number; + errorCode?: string | null; + errorMessage?: string | null; +}; + +function mapRowToSyncRun( + row: typeof postApplicationSyncRuns.$inferSelect, +): PostApplicationSyncRun { + return { + id: row.id, + provider: row.provider, + accountKey: row.accountKey, + integrationId: row.integrationId, + status: row.status as PostApplicationSyncRunStatus, + startedAt: row.startedAt, + completedAt: row.completedAt, + messagesDiscovered: row.messagesDiscovered, + messagesRelevant: row.messagesRelevant, + messagesClassified: row.messagesClassified, + messagesMatched: row.messagesMatched, + messagesApproved: row.messagesApproved, + messagesDenied: row.messagesDenied, + messagesErrored: row.messagesErrored, + errorCode: row.errorCode, + errorMessage: row.errorMessage, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export async function startPostApplicationSyncRun( + input: StartPostApplicationSyncRunInput, +): Promise { + const id = randomUUID(); + const nowEpoch = Date.now(); + const nowIso = new Date(nowEpoch).toISOString(); + + await db.insert(postApplicationSyncRuns).values({ + id, + provider: input.provider, + accountKey: input.accountKey, + integrationId: input.integrationId, + status: "running", + startedAt: nowEpoch, + completedAt: null, + messagesDiscovered: 0, + messagesRelevant: 0, + messagesClassified: 0, + messagesMatched: 0, + messagesApproved: 0, + messagesDenied: 0, + messagesErrored: 0, + errorCode: null, + errorMessage: null, + createdAt: nowIso, + updatedAt: nowIso, + }); + + const run = await getPostApplicationSyncRunById(id); + if (!run) { + throw new Error(`Failed to load created post-application sync run ${id}.`); + } + return run; +} + +export async function completePostApplicationSyncRun( + input: CompletePostApplicationSyncRunInput, +): Promise { + const nowEpoch = Date.now(); + const nowIso = new Date(nowEpoch).toISOString(); + + await db + .update(postApplicationSyncRuns) + .set({ + status: input.status, + completedAt: nowEpoch, + messagesDiscovered: input.messagesDiscovered, + messagesRelevant: input.messagesRelevant, + messagesClassified: input.messagesClassified, + messagesMatched: input.messagesMatched ?? 0, + messagesApproved: input.messagesApproved ?? 0, + messagesDenied: input.messagesDenied ?? 0, + messagesErrored: input.messagesErrored, + errorCode: input.errorCode ?? null, + errorMessage: input.errorMessage ?? null, + updatedAt: nowIso, + }) + .where(eq(postApplicationSyncRuns.id, input.id)); + + return getPostApplicationSyncRunById(input.id); +} + +export async function getPostApplicationSyncRunById( + id: string, +): Promise { + const [row] = await db + .select() + .from(postApplicationSyncRuns) + .where(eq(postApplicationSyncRuns.id, id)); + return row ? mapRowToSyncRun(row) : null; +} + +export async function listPostApplicationSyncRuns( + provider: PostApplicationProvider, + accountKey: string, + limit = 20, +): Promise { + const rows = await db + .select() + .from(postApplicationSyncRuns) + .where( + and( + eq(postApplicationSyncRuns.provider, provider), + eq(postApplicationSyncRuns.accountKey, accountKey), + ), + ) + .orderBy(desc(postApplicationSyncRuns.startedAt)) + .limit(limit); + return rows.map(mapRowToSyncRun); +} diff --git a/orchestrator/src/server/services/post-application/ingestion/gmail-sync.idempotency.test.ts b/orchestrator/src/server/services/post-application/ingestion/gmail-sync.idempotency.test.ts new file mode 100644 index 0000000..12fd424 --- /dev/null +++ b/orchestrator/src/server/services/post-application/ingestion/gmail-sync.idempotency.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@server/repositories/post-application-integrations", () => ({ + getPostApplicationIntegration: vi.fn().mockResolvedValue({ + id: "integration-1", + provider: "gmail", + accountKey: "default", + displayName: "Gmail", + status: "connected", + credentials: { + refreshToken: "refresh-token", + accessToken: "access-token", + expiryDate: Date.now() + 60 * 60 * 1000, + }, + lastConnectedAt: null, + lastSyncedAt: null, + lastError: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + updatePostApplicationIntegrationSyncState: vi.fn().mockResolvedValue(null), + upsertConnectedPostApplicationIntegration: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@server/repositories/post-application-sync-runs", () => ({ + startPostApplicationSyncRun: vi + .fn() + .mockResolvedValue({ id: "sync-run-1", startedAt: Date.now() }), + completePostApplicationSyncRun: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@server/repositories/jobs", () => ({ + getAllJobs: vi.fn().mockResolvedValue([ + { + id: "job-1", + employer: "Example Co", + title: "Software Engineer", + status: "applied", + }, + ]), +})); + +const upsertPostApplicationMessage = vi.fn(); +vi.mock("@server/repositories/post-application-messages", () => ({ + upsertPostApplicationMessage, +})); + +const transitionStage = vi.fn(); +vi.mock("@server/services/applicationTracking", () => ({ + transitionStage, +})); + +vi.mock("@server/repositories/settings", () => ({ + getSetting: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@server/services/llm-service", () => ({ + LlmService: class { + callJson() { + return Promise.resolve({ + success: true, + data: { + bestMatchIndex: 1, + confidence: 99, + stageTarget: "assessment", + isRelevant: true, + stageEventPayload: null, + reason: "matches", + }, + }); + } + }, +})); + +function makeJsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + json: async () => body, + } as unknown as Response; +} + +describe("gmail sync auto-log idempotency", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.includes("/gmail/v1/users/me/messages?")) { + return makeJsonResponse({ + messages: [{ id: "message-1", threadId: "thread-1" }], + }); + } + if (url.includes("message-1") && url.includes("format=metadata")) { + return makeJsonResponse({ + id: "message-1", + threadId: "thread-1", + snippet: "snippet", + payload: { + headers: [ + { name: "From", value: "Recruiter " }, + { name: "Subject", value: "Interview update" }, + { name: "Date", value: new Date().toUTCString() }, + ], + }, + }); + } + if (url.includes("message-1") && url.includes("format=full")) { + return makeJsonResponse({ + id: "message-1", + threadId: "thread-1", + snippet: "snippet", + payload: { + mimeType: "text/plain", + body: { + data: Buffer.from("Hello").toString("base64url"), + }, + }, + }); + } + + throw new Error(`Unexpected fetch URL in test: ${url}`); + }), + ); + }); + + it("creates auto stage event only on first auto_linked transition", async () => { + const { runGmailIngestionSync } = await import("./gmail-sync"); + + upsertPostApplicationMessage + .mockResolvedValueOnce({ + message: { + id: "post-msg-1", + matchedJobId: "job-1", + processingStatus: "auto_linked", + stageTarget: "assessment", + receivedAt: Date.now(), + }, + wasCreated: true, + previousProcessingStatus: null, + autoLinkTransitioned: true, + }) + .mockResolvedValueOnce({ + message: { + id: "post-msg-1", + matchedJobId: "job-1", + processingStatus: "auto_linked", + stageTarget: "assessment", + receivedAt: Date.now(), + }, + wasCreated: false, + previousProcessingStatus: "auto_linked", + autoLinkTransitioned: false, + }); + + await runGmailIngestionSync({ accountKey: "default", maxMessages: 1 }); + await runGmailIngestionSync({ accountKey: "default", maxMessages: 1 }); + + expect(upsertPostApplicationMessage).toHaveBeenCalledTimes(2); + expect(transitionStage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/orchestrator/src/server/services/post-application/ingestion/gmail-sync.test.ts b/orchestrator/src/server/services/post-application/ingestion/gmail-sync.test.ts new file mode 100644 index 0000000..d2729e2 --- /dev/null +++ b/orchestrator/src/server/services/post-application/ingestion/gmail-sync.test.ts @@ -0,0 +1,229 @@ +import type { AppError } from "@infra/errors"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { __test__, gmailApi, resolveGmailAccessToken } from "./gmail-sync"; + +describe("gmail sync http behavior", () => { + const originalClientId = process.env.GMAIL_OAUTH_CLIENT_ID; + const originalClientSecret = process.env.GMAIL_OAUTH_CLIENT_SECRET; + + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + process.env.GMAIL_OAUTH_CLIENT_ID = "client-id"; + process.env.GMAIL_OAUTH_CLIENT_SECRET = "client-secret"; + }); + + afterEach(() => { + process.env.GMAIL_OAUTH_CLIENT_ID = originalClientId; + process.env.GMAIL_OAUTH_CLIENT_SECRET = originalClientSecret; + vi.restoreAllMocks(); + }); + + it("maps token refresh abort to REQUEST_TIMEOUT", async () => { + vi.mocked(fetch).mockRejectedValueOnce( + new DOMException("Aborted", "AbortError"), + ); + + await expect( + resolveGmailAccessToken({ refreshToken: "refresh-token" }), + ).rejects.toMatchObject({ + status: 408, + code: "REQUEST_TIMEOUT", + } satisfies Partial); + }); + + it("throws upstream token refresh error when response is not ok", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + json: vi.fn().mockResolvedValue({ error: "invalid_grant" }), + } as unknown as Response); + + await expect( + resolveGmailAccessToken({ refreshToken: "refresh-token" }), + ).rejects.toThrow("Gmail token refresh failed with HTTP 401."); + }); + + it("returns refreshed credentials when token refresh succeeds", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + access_token: "new-access-token", + expires_in: 1200, + }), + } as unknown as Response); + + const refreshed = await resolveGmailAccessToken({ + refreshToken: "refresh-token", + }); + + expect(refreshed.accessToken).toBe("new-access-token"); + expect(typeof refreshed.expiryDate).toBe("number"); + expect(refreshed.expiryDate).toBeGreaterThan(Date.now()); + }); + + it("maps gmail API abort to REQUEST_TIMEOUT", async () => { + vi.mocked(fetch).mockRejectedValueOnce( + new DOMException("Aborted", "AbortError"), + ); + + await expect( + gmailApi("access-token", "https://gmail.googleapis.com/test"), + ).rejects.toMatchObject({ + status: 408, + code: "REQUEST_TIMEOUT", + } satisfies Partial); + }); + + it("throws when gmail API response is not ok", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 502, + json: vi.fn().mockResolvedValue({}), + } as unknown as Response); + + await expect( + gmailApi("access-token", "https://gmail.googleapis.com/test"), + ).rejects.toThrow("Gmail API request failed (502)."); + }); + + it("returns gmail API payload on success", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ id: "message-1" }), + } as unknown as Response); + + const response = await gmailApi<{ id: string }>( + "access-token", + "https://gmail.googleapis.com/test", + ); + + expect(response).toEqual({ id: "message-1" }); + }); +}); + +describe("gmail sync body extraction", () => { + const encodeBase64Url = (value: string): string => + Buffer.from(value, "utf8").toString("base64url"); + + it("removes scripts/styles/images and strips link URLs from html bodies", () => { + const payload = { + mimeType: "text/html", + body: { + data: encodeBase64Url(` + + + + + + +

Hello there.

+ Apply now + Banner + + + `), + }, + }; + + const body = __test__.extractBodyText(payload); + + expect(body).toContain("Hello there."); + expect(body).toContain("Apply now"); + expect(body).not.toContain("https://example.com/apply?token=abc"); + expect(body).not.toContain("display: none"); + expect(body).not.toContain('console.log("secret")'); + expect(body).not.toContain("banner.png"); + }); + + it("uses text/plain only for multipart/alternative when plain text exceeds threshold", () => { + const payload = { + mimeType: "multipart/alternative", + parts: [ + { + mimeType: "text/plain", + body: { + data: encodeBase64Url( + "This plain text message is definitely longer than fifty characters and should win.", + ), + }, + }, + { + mimeType: "text/html", + body: { + data: encodeBase64Url( + "

HTML version should be ignored when plain text is long enough.

", + ), + }, + }, + ], + }; + + const body = __test__.extractBodyText(payload); + expect(body).toContain("plain text message"); + expect(body).not.toContain("HTML version should be ignored"); + }); + + it("prefers plain text even when multipart/alternative plain text is short", () => { + const payload = { + mimeType: "multipart/alternative", + parts: [ + { + mimeType: "text/plain", + body: { data: encodeBase64Url("Too short") }, + }, + { + mimeType: "text/html", + body: { + data: encodeBase64Url("

Preferred HTML content

"), + }, + }, + ], + }; + + const body = __test__.extractBodyText(payload); + expect(body).toContain("Too short"); + expect(body).not.toContain("Preferred HTML content"); + }); + + it("deduplicates repeated text chunks across parts", () => { + const payload = { + mimeType: "multipart/mixed", + parts: [ + { + mimeType: "text/plain", + body: { data: encodeBase64Url("Repeated sentence here.") }, + }, + { + mimeType: "text/plain", + body: { data: encodeBase64Url("Repeated sentence here.") }, + }, + ], + }; + + const body = __test__.extractBodyText(payload); + expect(body).toBe("Repeated sentence here."); + }); + + it("returns empty string when payload is missing", () => { + expect(__test__.extractBodyText(undefined)).toBe(""); + }); +}); + +describe("gmail sync prompt assembly", () => { + it("omits snippet from email text sent to the llm", () => { + const emailText = __test__.buildEmailText({ + from: "jobs@example.com", + subject: "Interview update", + date: "Mon, 1 Jan 2026 10:00:00 +0000", + body: "Hello from body", + }); + + expect(emailText).toContain("From: jobs@example.com"); + expect(emailText).toContain("Subject: Interview update"); + expect(emailText).toContain("Date: Mon, 1 Jan 2026 10:00:00 +0000"); + expect(emailText).toContain("Body:\nHello from body"); + expect(emailText).not.toContain("Snippet:"); + }); +}); diff --git a/orchestrator/src/server/services/post-application/ingestion/gmail-sync.ts b/orchestrator/src/server/services/post-application/ingestion/gmail-sync.ts new file mode 100644 index 0000000..8732c54 --- /dev/null +++ b/orchestrator/src/server/services/post-application/ingestion/gmail-sync.ts @@ -0,0 +1,975 @@ +import { requestTimeout } from "@infra/errors"; +import { logger } from "@infra/logger"; +import { getAllJobs } from "@server/repositories/jobs"; +import { + getPostApplicationIntegration, + updatePostApplicationIntegrationSyncState, + upsertConnectedPostApplicationIntegration, +} from "@server/repositories/post-application-integrations"; +import { upsertPostApplicationMessage } from "@server/repositories/post-application-messages"; +import { + completePostApplicationSyncRun, + startPostApplicationSyncRun, +} from "@server/repositories/post-application-sync-runs"; +import { getSetting } from "@server/repositories/settings"; +import { transitionStage } from "@server/services/applicationTracking"; +import { + type JsonSchemaDefinition, + LlmService, +} from "@server/services/llm-service"; +import { + messageTypeFromStageTarget, + normalizeStageTarget, + resolveStageTransitionForTarget, +} from "@server/services/post-application/stage-target"; +import { + type Job, + POST_APPLICATION_ROUTER_STAGE_TARGETS, + type PostApplicationMessageType, + type PostApplicationRouterStageTarget, +} from "@shared/types"; +import { convert } from "html-to-text"; + +const DEFAULT_SEARCH_DAYS = 90; +const DEFAULT_MAX_MESSAGES = 100; +const GMAIL_HTTP_TIMEOUT_MS = 15_000; +const ROUTER_EMAIL_CHAR_LIMIT = 12_000; + +const SMART_ROUTER_SCHEMA: JsonSchemaDefinition = { + name: "post_application_email_router", + schema: { + type: "object", + properties: { + bestMatchIndex: { + type: ["integer", "null"], + description: + "Best matching active-job index from provided list (1-based), or null.", + }, + confidence: { + type: "integer", + description: "Confidence score 0-100 for routing decision.", + }, + stageTarget: { + type: "string", + enum: [...POST_APPLICATION_ROUTER_STAGE_TARGETS], + description: + "Normalized stage target for this message, matching Log Event options.", + }, + isRelevant: { + type: "boolean", + description: + "Whether this is a relevant recruitment/application email.", + }, + stageEventPayload: { + type: ["object", "null"], + description: "Structured metadata for a potential stage event.", + additionalProperties: true, + }, + reason: { + type: "string", + description: "One sentence reason for the routing decision.", + }, + }, + required: [ + "bestMatchIndex", + "confidence", + "stageTarget", + "isRelevant", + "stageEventPayload", + "reason", + ], + additionalProperties: false, + }, +}; + +type GmailCredentials = { + refreshToken: string; + accessToken?: string; + expiryDate?: number; + scope?: string; + tokenType?: string; + email?: string; +}; + +type GmailListMessage = { + id: string; + threadId: string; +}; + +type GmailHeader = { name?: string; value?: string }; + +type GmailMetadataMessage = { + id: string; + threadId: string; + snippet: string; + headers: GmailHeader[]; +}; + +type GmailFullMessage = GmailMetadataMessage & { + payload?: { + mimeType?: string; + body?: { data?: string }; + parts?: Array<{ + mimeType?: string; + body?: { data?: string }; + parts?: unknown[]; + }>; + }; +}; + +type SmartRouterResult = { + bestMatchId: string | null; + confidence: number; + stageTarget: PostApplicationRouterStageTarget; + messageType: PostApplicationMessageType; + isRelevant: boolean; + stageEventPayload: Record | null; + reason: string; +}; + +type IndexedActiveJob = { + index: number; + id: string; + company: string; + title: string; +}; + +export type GmailSyncSummary = { + discovered: number; + relevant: number; + classified: number; + errored: number; +}; + +function asString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function parseGmailCredentials( + credentials: Record | null, +): GmailCredentials | null { + if (!credentials) return null; + const refreshToken = asString(credentials.refreshToken); + if (!refreshToken) return null; + + const accessToken = asString(credentials.accessToken) ?? undefined; + const expiryDate = + typeof credentials.expiryDate === "number" && + Number.isFinite(credentials.expiryDate) + ? credentials.expiryDate + : undefined; + + return { + refreshToken, + accessToken, + expiryDate, + scope: asString(credentials.scope) ?? undefined, + tokenType: asString(credentials.tokenType) ?? undefined, + email: asString(credentials.email) ?? undefined, + }; +} + +export async function resolveGmailAccessToken( + credentials: GmailCredentials, +): Promise { + const now = Date.now(); + if ( + credentials.accessToken && + credentials.expiryDate && + credentials.expiryDate > now + 60_000 + ) { + return credentials; + } + + const clientId = asString(process.env.GMAIL_OAUTH_CLIENT_ID); + const clientSecret = asString(process.env.GMAIL_OAUTH_CLIENT_SECRET); + if (!clientId || !clientSecret) { + throw new Error( + "Missing GMAIL_OAUTH_CLIENT_ID or GMAIL_OAUTH_CLIENT_SECRET for Gmail token refresh.", + ); + } + + const body = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "refresh_token", + refresh_token: credentials.refreshToken, + }); + + const response = await fetchWithTimeout( + "https://oauth2.googleapis.com/token", + { + timeoutMs: GMAIL_HTTP_TIMEOUT_MS, + init: { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }, + }, + ); + const data = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(`Gmail token refresh failed with HTTP ${response.status}.`); + } + + const accessToken = asString(data?.access_token); + const expiresIn = + typeof data?.expires_in === "number" && Number.isFinite(data.expires_in) + ? data.expires_in + : 3600; + if (!accessToken) { + throw new Error( + "Gmail token refresh response did not include access_token.", + ); + } + + return { + ...credentials, + accessToken, + expiryDate: Date.now() + expiresIn * 1000, + }; +} + +export async function gmailApi(token: string, url: string): Promise { + const response = await fetchWithTimeout(url, { + timeoutMs: GMAIL_HTTP_TIMEOUT_MS, + init: { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }, + }); + + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(`Gmail API request failed (${response.status}).`); + } + return data as T; +} + +async function fetchWithTimeout( + url: string, + args: { timeoutMs: number; init: RequestInit }, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), args.timeoutMs); + + try { + return await fetch(url, { + ...args.init, + signal: controller.signal, + }); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "AbortError" + ) { + throw requestTimeout( + `Gmail request timed out after ${args.timeoutMs}ms for ${url}.`, + ); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +function buildGmailQuery(searchDays: number): string { + const subjectTerms = [ + "application", + "thank you for applying", + "thanks for applying", + "application received", + "application submitted", + "your application", + "interview", + "assessment", + "coding challenge", + "take-home", + "availability", + "offer", + "offer letter", + "referral", + "recruiter", + "hiring team", + "regret to inform", + "not moving forward", + "not selected", + "application unsuccessful", + "moving forward with other candidates", + "unable to proceed", + "position has been filled", + "hiring freeze", + "position on hold", + "withdrawn", + ]; + const fromTerms = [ + "careers@", + "jobs@", + "recruiting@", + "talent@", + "no-reply@greenhouse.io", + "no-reply@us.greenhouse-mail.io", + "no-reply@ashbyhq.com", + "notification@smartrecruiters.com", + "@smartrecruiters.com", + "@workablemail.com", + "@hire.lever.co", + "@myworkday.com", + "@workdaymail.com", + "@greenhouse.io", + "@ashbyhq.com", + ]; + const excludeSubjectTerms = [ + "newsletter", + "webinar", + "course", + "discount", + "event invitation", + "job search council", + "matched new opportunities", + ]; + + const quoteTerm = (value: string) => `"${value.replace(/"/g, '\\"')}"`; + const subjectBlock = subjectTerms + .map((term) => `subject:${quoteTerm(term)}`) + .join(" OR "); + const fromBlock = fromTerms + .map((term) => `from:${quoteTerm(term)}`) + .join(" OR "); + const excludeClauses = excludeSubjectTerms + .map((term) => `-subject:${quoteTerm(term)}`) + .join(" "); + + return `newer_than:${searchDays}d ((${subjectBlock}) OR (${fromBlock})) ${excludeClauses}`.trim(); +} + +async function listMessageIds( + token: string, + searchDays: number, + maxMessages: number, +): Promise { + const messages: GmailListMessage[] = []; + let pageToken: string | undefined; + + do { + const q = encodeURIComponent(buildGmailQuery(searchDays)); + const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${q}&maxResults=${Math.min( + 100, + maxMessages, + )}${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`; + + const page = await gmailApi<{ + messages?: Array<{ id?: string; threadId?: string }>; + nextPageToken?: string; + }>(token, listUrl); + + for (const message of page.messages ?? []) { + if (!message.id || !message.threadId) continue; + messages.push({ id: message.id, threadId: message.threadId }); + if (messages.length >= maxMessages) { + return messages; + } + } + pageToken = page.nextPageToken; + } while (pageToken && messages.length < maxMessages); + + return messages; +} + +function headerValue(headers: GmailHeader[], name: string): string { + const found = headers.find( + (header) => (header.name ?? "").toLowerCase() === name.toLowerCase(), + ); + return String(found?.value ?? ""); +} + +function parseFromHeader(fromHeader: string): { + fromAddress: string; + fromDomain: string | null; + senderName: string | null; +} { + const match = fromHeader.match(/^(.*?)<([^>]+)>$/); + const senderName = match?.[1]?.trim() || null; + const fromAddress = (match?.[2] || fromHeader).trim().toLowerCase(); + const atIndex = fromAddress.indexOf("@"); + const fromDomain = + atIndex > 0 ? fromAddress.slice(atIndex + 1).toLowerCase() : null; + + return { fromAddress, fromDomain, senderName }; +} + +function parseReceivedAt(dateHeader: string): number { + const parsed = Date.parse(dateHeader); + return Number.isFinite(parsed) ? parsed : Date.now(); +} + +function decodeBase64Url(value: string): string { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + return Buffer.from(padded, "base64").toString("utf8"); +} + +function cleanEmailHtmlForLlm(htmlContent: string): string { + return convert(htmlContent, { + wordwrap: 130, + selectors: [ + { selector: "img", format: "skip" }, + { selector: "a", options: { ignoreHref: true } }, + { selector: "style", format: "skip" }, + { selector: "script", format: "skip" }, + ], + }); +} + +function normalizeChunkForDedup(value: string): string { + return value.replace(/\s+/g, " ").trim().toLowerCase(); +} + +function decodeTextPart( + part: NonNullable, +): string { + const data = part.body?.data; + if (!data) return ""; + const decoded = decodeBase64Url(data); + const mimeType = String(part.mimeType ?? "").toLowerCase(); + if (mimeType.includes("text/html")) { + return cleanEmailHtmlForLlm(decoded); + } + if (mimeType.startsWith("text/")) { + return decoded; + } + return ""; +} + +function extractBodyText(payload: GmailFullMessage["payload"]): string { + if (!payload) return ""; + const chunks: string[] = []; + const seen = new Set(); + const addChunk = (value: string): void => { + const chunk = value.trim(); + if (!chunk) return; + const normalized = normalizeChunkForDedup(chunk); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + chunks.push(chunk); + }; + + const walk = (part: NonNullable): void => { + const mimeType = String(part.mimeType ?? "").toLowerCase(); + + if (mimeType === "multipart/alternative") { + const children = (part.parts ?? []) as Array< + NonNullable + >; + const plainChild = children.find( + (child) => String(child.mimeType ?? "").toLowerCase() === "text/plain", + ); + const plainText = plainChild ? decodeTextPart(plainChild).trim() : ""; + if (plainText.length > 50) { + addChunk(plainText); + return; + } + + if (plainText) { + addChunk(plainText); + return; + } + + const htmlChild = children.find((child) => + String(child.mimeType ?? "") + .toLowerCase() + .includes("text/html"), + ); + if (htmlChild) { + addChunk(decodeTextPart(htmlChild)); + return; + } + } + + const chunk = decodeTextPart(part); + if (chunk) { + addChunk(chunk); + } + + for (const child of part.parts ?? []) { + walk(child as NonNullable); + } + }; + + walk(payload); + return chunks.join("\n\n").trim(); +} + +export const __test__ = { + extractBodyText, + buildEmailText, +}; + +function buildEmailText(input: { + from: string; + subject: string; + date: string; + body: string; +}): string { + return `From: ${input.from} +Subject: ${input.subject} +Date: ${input.date} +Body: +${input.body}`.trim(); +} + +function minifyActiveJobs(jobs: Job[]): Array<{ + id: string; + company: string; + title: string; +}> { + return jobs.map((job) => ({ + id: job.id, + company: job.employer, + title: job.title, + })); +} + +function sanitizeJobPromptValue(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function buildIndexedActiveJobs( + jobs: Array<{ id: string; company: string; title: string }>, +): IndexedActiveJob[] { + return jobs.map((job, offset) => ({ + index: offset + 1, + id: job.id, + company: sanitizeJobPromptValue(job.company || "Unknown company"), + title: sanitizeJobPromptValue(job.title || "Unknown title"), + })); +} + +function buildCompactActiveJobsList(jobs: IndexedActiveJob[]): string { + return jobs + .map((job) => `${job.index}. ${job.company}: ${job.title}`) + .join("\n"); +} + +function normalizeBestMatchIndex(value: unknown, max: number): number | null { + if (value === null || value === undefined || max <= 0) return null; + const numeric = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseInt(value, 10) + : Number.NaN; + if (!Number.isFinite(numeric)) return null; + const rounded = Math.round(numeric); + if (rounded < 1 || rounded > max) return null; + return rounded; +} + +async function classifyWithSmartRouter(args: { + emailText: string; + activeJobs: Array<{ id: string; company: string; title: string }>; +}): Promise { + const overrideModel = await getSetting("model"); + const model = + overrideModel || process.env.MODEL || "google/gemini-3-flash-preview"; + const llmEmailText = args.emailText.slice(0, ROUTER_EMAIL_CHAR_LIMIT); + const indexedActiveJobs = buildIndexedActiveJobs(args.activeJobs); + const compactActiveJobsList = buildCompactActiveJobsList(indexedActiveJobs); + const messages = [ + { + role: "system" as const, + content: + "You are a smart router for post-application emails. Return only strict JSON. Ignore sensitive data and include only routing fields.", + }, + { + role: "user" as const, + content: `Route this email to one active job if possible. +- Choose bestMatchIndex only from listed job numbers (1-based), or null. +- confidence is 0..100. +- stageTarget must be one of: ${POST_APPLICATION_ROUTER_STAGE_TARGETS.join("|")}. +- isRelevant should be true for recruitment/application lifecycle emails. +- stageEventPayload should be minimal structured data or null. + +Active jobs (index. company: title): +${compactActiveJobsList} + +Email: +${llmEmailText}`, + }, + ]; + + const llm = new LlmService(); + const result = await llm.callJson<{ + bestMatchIndex: number | null; + confidence: number; + stageTarget: string; + isRelevant: boolean; + stageEventPayload: Record | null; + reason: string; + }>({ + model, + messages, + jsonSchema: SMART_ROUTER_SCHEMA, + maxRetries: 1, + retryDelayMs: 400, + }); + + if (!result.success) { + throw new Error(`LLM classification failed: ${result.error}`); + } + + const confidence = Math.max( + 0, + Math.min(100, Math.round(Number(result.data.confidence) || 0)), + ); + const bestMatchIndex = normalizeBestMatchIndex( + result.data.bestMatchIndex, + indexedActiveJobs.length, + ); + const bestMatchId = + bestMatchIndex !== null + ? (indexedActiveJobs[bestMatchIndex - 1]?.id ?? null) + : null; + const stageTarget = + normalizeStageTarget(result.data.stageTarget) ?? "no_change"; + const messageType = messageTypeFromStageTarget(stageTarget); + + return { + bestMatchId, + confidence, + stageTarget, + messageType, + isRelevant: Boolean(result.data.isRelevant), + stageEventPayload: + result.data.stageEventPayload && + typeof result.data.stageEventPayload === "object" + ? result.data.stageEventPayload + : null, + reason: String(result.data.reason ?? "").trim(), + }; +} + +async function getMessageMetadata( + token: string, + messageId: string, +): Promise { + const message = await gmailApi<{ + id?: string; + threadId?: string; + snippet?: string; + payload?: { headers?: GmailHeader[] }; + }>( + token, + `https://gmail.googleapis.com/gmail/v1/users/me/messages/${encodeURIComponent( + messageId, + )}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date`, + ); + + return { + id: message.id ?? messageId, + threadId: message.threadId ?? "", + snippet: message.snippet ?? "", + headers: message.payload?.headers ?? [], + }; +} + +async function getMessageFull( + token: string, + messageId: string, +): Promise { + const message = await gmailApi<{ + id?: string; + threadId?: string; + snippet?: string; + payload?: GmailFullMessage["payload"]; + }>( + token, + `https://gmail.googleapis.com/gmail/v1/users/me/messages/${encodeURIComponent( + messageId, + )}?format=full`, + ); + + return { + id: message.id ?? messageId, + threadId: message.threadId ?? "", + snippet: message.snippet ?? "", + headers: [], + payload: message.payload, + }; +} + +function normalizeErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return "Unknown error"; +} + +async function createAutoStageEvent(args: { + jobId: string; + stageTarget: PostApplicationRouterStageTarget; + receivedAt: number; + note: string; +}): Promise { + const transition = resolveStageTransitionForTarget(args.stageTarget); + if (transition.toStage === "no_change") return; + + const eventLabel = + args.stageTarget === "applied" + ? "Email received" + : `Logged from email: ${args.stageTarget}`; + + transitionStage( + args.jobId, + transition.toStage, + Math.floor(args.receivedAt / 1000), + { + actor: "system", + eventType: "status_update", + eventLabel, + note: args.note, + reasonCode: transition.reasonCode ?? "post_application_auto_linked", + }, + transition.outcome, + ); +} + +async function runWithConcurrency( + items: T[], + concurrency: number, + worker: (item: T) => Promise, +): Promise { + if (items.length === 0) return; + const queue = [...items]; + const workers = Array.from({ length: Math.max(1, concurrency) }).map( + async () => { + while (queue.length > 0) { + const next = queue.shift(); + if (!next) return; + await worker(next); + } + }, + ); + await Promise.all(workers); +} + +export async function runGmailIngestionSync(args: { + accountKey: string; + maxMessages?: number; + searchDays?: number; +}): Promise { + const integration = await getPostApplicationIntegration( + "gmail", + args.accountKey, + ); + const parsedCredentials = parseGmailCredentials( + integration?.credentials ?? null, + ); + if (!integration || !parsedCredentials) { + throw new Error(`Gmail account '${args.accountKey}' is not connected.`); + } + + const searchDays = Math.max(1, args.searchDays ?? DEFAULT_SEARCH_DAYS); + const maxMessages = Math.max(1, args.maxMessages ?? DEFAULT_MAX_MESSAGES); + + const syncRun = await startPostApplicationSyncRun({ + provider: "gmail", + accountKey: args.accountKey, + integrationId: integration.id, + }); + + let discovered = 0; + let relevant = 0; + let classified = 0; + let matched = 0; + let errored = 0; + + try { + const resolvedCredentials = + await resolveGmailAccessToken(parsedCredentials); + if (!resolvedCredentials.accessToken) { + throw new Error("Gmail sync failed to resolve access token."); + } + const accessToken = resolvedCredentials.accessToken; + + if ( + resolvedCredentials.accessToken !== parsedCredentials.accessToken || + resolvedCredentials.expiryDate !== parsedCredentials.expiryDate + ) { + await upsertConnectedPostApplicationIntegration({ + provider: "gmail", + accountKey: args.accountKey, + displayName: integration.displayName, + credentials: { + refreshToken: resolvedCredentials.refreshToken, + accessToken: resolvedCredentials.accessToken, + expiryDate: resolvedCredentials.expiryDate, + scope: resolvedCredentials.scope, + tokenType: resolvedCredentials.tokenType, + email: resolvedCredentials.email, + }, + }); + } + + const messageIds = await listMessageIds( + accessToken, + searchDays, + maxMessages, + ); + const activeJobs = await getAllJobs(["applied", "processing"]); + const activeJobMinified = minifyActiveJobs(activeJobs); + const activeJobIds = new Set(activeJobMinified.map((job) => job.id)); + const concurrency = Math.max( + 1, + Number.parseInt( + process.env.POST_APPLICATION_ROUTER_CONCURRENCY ?? "3", + 10, + ) || 3, + ); + + await runWithConcurrency(messageIds, concurrency, async (message) => { + discovered += 1; + + try { + const metadata = await getMessageMetadata(accessToken, message.id); + const from = headerValue(metadata.headers, "From"); + const subject = headerValue(metadata.headers, "Subject"); + const date = headerValue(metadata.headers, "Date"); + const { fromAddress, fromDomain, senderName } = parseFromHeader(from); + const receivedAt = parseReceivedAt(date); + + const fullMessage = await getMessageFull(accessToken, message.id); + const body = extractBodyText(fullMessage.payload); + const emailText = buildEmailText({ + from, + subject, + date, + body, + }); + const routerResult = await classifyWithSmartRouter({ + emailText, + activeJobs: activeJobMinified, + }); + + const matchedJobId = + routerResult.bestMatchId && activeJobIds.has(routerResult.bestMatchId) + ? routerResult.bestMatchId + : null; + const isAutoLinked = routerResult.confidence >= 95 && matchedJobId; + const isPendingMatch = routerResult.confidence >= 50; + const isRelevantOrphan = routerResult.isRelevant; + const processingStatus = isAutoLinked + ? "auto_linked" + : isPendingMatch || isRelevantOrphan + ? "pending_user" + : "ignored"; + + const { message: savedMessage, autoLinkTransitioned } = + await upsertPostApplicationMessage({ + provider: "gmail", + accountKey: args.accountKey, + integrationId: integration.id, + syncRunId: syncRun.id, + externalMessageId: metadata.id, + externalThreadId: metadata.threadId, + fromAddress, + fromDomain, + senderName, + subject, + receivedAt, + snippet: metadata.snippet, + classificationLabel: routerResult.stageTarget, + classificationConfidence: routerResult.confidence / 100, + classificationPayload: { + method: "smart_router", + reason: routerResult.reason, + stageTarget: routerResult.stageTarget, + }, + relevanceLlmScore: routerResult.confidence, + relevanceDecision: routerResult.isRelevant + ? "relevant" + : "not_relevant", + matchedJobId: isAutoLinked || isPendingMatch ? matchedJobId : null, + matchConfidence: routerResult.confidence, + stageTarget: routerResult.stageTarget, + messageType: routerResult.messageType, + stageEventPayload: routerResult.stageEventPayload, + processingStatus, + }); + + if (savedMessage.processingStatus !== "ignored") { + relevant += 1; + } + classified += 1; + if (savedMessage.matchedJobId) { + matched += 1; + } + + if (autoLinkTransitioned && savedMessage.matchedJobId) { + await createAutoStageEvent({ + jobId: savedMessage.matchedJobId, + stageTarget: savedMessage.stageTarget ?? "no_change", + receivedAt: savedMessage.receivedAt, + note: "Auto-created from Smart Router.", + }); + } + } catch (error) { + errored += 1; + logger.warn("Failed to ingest Gmail message", { + provider: "gmail", + accountKey: args.accountKey, + externalMessageId: message.id, + syncRunId: syncRun.id, + error: normalizeErrorMessage(error), + }); + } + }); + + await completePostApplicationSyncRun({ + id: syncRun.id, + status: "completed", + messagesDiscovered: discovered, + messagesRelevant: relevant, + messagesClassified: classified, + messagesMatched: matched, + messagesErrored: errored, + }); + await updatePostApplicationIntegrationSyncState({ + provider: "gmail", + accountKey: args.accountKey, + lastSyncedAt: Date.now(), + lastError: null, + status: "connected", + }); + + return { discovered, relevant, classified, errored }; + } catch (error) { + const errorMessage = normalizeErrorMessage(error); + await completePostApplicationSyncRun({ + id: syncRun.id, + status: "failed", + messagesDiscovered: discovered, + messagesRelevant: relevant, + messagesClassified: classified, + messagesMatched: matched, + messagesErrored: errored, + errorCode: "GMAIL_SYNC_FAILED", + errorMessage, + }); + await updatePostApplicationIntegrationSyncState({ + provider: "gmail", + accountKey: args.accountKey, + lastSyncedAt: Date.now(), + lastError: errorMessage, + status: "error", + }); + + throw error; + } +} diff --git a/orchestrator/src/server/services/post-application/providers/errors.ts b/orchestrator/src/server/services/post-application/providers/errors.ts new file mode 100644 index 0000000..84240e6 --- /dev/null +++ b/orchestrator/src/server/services/post-application/providers/errors.ts @@ -0,0 +1,87 @@ +import { + AppError, + badRequest, + serviceUnavailable, + upstreamError, +} from "@infra/errors"; + +export type PostApplicationProviderErrorKind = + | "invalid_request" + | "not_implemented" + | "service_unavailable" + | "upstream"; + +export class PostApplicationProviderError extends Error { + constructor( + readonly kind: PostApplicationProviderErrorKind, + message: string, + readonly details?: unknown, + ) { + super(message); + this.name = "PostApplicationProviderError"; + } +} + +export function providerInvalidRequest( + message: string, + details?: unknown, +): PostApplicationProviderError { + return new PostApplicationProviderError("invalid_request", message, details); +} + +export function providerNotImplemented( + message: string, + details?: unknown, +): PostApplicationProviderError { + return new PostApplicationProviderError("not_implemented", message, details); +} + +export function providerServiceUnavailable( + message: string, + details?: unknown, +): PostApplicationProviderError { + return new PostApplicationProviderError( + "service_unavailable", + message, + details, + ); +} + +export function providerUpstreamError( + message: string, + details?: unknown, +): PostApplicationProviderError { + return new PostApplicationProviderError("upstream", message, details); +} + +export function toProviderAppError(error: unknown): AppError { + if (error instanceof AppError) return error; + + if (error instanceof PostApplicationProviderError) { + if (error.kind === "invalid_request") { + return badRequest(error.message, error.details); + } + + if (error.kind === "upstream") { + return upstreamError(error.message, error.details); + } + + return serviceUnavailable(error.message); + } + + if (error instanceof Error) { + return new AppError({ + status: 500, + code: "INTERNAL_ERROR", + message: error.message || "Provider action failed", + cause: error, + }); + } + + return new AppError({ + status: 500, + code: "INTERNAL_ERROR", + message: "Provider action failed", + details: error, + }); +} diff --git a/orchestrator/src/server/services/post-application/providers/gmail.ts b/orchestrator/src/server/services/post-application/providers/gmail.ts new file mode 100644 index 0000000..f47cee4 --- /dev/null +++ b/orchestrator/src/server/services/post-application/providers/gmail.ts @@ -0,0 +1,295 @@ +import { logger } from "@infra/logger"; +import { + disconnectPostApplicationIntegration, + getPostApplicationIntegration, + upsertConnectedPostApplicationIntegration, +} from "@server/repositories/post-application-integrations"; +import { runGmailIngestionSync } from "@server/services/post-application/ingestion/gmail-sync"; +import type { PostApplicationIntegration } from "@shared/types"; +import { providerInvalidRequest, providerUpstreamError } from "./errors"; +import type { + PostApplicationProviderActionResult, + PostApplicationProviderAdapter, + PostApplicationProviderConnectArgs, + PostApplicationProviderDisconnectArgs, + PostApplicationProviderStatusArgs, + PostApplicationProviderSyncArgs, +} from "./types"; + +type GmailCredentialPayload = { + refreshToken: string; + accessToken?: string; + expiryDate?: number; + scope?: string; + tokenType?: string; + email?: string; + displayName?: string; +}; + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +function parseGmailCredentials( + args: PostApplicationProviderConnectArgs, +): GmailCredentialPayload { + const raw = args.payload?.payload; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw providerInvalidRequest( + "Gmail connect requires payload credentials in body.payload.", + ); + } + + const refreshToken = asString((raw as Record).refreshToken); + if (!refreshToken) { + throw providerInvalidRequest( + "Gmail connect requires a non-empty refreshToken in body.payload.refreshToken.", + ); + } + + return { + refreshToken, + accessToken: asString((raw as Record).accessToken), + expiryDate: asNumber((raw as Record).expiryDate), + scope: asString((raw as Record).scope), + tokenType: asString((raw as Record).tokenType), + email: asString((raw as Record).email), + displayName: asString((raw as Record).displayName), + }; +} + +function toPublicIntegration( + integration: PostApplicationIntegration | null, +): PostApplicationIntegration | null { + if (!integration) return null; + + const credentials = integration.credentials ?? {}; + return { + ...integration, + credentials: { + hasRefreshToken: + typeof credentials.refreshToken === "string" && + credentials.refreshToken.length > 0, + hasAccessToken: + typeof credentials.accessToken === "string" && + credentials.accessToken.length > 0, + scope: asString(credentials.scope) ?? null, + tokenType: asString(credentials.tokenType) ?? null, + expiryDate: asNumber(credentials.expiryDate) ?? null, + email: asString(credentials.email) ?? null, + }, + }; +} + +function buildStatus( + accountKey: string, + integration: PostApplicationIntegration | null, + message?: string, +): PostApplicationProviderActionResult { + const publicIntegration = toPublicIntegration(integration); + const hasRefreshToken = Boolean( + publicIntegration?.credentials?.hasRefreshToken, + ); + + return { + status: { + provider: "gmail", + accountKey, + connected: publicIntegration?.status === "connected" && hasRefreshToken, + integration: publicIntegration, + }, + message, + }; +} + +async function revokeGoogleToken(token: string): Promise { + const timeoutMs = 5_000; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const body = new URLSearchParams({ token }); + const response = await fetch("https://oauth2.googleapis.com/revoke", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + signal: controller.signal, + }); + if (!response.ok) { + throw providerUpstreamError( + `Google token revoke failed with HTTP ${response.status}.`, + ); + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw providerUpstreamError("Google token revoke request timed out."); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +export const gmailProvider: PostApplicationProviderAdapter = { + key: "gmail", + async connect( + args: PostApplicationProviderConnectArgs, + ): Promise { + const credentials = parseGmailCredentials(args); + const displayName = + credentials.displayName ?? + credentials.email ?? + `Gmail (${args.accountKey})`; + + const integration = await upsertConnectedPostApplicationIntegration({ + provider: "gmail", + accountKey: args.accountKey, + displayName, + credentials: { + refreshToken: credentials.refreshToken, + ...(credentials.accessToken + ? { accessToken: credentials.accessToken } + : {}), + ...(typeof credentials.expiryDate === "number" + ? { expiryDate: credentials.expiryDate } + : {}), + ...(credentials.scope ? { scope: credentials.scope } : {}), + ...(credentials.tokenType ? { tokenType: credentials.tokenType } : {}), + ...(credentials.email ? { email: credentials.email } : {}), + }, + }); + + logger.info("Gmail integration connected", { + provider: "gmail", + accountKey: args.accountKey, + initiatedBy: args.initiatedBy ?? null, + integrationId: integration.id, + }); + + return buildStatus( + args.accountKey, + integration, + "Gmail integration connected.", + ); + }, + + async status( + args: PostApplicationProviderStatusArgs, + ): Promise { + const integration = await getPostApplicationIntegration( + "gmail", + args.accountKey, + ); + if (!integration) { + return buildStatus( + args.accountKey, + null, + "Gmail provider is not connected.", + ); + } + + return buildStatus(args.accountKey, integration); + }, + + async sync( + args: PostApplicationProviderSyncArgs, + ): Promise { + const integration = await getPostApplicationIntegration( + "gmail", + args.accountKey, + ); + if (!integration) { + throw providerInvalidRequest( + `Gmail account '${args.accountKey}' is not connected.`, + ); + } + + const summary = await runGmailIngestionSync({ + accountKey: args.accountKey, + maxMessages: args.payload?.maxMessages, + searchDays: args.payload?.searchDays, + }); + + const refreshedIntegration = await getPostApplicationIntegration( + "gmail", + args.accountKey, + ); + logger.info("Gmail sync completed", { + provider: "gmail", + accountKey: args.accountKey, + initiatedBy: args.initiatedBy ?? null, + integrationId: integration.id, + discovered: summary.discovered, + relevant: summary.relevant, + classified: summary.classified, + errored: summary.errored, + }); + + return buildStatus( + args.accountKey, + refreshedIntegration, + `Sync complete: discovered=${summary.discovered}, relevant=${summary.relevant}, classified=${summary.classified}, errored=${summary.errored}.`, + ); + }, + + async disconnect( + args: PostApplicationProviderDisconnectArgs, + ): Promise { + const integration = await getPostApplicationIntegration( + "gmail", + args.accountKey, + ); + const refreshToken = + integration?.credentials && + typeof integration.credentials.refreshToken === "string" && + integration.credentials.refreshToken.length > 0 + ? integration.credentials.refreshToken + : null; + + let revokeWarning: string | null = null; + if (refreshToken) { + try { + await revokeGoogleToken(refreshToken); + } catch (error) { + revokeWarning = + error instanceof Error + ? error.message + : "Google token revoke failed before disconnect."; + logger.warn("Gmail token revoke failed during disconnect", { + provider: "gmail", + accountKey: args.accountKey, + initiatedBy: args.initiatedBy ?? null, + revokeWarning, + }); + } + } + + const disconnected = await disconnectPostApplicationIntegration( + "gmail", + args.accountKey, + ); + logger.info("Gmail integration disconnected", { + provider: "gmail", + accountKey: args.accountKey, + initiatedBy: args.initiatedBy ?? null, + integrationId: disconnected?.id ?? integration?.id ?? null, + tokenRevoked: Boolean(refreshToken && !revokeWarning), + }); + + return buildStatus( + args.accountKey, + disconnected, + revokeWarning + ? "Gmail disconnected locally. Token revoke should be retried." + : "Gmail integration disconnected.", + ); + }, +}; diff --git a/orchestrator/src/server/services/post-application/providers/imap.ts b/orchestrator/src/server/services/post-application/providers/imap.ts new file mode 100644 index 0000000..d03f08c --- /dev/null +++ b/orchestrator/src/server/services/post-application/providers/imap.ts @@ -0,0 +1,43 @@ +import { providerNotImplemented } from "./errors"; +import type { + PostApplicationProviderActionResult, + PostApplicationProviderAdapter, + PostApplicationProviderConnectArgs, + PostApplicationProviderDisconnectArgs, + PostApplicationProviderStatusArgs, + PostApplicationProviderSyncArgs, +} from "./types"; + +function notImplemented(accountKey: string): never { + throw providerNotImplemented( + `IMAP provider is not implemented yet for account '${accountKey}'.`, + ); +} + +export const imapProvider: PostApplicationProviderAdapter = { + key: "imap", + + async connect( + args: PostApplicationProviderConnectArgs, + ): Promise { + return notImplemented(args.accountKey); + }, + + async status( + args: PostApplicationProviderStatusArgs, + ): Promise { + return notImplemented(args.accountKey); + }, + + async sync( + args: PostApplicationProviderSyncArgs, + ): Promise { + return notImplemented(args.accountKey); + }, + + async disconnect( + args: PostApplicationProviderDisconnectArgs, + ): Promise { + return notImplemented(args.accountKey); + }, +}; diff --git a/orchestrator/src/server/services/post-application/providers/index.ts b/orchestrator/src/server/services/post-application/providers/index.ts new file mode 100644 index 0000000..8ce82f8 --- /dev/null +++ b/orchestrator/src/server/services/post-application/providers/index.ts @@ -0,0 +1,24 @@ +export { + PostApplicationProviderError, + providerInvalidRequest, + providerNotImplemented, + providerServiceUnavailable, + providerUpstreamError, + toProviderAppError, +} from "./errors"; +export { gmailProvider } from "./gmail"; +export { imapProvider } from "./imap"; +export { + listPostApplicationProviders, + resolvePostApplicationProvider, +} from "./registry"; +export { executePostApplicationProviderAction } from "./service"; +export type { + ExecutePostApplicationProviderActionInput, + PostApplicationProviderActionResult, + PostApplicationProviderAdapter, + PostApplicationProviderConnectArgs, + PostApplicationProviderDisconnectArgs, + PostApplicationProviderStatusArgs, + PostApplicationProviderSyncArgs, +} from "./types"; diff --git a/orchestrator/src/server/services/post-application/providers/registry.ts b/orchestrator/src/server/services/post-application/providers/registry.ts new file mode 100644 index 0000000..2012724 --- /dev/null +++ b/orchestrator/src/server/services/post-application/providers/registry.ts @@ -0,0 +1,37 @@ +import type { PostApplicationProvider } from "@shared/types"; +import { POST_APPLICATION_PROVIDERS } from "@shared/types"; +import { providerInvalidRequest } from "./errors"; +import { gmailProvider } from "./gmail"; +import { imapProvider } from "./imap"; +import type { PostApplicationProviderAdapter } from "./types"; + +const providerRegistry: Record< + PostApplicationProvider, + PostApplicationProviderAdapter +> = { + gmail: gmailProvider, + imap: imapProvider, +}; + +function isPostApplicationProvider( + value: string, +): value is PostApplicationProvider { + return (POST_APPLICATION_PROVIDERS as readonly string[]).includes(value); +} + +export function resolvePostApplicationProvider( + provider: string, +): PostApplicationProviderAdapter { + if (!isPostApplicationProvider(provider)) { + throw providerInvalidRequest(`Unsupported provider '${provider}'.`, { + provider, + supportedProviders: POST_APPLICATION_PROVIDERS, + }); + } + + return providerRegistry[provider]; +} + +export function listPostApplicationProviders(): PostApplicationProvider[] { + return [...POST_APPLICATION_PROVIDERS]; +} diff --git a/orchestrator/src/server/services/post-application/providers/service.test.ts b/orchestrator/src/server/services/post-application/providers/service.test.ts new file mode 100644 index 0000000..d6712c4 --- /dev/null +++ b/orchestrator/src/server/services/post-application/providers/service.test.ts @@ -0,0 +1,236 @@ +import type { PostApplicationIntegration } from "@shared/types"; +import type { Mock } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@server/repositories/post-application-integrations", () => ({ + getPostApplicationIntegration: vi.fn().mockResolvedValue(null), + upsertConnectedPostApplicationIntegration: vi.fn().mockImplementation( + async ({ + provider, + accountKey, + displayName, + credentials, + }: { + provider: "gmail"; + accountKey: string; + displayName: string; + credentials: Record; + }) => + ({ + id: "integration-test", + provider, + accountKey, + displayName, + status: "connected", + credentials, + lastConnectedAt: Date.now(), + lastSyncedAt: null, + lastError: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) satisfies PostApplicationIntegration, + ), + disconnectPostApplicationIntegration: vi.fn().mockImplementation( + async (provider: "gmail", accountKey: string) => + ({ + id: "integration-test", + provider, + accountKey, + displayName: "Gmail (default)", + status: "disconnected", + credentials: null, + lastConnectedAt: Date.now(), + lastSyncedAt: null, + lastError: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) satisfies PostApplicationIntegration, + ), +})); + +const integrationRepo = await import( + "@server/repositories/post-application-integrations" +); + +import { + PostApplicationProviderError, + providerUpstreamError, + toProviderAppError, +} from "./errors"; +import { + listPostApplicationProviders, + resolvePostApplicationProvider, +} from "./registry"; +import { executePostApplicationProviderAction } from "./service"; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("post-application provider registry", () => { + it("lists registered providers", () => { + expect(listPostApplicationProviders()).toEqual(["gmail", "imap"]); + }); + + it("resolves a known provider", () => { + const provider = resolvePostApplicationProvider("gmail"); + expect(provider.key).toBe("gmail"); + }); + + it("throws explicit invalid-request error for unknown provider", () => { + expect(() => resolvePostApplicationProvider("exchange")).toThrowError( + PostApplicationProviderError, + ); + + try { + resolvePostApplicationProvider("exchange"); + throw new Error("expected resolve to throw"); + } catch (error) { + expect(error).toBeInstanceOf(PostApplicationProviderError); + expect((error as PostApplicationProviderError).kind).toBe( + "invalid_request", + ); + } + }); +}); + +describe("post-application provider action dispatcher", () => { + it("connects gmail and persists credentials in the integrations store", async () => { + const response = await executePostApplicationProviderAction({ + provider: "gmail", + action: "connect", + accountKey: "account:gmail:test", + connectPayload: { + payload: { + refreshToken: "refresh-token", + accessToken: "access-token", + email: "candidate@example.com", + scope: "https://www.googleapis.com/auth/gmail.readonly", + }, + }, + }); + + expect( + integrationRepo.upsertConnectedPostApplicationIntegration, + ).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "gmail", + accountKey: "account:gmail:test", + }), + ); + expect(response.status.connected).toBe(true); + expect(response.message).toBe("Gmail integration connected."); + expect(response.status.integration?.credentials).toEqual( + expect.objectContaining({ + hasRefreshToken: true, + hasAccessToken: true, + email: "candidate@example.com", + }), + ); + }); + + it("dispatches status action to gmail provider", async () => { + const response = await executePostApplicationProviderAction({ + provider: "gmail", + action: "status", + accountKey: "account:gmail:test", + }); + + expect(response).toEqual({ + provider: "gmail", + action: "status", + accountKey: "account:gmail:test", + status: { + provider: "gmail", + accountKey: "account:gmail:test", + connected: false, + integration: null, + }, + message: "Gmail provider is not connected.", + }); + }); + + it("disconnects gmail and clears credentials from integration store", async () => { + const getIntegrationMock = + integrationRepo.getPostApplicationIntegration as Mock; + getIntegrationMock.mockResolvedValueOnce({ + id: "integration-test", + provider: "gmail", + accountKey: "account:gmail:test", + displayName: "Gmail (default)", + status: "connected", + credentials: { + refreshToken: "refresh-token", + }, + lastConnectedAt: Date.now(), + lastSyncedAt: null, + lastError: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } satisfies PostApplicationIntegration); + + const response = await executePostApplicationProviderAction({ + provider: "gmail", + action: "disconnect", + accountKey: "account:gmail:test", + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect( + integrationRepo.disconnectPostApplicationIntegration, + ).toHaveBeenCalledWith("gmail", "account:gmail:test"); + expect(response.status.connected).toBe(false); + expect(response.message).toBe("Gmail integration disconnected."); + expect(response.status.integration?.credentials).toEqual( + expect.objectContaining({ + hasRefreshToken: false, + hasAccessToken: false, + }), + ); + }); + + it("returns invalid request when gmail connect payload is missing refresh token", async () => { + await expect( + executePostApplicationProviderAction({ + provider: "gmail", + action: "connect", + accountKey: "account:gmail:test", + connectPayload: { payload: {} }, + }), + ).rejects.toMatchObject({ + status: 400, + code: "INVALID_REQUEST", + }); + }); + + it("maps IMAP not-implemented errors to service unavailable app errors", async () => { + await expect( + executePostApplicationProviderAction({ + provider: "imap", + action: "connect", + accountKey: "account:imap:test", + }), + ).rejects.toMatchObject({ + status: 503, + code: "SERVICE_UNAVAILABLE", + message: + "IMAP provider is not implemented yet for account 'account:imap:test'.", + }); + }); + + it("maps upstream provider errors to upstream app errors", () => { + const appError = toProviderAppError( + providerUpstreamError("Provider API timed out"), + ); + + expect(appError.status).toBe(502); + expect(appError.code).toBe("UPSTREAM_ERROR"); + expect(appError.message).toBe("Provider API timed out"); + }); +}); diff --git a/orchestrator/src/server/services/post-application/providers/service.ts b/orchestrator/src/server/services/post-application/providers/service.ts new file mode 100644 index 0000000..9c7796c --- /dev/null +++ b/orchestrator/src/server/services/post-application/providers/service.ts @@ -0,0 +1,52 @@ +import { logger } from "@infra/logger"; +import type { PostApplicationProviderActionResponse } from "@shared/types"; +import { toProviderAppError } from "./errors"; +import { resolvePostApplicationProvider } from "./registry"; +import type { ExecutePostApplicationProviderActionInput } from "./types"; + +export async function executePostApplicationProviderAction( + input: ExecutePostApplicationProviderActionInput, +): Promise { + const provider = resolvePostApplicationProvider(input.provider); + + try { + const result = + input.action === "connect" + ? await provider.connect({ + accountKey: input.accountKey, + initiatedBy: input.initiatedBy, + payload: input.connectPayload, + }) + : input.action === "status" + ? await provider.status({ + accountKey: input.accountKey, + }) + : input.action === "sync" + ? await provider.sync({ + accountKey: input.accountKey, + initiatedBy: input.initiatedBy, + payload: input.syncPayload, + }) + : await provider.disconnect({ + accountKey: input.accountKey, + initiatedBy: input.initiatedBy, + }); + + return { + provider: provider.key, + action: input.action, + accountKey: input.accountKey, + status: result.status, + ...(result.message ? { message: result.message } : {}), + }; + } catch (error) { + logger.warn("Post-application provider action failed", { + provider: provider.key, + action: input.action, + accountKey: input.accountKey, + initiatedBy: input.initiatedBy ?? null, + error, + }); + throw toProviderAppError(error); + } +} diff --git a/orchestrator/src/server/services/post-application/providers/types.ts b/orchestrator/src/server/services/post-application/providers/types.ts new file mode 100644 index 0000000..16ecf7d --- /dev/null +++ b/orchestrator/src/server/services/post-application/providers/types.ts @@ -0,0 +1,62 @@ +import type { + PostApplicationProvider, + PostApplicationProviderActionConnectRequest, + PostApplicationProviderActionResponse, + PostApplicationProviderActionSyncRequest, + PostApplicationProviderStatus, +} from "@shared/types"; + +export type PostApplicationProviderConnectArgs = { + accountKey: string; + initiatedBy?: string | null; + payload?: PostApplicationProviderActionConnectRequest; +}; + +export type PostApplicationProviderStatusArgs = { + accountKey: string; +}; + +export type PostApplicationProviderSyncArgs = { + accountKey: string; + initiatedBy?: string | null; + payload?: PostApplicationProviderActionSyncRequest; +}; + +export type PostApplicationProviderDisconnectArgs = { + accountKey: string; + initiatedBy?: string | null; +}; + +export type PostApplicationProviderActionResult = { + status: PostApplicationProviderStatus; + message?: string; +}; + +export interface PostApplicationProviderAdapter { + readonly key: PostApplicationProvider; + + connect( + args: PostApplicationProviderConnectArgs, + ): Promise; + + status( + args: PostApplicationProviderStatusArgs, + ): Promise; + + sync( + args: PostApplicationProviderSyncArgs, + ): Promise; + + disconnect( + args: PostApplicationProviderDisconnectArgs, + ): Promise; +} + +export type ExecutePostApplicationProviderActionInput = { + provider: string; + action: PostApplicationProviderActionResponse["action"]; + accountKey: string; + initiatedBy?: string | null; + connectPayload?: PostApplicationProviderActionConnectRequest; + syncPayload?: PostApplicationProviderActionSyncRequest; +}; diff --git a/orchestrator/src/server/services/post-application/review/index.ts b/orchestrator/src/server/services/post-application/review/index.ts new file mode 100644 index 0000000..6b2fc8b --- /dev/null +++ b/orchestrator/src/server/services/post-application/review/index.ts @@ -0,0 +1,8 @@ +export { + approvePostApplicationInboxItem, + bulkPostApplicationInboxAction, + denyPostApplicationInboxItem, + listPostApplicationInbox, + listPostApplicationReviewRuns, + listPostApplicationRunMessages, +} from "./service"; diff --git a/orchestrator/src/server/services/post-application/review/service.ts b/orchestrator/src/server/services/post-application/review/service.ts new file mode 100644 index 0000000..c980e17 --- /dev/null +++ b/orchestrator/src/server/services/post-application/review/service.ts @@ -0,0 +1,419 @@ +import { + AppError, + conflict, + notFound, + unprocessableEntity, +} from "@infra/errors"; +import { db, schema } from "@server/db"; +import { getJobById, listJobSummariesByIds } from "@server/repositories/jobs"; +import { + getPostApplicationMessageById, + listPostApplicationMessagesByProcessingStatus, + listPostApplicationMessagesBySyncRun, +} from "@server/repositories/post-application-messages"; +import { + getPostApplicationSyncRunById, + listPostApplicationSyncRuns, +} from "@server/repositories/post-application-sync-runs"; +import { transitionStage } from "@server/services/applicationTracking"; +import { + resolveStageTransitionForTarget, + stageTargetFromMessageType, +} from "@server/services/post-application/stage-target"; +import type { + ApplicationStage, + BulkPostApplicationActionRequest, + BulkPostApplicationActionResponse, + BulkPostApplicationActionResult, + PostApplicationInboxItem, + PostApplicationMessage, + PostApplicationProvider, + PostApplicationRouterStageTarget, + PostApplicationSyncRun, +} from "@shared/types"; +import { and, eq, sql } from "drizzle-orm"; + +const { postApplicationMessages, postApplicationSyncRuns } = schema; + +function buildMatchedJobMap( + items: PostApplicationMessage[], + jobs: Awaited>, +): PostApplicationInboxItem[] { + const jobById = new Map(jobs.map((job) => [job.id, job])); + return items.map((message) => ({ + message, + matchedJob: message.matchedJobId + ? (jobById.get(message.matchedJobId) ?? null) + : null, + })); +} + +export async function listPostApplicationInbox(args: { + provider: PostApplicationProvider; + accountKey: string; + limit?: number; +}): Promise { + const limit = args.limit ?? 50; + const messages = await listPostApplicationMessagesByProcessingStatus( + args.provider, + args.accountKey, + "pending_user", + limit, + ); + + const jobIds = Array.from( + new Set(messages.map((message) => message.matchedJobId).filter(Boolean)), + ) as string[]; + const jobs = await listJobSummariesByIds(jobIds); + return buildMatchedJobMap(messages, jobs); +} + +export async function approvePostApplicationInboxItem(args: { + messageId: string; + provider: PostApplicationProvider; + accountKey: string; + jobId?: string; + stageTarget?: PostApplicationRouterStageTarget; + toStage?: ApplicationStage; + note?: string; + decidedBy?: string | null; +}): Promise<{ message: PostApplicationMessage; stageEventId: string | null }> { + const message = await getPostApplicationMessageById(args.messageId); + if (!message) { + throw notFound(`Post-application message '${args.messageId}' not found.`); + } + if ( + message.provider !== args.provider || + message.accountKey !== args.accountKey + ) { + throw notFound(`Post-application message '${args.messageId}' not found.`); + } + if (message.processingStatus !== "pending_user") { + throw conflict( + `Message '${args.messageId}' is already decided with status '${message.processingStatus}'.`, + ); + } + + const resolvedJobId = args.jobId ?? message.matchedJobId; + if (!resolvedJobId) { + throw unprocessableEntity( + "Approval requires a resolved jobId from payload or message suggestion.", + ); + } + + const targetJob = await getJobById(resolvedJobId); + if (!targetJob) { + throw notFound(`Job '${resolvedJobId}' not found.`); + } + + const decidedAt = Date.now(); + const updated = db.transaction((tx) => { + let stageEventId: string | null = null; + const decidedAtIso = new Date(decidedAt).toISOString(); + + const messageUpdateResult = tx + .update(postApplicationMessages) + .set({ + processingStatus: "manual_linked", + matchedJobId: resolvedJobId, + decidedAt, + decidedBy: args.decidedBy ?? null, + updatedAt: decidedAtIso, + }) + .where( + and( + eq(postApplicationMessages.id, message.id), + eq(postApplicationMessages.processingStatus, "pending_user"), + ), + ) + .run(); + if (messageUpdateResult.changes === 0) { + throw conflict( + `Message '${message.id}' was already decided by another request.`, + ); + } + + const resolvedTarget = + args.stageTarget ?? + (args.toStage as PostApplicationRouterStageTarget | undefined) ?? + message.stageTarget ?? + stageTargetFromMessageType(message.messageType); + const transition = resolveStageTransitionForTarget(resolvedTarget); + + if (transition.toStage !== "no_change") { + const event = transitionStage( + resolvedJobId, + transition.toStage, + Math.floor( + Number.isFinite(message.receivedAt) + ? message.receivedAt / 1000 + : decidedAt / 1000, + ), + { + actor: "system", + eventType: "status_update", + eventLabel: `Post-application: ${resolvedTarget}`, + note: args.note ?? null, + reasonCode: transition.reasonCode ?? "post_application_manual_linked", + }, + transition.outcome, + ); + stageEventId = event.id; + } + + if (message.syncRunId) { + tx.update(postApplicationSyncRuns) + .set({ + messagesApproved: sql`${postApplicationSyncRuns.messagesApproved} + 1`, + updatedAt: decidedAtIso, + }) + .where(eq(postApplicationSyncRuns.id, message.syncRunId)) + .run(); + } + + return { stageEventId }; + }); + + const updatedMessage = await getPostApplicationMessageById(message.id); + + if (!updatedMessage) { + throw notFound( + `Post-application message '${message.id}' not found after approval.`, + ); + } + + return { message: updatedMessage, stageEventId: updated.stageEventId }; +} + +export async function denyPostApplicationInboxItem(args: { + messageId: string; + provider: PostApplicationProvider; + accountKey: string; + decidedBy?: string | null; +}): Promise<{ message: PostApplicationMessage }> { + const message = await getPostApplicationMessageById(args.messageId); + if (!message) { + throw notFound(`Post-application message '${args.messageId}' not found.`); + } + if ( + message.provider !== args.provider || + message.accountKey !== args.accountKey + ) { + throw notFound(`Post-application message '${args.messageId}' not found.`); + } + if (message.processingStatus !== "pending_user") { + throw conflict( + `Message '${args.messageId}' is already decided with status '${message.processingStatus}'.`, + ); + } + + const decidedAt = Date.now(); + db.transaction((tx) => { + const decidedAtIso = new Date(decidedAt).toISOString(); + const messageUpdateResult = tx + .update(postApplicationMessages) + .set({ + processingStatus: "ignored", + matchedJobId: null, + decidedAt, + decidedBy: args.decidedBy ?? null, + updatedAt: decidedAtIso, + }) + .where( + and( + eq(postApplicationMessages.id, message.id), + eq(postApplicationMessages.processingStatus, "pending_user"), + ), + ) + .run(); + if (messageUpdateResult.changes === 0) { + throw conflict( + `Message '${message.id}' was already decided by another request.`, + ); + } + + if (message.syncRunId) { + tx.update(postApplicationSyncRuns) + .set({ + messagesDenied: sql`${postApplicationSyncRuns.messagesDenied} + 1`, + updatedAt: decidedAtIso, + }) + .where(eq(postApplicationSyncRuns.id, message.syncRunId)) + .run(); + } + }); + + const updatedMessage = await getPostApplicationMessageById(message.id); + if (!updatedMessage) { + throw notFound( + `Post-application message '${message.id}' not found after denial.`, + ); + } + + return { message: updatedMessage }; +} + +export async function bulkPostApplicationInboxAction( + args: BulkPostApplicationActionRequest & { decidedBy?: string | null }, +): Promise { + const { provider, accountKey, action, decidedBy } = args; + + const pendingItems = await listPostApplicationInbox({ + provider, + accountKey, + limit: 1000, + }); + + const results: BulkPostApplicationActionResult[] = []; + let skipped = 0; + let failed = 0; + + for (const item of pendingItems) { + const { message, matchedJob } = item; + + if (action === "approve") { + if (!matchedJob) { + skipped++; + results.push({ + messageId: message.id, + ok: false, + error: { + code: "NO_SUGGESTED_MATCH", + message: "Message has no suggested job match", + }, + }); + continue; + } + + try { + const result = await approvePostApplicationInboxItem({ + messageId: message.id, + provider, + accountKey, + jobId: matchedJob.id, + decidedBy, + }); + results.push({ + messageId: message.id, + ok: true, + message: result.message, + stageEventId: result.stageEventId, + }); + } catch (error) { + if (error instanceof AppError && error.code === "CONFLICT") { + skipped++; + results.push({ + messageId: message.id, + ok: false, + error: { + code: "ALREADY_DECIDED", + message: error.message, + }, + }); + continue; + } + failed++; + results.push({ + messageId: message.id, + ok: false, + error: { + code: "APPROVE_FAILED", + message: error instanceof Error ? error.message : "Unknown error", + }, + }); + } + } else { + try { + const result = await denyPostApplicationInboxItem({ + messageId: message.id, + provider, + accountKey, + decidedBy, + }); + results.push({ + messageId: message.id, + ok: true, + message: result.message, + }); + } catch (error) { + if (error instanceof AppError && error.code === "CONFLICT") { + skipped++; + results.push({ + messageId: message.id, + ok: false, + error: { + code: "ALREADY_DECIDED", + message: error.message, + }, + }); + continue; + } + failed++; + results.push({ + messageId: message.id, + ok: false, + error: { + code: "DENY_FAILED", + message: error instanceof Error ? error.message : "Unknown error", + }, + }); + } + } + } + + const succeeded = results.filter((r) => r.ok).length; + + return { + action, + requested: pendingItems.length, + succeeded, + failed, + skipped, + results, + }; +} + +export async function listPostApplicationReviewRuns(args: { + provider: PostApplicationProvider; + accountKey: string; + limit?: number; +}): Promise { + return listPostApplicationSyncRuns( + args.provider, + args.accountKey, + args.limit ?? 20, + ); +} + +export async function listPostApplicationRunMessages(args: { + provider: PostApplicationProvider; + accountKey: string; + runId: string; + limit?: number; +}): Promise<{ + run: PostApplicationSyncRun; + items: PostApplicationInboxItem[]; +}> { + const run = await getPostApplicationSyncRunById(args.runId); + if ( + !run || + run.provider !== args.provider || + run.accountKey !== args.accountKey + ) { + throw notFound(`Post-application sync run '${args.runId}' not found.`); + } + + const messages = await listPostApplicationMessagesBySyncRun( + args.provider, + args.accountKey, + args.runId, + args.limit ?? 300, + ); + + const jobIds = Array.from( + new Set(messages.map((message) => message.matchedJobId).filter(Boolean)), + ) as string[]; + const jobs = await listJobSummariesByIds(jobIds); + + return { run, items: buildMatchedJobMap(messages, jobs) }; +} diff --git a/orchestrator/src/server/services/post-application/stage-target.ts b/orchestrator/src/server/services/post-application/stage-target.ts new file mode 100644 index 0000000..e8501d9 --- /dev/null +++ b/orchestrator/src/server/services/post-application/stage-target.ts @@ -0,0 +1,81 @@ +import type { + ApplicationStage, + JobOutcome, + PostApplicationMessageType, + PostApplicationRouterStageTarget, +} from "@shared/types"; +import { POST_APPLICATION_ROUTER_STAGE_TARGETS } from "@shared/types"; + +const STAGE_TARGET_VALUES = new Set( + POST_APPLICATION_ROUTER_STAGE_TARGETS, +); + +export function normalizeStageTarget( + value: unknown, +): PostApplicationRouterStageTarget | null { + if (typeof value !== "string") return null; + return STAGE_TARGET_VALUES.has(value as PostApplicationRouterStageTarget) + ? (value as PostApplicationRouterStageTarget) + : null; +} + +export function messageTypeFromStageTarget( + target: PostApplicationRouterStageTarget, +): PostApplicationMessageType { + if ( + target === "assessment" || + target === "hiring_manager_screen" || + target === "technical_interview" || + target === "onsite" + ) { + return "interview"; + } + if (target === "offer") return "offer"; + if (target === "rejected" || target === "withdrawn" || target === "closed") { + return "rejection"; + } + if (target === "applied" || target === "recruiter_screen") return "update"; + return "other"; +} + +export function stageTargetFromMessageType( + messageType: PostApplicationMessageType, +): PostApplicationRouterStageTarget { + if (messageType === "interview") return "technical_interview"; + if (messageType === "offer") return "offer"; + if (messageType === "rejection") return "rejected"; + if (messageType === "update") return "recruiter_screen"; + return "no_change"; +} + +export function resolveStageTransitionForTarget( + target: PostApplicationRouterStageTarget, +): { + toStage: ApplicationStage | "no_change"; + outcome: JobOutcome | null; + reasonCode: string | null; +} { + if (target === "rejected") { + return { + toStage: "closed", + outcome: "rejected", + reasonCode: "rejected", + }; + } + if (target === "withdrawn") { + return { + toStage: "closed", + outcome: "withdrawn", + reasonCode: "withdrawn", + }; + } + if (target === "no_change") { + return { toStage: "no_change", outcome: null, reasonCode: null }; + } + + return { + toStage: target, + outcome: null, + reasonCode: null, + }; +} diff --git a/orchestrator/src/setupTests.ts b/orchestrator/src/setupTests.ts index ae61ad7..8469f7a 100644 --- a/orchestrator/src/setupTests.ts +++ b/orchestrator/src/setupTests.ts @@ -13,3 +13,46 @@ if (typeof globalThis.ResizeObserver === "undefined") { globalThis.ResizeObserver = ResizeObserver; } + +const hasStorageShape = (value: unknown): value is Storage => { + if (!value || typeof value !== "object") return false; + const storage = value as Partial; + return ( + typeof storage.getItem === "function" && + typeof storage.setItem === "function" && + typeof storage.removeItem === "function" && + typeof storage.clear === "function" && + typeof storage.key === "function" + ); +}; + +if (!hasStorageShape(globalThis.localStorage)) { + const store = new Map(); + const storage: Storage = { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key: string) { + const value = store.get(key); + return value ?? null; + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key: string) { + store.delete(key); + }, + setItem(key: string, value: string) { + store.set(key, value); + }, + }; + + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + writable: true, + value: storage, + }); +} diff --git a/package-lock.json b/package-lock.json index c0a57d2..5171a18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2702,6 +2702,19 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -2787,6 +2800,13 @@ "integrity": "sha512-Hq9IMnfekuOCsEmYl4QX2HBrT+XsfXiupfrLLY8Dcf3Puf4BkBOxSbWYTITSOQAhJoYPBez+b4MJRpIYL65z8A==", "license": "MIT" }, + "node_modules/@types/html-to-text": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@types/html-to-text/-/html-to-text-9.0.4.tgz", + "integrity": "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -3587,6 +3607,15 @@ "node": ">=4.0.0" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -4375,6 +4404,41 @@ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", "license": "MIT" }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/html-to-text/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/htmlparser2": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", @@ -5043,6 +5107,15 @@ "node": ">=22" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/lightningcss-android-arm64": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", @@ -5849,6 +5922,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5895,6 +5981,15 @@ "through": "~2.3" } }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6410,6 +6505,18 @@ "loose-envify": "^1.1.0" } }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -7375,6 +7482,7 @@ "drizzle-orm": "^0.38.2", "express": "^4.18.2", "get-tsconfig": "^4.10.0", + "html-to-text": "^9.0.5", "jsdom": "^25.0.1", "lucide-react": "^0.561.0", "next-themes": "^0.4.6", @@ -7399,6 +7507,7 @@ "@types/better-sqlite3": "^7.6.8", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/html-to-text": "^9.0.4", "@types/jsdom": "^27.0.0", "@types/node": "^22.10.1", "@types/react": "18.3.12", diff --git a/shared/src/types.ts b/shared/src/types.ts index 5944cd5..e6f70e5 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -364,6 +364,215 @@ export type ApiResponse = meta: ApiMeta; }; +export const POST_APPLICATION_PROVIDERS = ["gmail", "imap"] as const; +export type PostApplicationProvider = + (typeof POST_APPLICATION_PROVIDERS)[number]; + +export const POST_APPLICATION_PROVIDER_ACTIONS = [ + "connect", + "status", + "sync", + "disconnect", +] as const; +export type PostApplicationProviderAction = + (typeof POST_APPLICATION_PROVIDER_ACTIONS)[number]; + +export const POST_APPLICATION_INTEGRATION_STATUSES = [ + "disconnected", + "connected", + "error", +] as const; +export type PostApplicationIntegrationStatus = + (typeof POST_APPLICATION_INTEGRATION_STATUSES)[number]; + +export const POST_APPLICATION_SYNC_RUN_STATUSES = [ + "running", + "completed", + "failed", + "cancelled", +] as const; +export type PostApplicationSyncRunStatus = + (typeof POST_APPLICATION_SYNC_RUN_STATUSES)[number]; + +export const POST_APPLICATION_RELEVANCE_DECISIONS = [ + "relevant", + "not_relevant", + "needs_llm", +] as const; +export type PostApplicationRelevanceDecision = + (typeof POST_APPLICATION_RELEVANCE_DECISIONS)[number]; + +export const POST_APPLICATION_MESSAGE_TYPES = [ + "interview", + "rejection", + "offer", + "update", + "other", +] as const; +export type PostApplicationMessageType = + (typeof POST_APPLICATION_MESSAGE_TYPES)[number]; + +export const POST_APPLICATION_ROUTER_STAGE_TARGETS = [ + "no_change", + "applied", + "recruiter_screen", + "assessment", + "hiring_manager_screen", + "technical_interview", + "onsite", + "offer", + "rejected", + "withdrawn", + "closed", +] as const; +export type PostApplicationRouterStageTarget = + (typeof POST_APPLICATION_ROUTER_STAGE_TARGETS)[number]; + +export const POST_APPLICATION_PROCESSING_STATUSES = [ + "auto_linked", + "pending_user", + "manual_linked", + "ignored", +] as const; +export type PostApplicationProcessingStatus = + (typeof POST_APPLICATION_PROCESSING_STATUSES)[number]; + +export interface PostApplicationIntegration { + id: string; + provider: PostApplicationProvider; + accountKey: string; + displayName: string | null; + status: PostApplicationIntegrationStatus; + credentials: Record | null; + lastConnectedAt: number | null; + lastSyncedAt: number | null; + lastError: string | null; + createdAt: string; + updatedAt: string; +} + +export interface PostApplicationSyncRun { + id: string; + provider: PostApplicationProvider; + accountKey: string; + integrationId: string | null; + status: PostApplicationSyncRunStatus; + startedAt: number; + completedAt: number | null; + messagesDiscovered: number; + messagesRelevant: number; + messagesClassified: number; + messagesMatched: number; + messagesApproved: number; + messagesDenied: number; + messagesErrored: number; + errorCode: string | null; + errorMessage: string | null; + createdAt: string; + updatedAt: string; +} + +export interface PostApplicationMessage { + id: string; + provider: PostApplicationProvider; + accountKey: string; + integrationId: string | null; + syncRunId: string | null; + externalMessageId: string; + externalThreadId: string | null; + fromAddress: string; + fromDomain: string | null; + senderName: string | null; + subject: string; + receivedAt: number; + snippet: string; + classificationLabel: string | null; + classificationConfidence: number | null; + classificationPayload: Record | null; + relevanceLlmScore: number | null; + relevanceDecision: PostApplicationRelevanceDecision; + matchedJobId: string | null; + matchConfidence: number | null; + stageTarget: PostApplicationRouterStageTarget | null; + messageType: PostApplicationMessageType; + stageEventPayload: Record | null; + processingStatus: PostApplicationProcessingStatus; + decidedAt: number | null; + decidedBy: string | null; + errorCode: string | null; + errorMessage: string | null; + createdAt: string; + updatedAt: string; +} + +export interface PostApplicationProviderActionConnectRequest { + accountKey?: string; + payload?: Record; +} + +export interface PostApplicationProviderActionSyncRequest { + accountKey?: string; + maxMessages?: number; + searchDays?: number; +} + +export interface PostApplicationProviderStatus { + provider: PostApplicationProvider; + accountKey: string; + connected: boolean; + integration: PostApplicationIntegration | null; +} + +export interface PostApplicationProviderActionResponse { + provider: PostApplicationProvider; + action: PostApplicationProviderAction; + accountKey: string; + status: PostApplicationProviderStatus; + message?: string; +} + +export interface PostApplicationInboxItem { + message: PostApplicationMessage; + matchedJob?: { + id: string; + title: string; + employer: string; + } | null; +} + +export type BulkPostApplicationAction = "approve" | "deny"; + +export interface BulkPostApplicationActionRequest { + action: BulkPostApplicationAction; + provider: PostApplicationProvider; + accountKey: string; +} + +export type BulkPostApplicationActionResult = + | { + messageId: string; + ok: true; + message: PostApplicationMessage; + stageEventId?: string | null; + } + | { + messageId: string; + ok: false; + error: { + code: string; + message: string; + }; + }; + +export interface BulkPostApplicationActionResponse { + action: BulkPostApplicationAction; + requested: number; + succeeded: number; + failed: number; + skipped: number; + results: BulkPostApplicationActionResult[]; +} + export interface JobsListResponse { jobs: TJob[]; total: number;