feat(post-application): automatically pull from email (#145)

* feat(post-application): add schema and shared types for provider ingestion (#136)

* test(orchestrator): ensure full localStorage shape in vitest setup

* feat(post-application): add provider registry and dispatcher framework (#137) (#146)

* Implement Gmail provider credential persistence (#147)

* Add unified post-application provider action API (#148)

* Implement Gmail ingestion sync with 95/60 relevance policy

* Implement Gmail ingestion sync with 95/60 relevance policy (#149)

* feat(post-application): add job mapping engine with llm rerank fallback

* feat(post-application): add inbox review APIs with transactional approve/deny (#151)

* feat(post-application): add tracking inbox UI with provider controls (#152)

* oauth implementation

* UI changes

* see past runs in more detail

* occurred at comes from email

* state mismatch

* better UI representation

* comments

* comments

* comments

* comments

* documentation

* explainer

* set things manually

* scrolling

* any found email can be pending

* searchable download

* Email-to-Job Matching Decision Tree

* email viewer list improvement

* simplification initial commit

* exclude discovered jobs

* show only resady

* dropdown

* mermaid

* syntax

* targets is the same as logging that is done manually

* event label

* duplicate avoidance

* clean up html

* token saving

* print

* send idx not uuid

* remove logging

* formatting

* better documentation

* documentation

* comments

* process all

* comments
This commit is contained in:
Shaheer Sarfaraz 2026-02-12 19:48:25 +00:00 committed by GitHub
parent 05f1c62de2
commit 687fd5e91f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 7652 additions and 89 deletions

View File

@ -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 <request-origin>/oauth/gmail/callback
# GMAIL_OAUTH_REDIRECT_URI=http://localhost:3005/oauth/gmail/callback
# =============================================================================
# UKVisaJobs (UK visa sponsorship jobs) - optional
# =============================================================================

View File

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

85
documentation/README.md Normal file
View File

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

View File

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

View File

@ -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<br/>with suggested job match]
C -->|<50%| G{Is it relevant?}
G -->|Yes| H[Goes to Inbox as orphan<br/>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<br/>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

View File

@ -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<br/>with suggested job match]
C -->|<50%| G{Is it relevant?}
G -->|Yes| H[Goes to Inbox as orphan<br/>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<br/>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.

View File

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

View File

@ -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",

View File

@ -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 */}
<Route path="/overview" element={<HomePage />} />
<Route
path="/oauth/gmail/callback"
element={<GmailOauthCallbackPage />}
/>
<Route path="/job/:id" element={<JobPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
<Route path="/tracking-inbox" element={<TrackingInboxPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route
path="/jobs/:tab/:jobId"

View File

@ -11,6 +11,8 @@ import type {
BackupInfo,
BulkJobActionRequest,
BulkJobActionResponse,
BulkPostApplicationAction,
BulkPostApplicationActionResponse,
DemoInfoResponse,
Job,
JobListItem,
@ -22,6 +24,11 @@ import type {
ManualJobFetchResponse,
ManualJobInferenceResponse,
PipelineStatusResponse,
PostApplicationInboxItem,
PostApplicationProvider,
PostApplicationProviderActionResponse,
PostApplicationRouterStageTarget,
PostApplicationSyncRun,
ProfileStatusResponse,
ResumeProfile,
ResumeProjectCatalogItem,
@ -537,6 +544,247 @@ export async function cancelPipeline(): Promise<{
});
}
// Post-Application Tracking API
export async function postApplicationProviderConnect(input: {
provider?: PostApplicationProvider;
accountKey?: string;
payload?: Record<string, unknown>;
}): Promise<PostApplicationProviderActionResponse> {
const provider = input.provider ?? "gmail";
return fetchApi<PostApplicationProviderActionResponse>(
`/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<PostApplicationProviderActionResponse> {
return fetchApi<PostApplicationProviderActionResponse>(
"/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<PostApplicationProviderActionResponse> {
const provider = input?.provider ?? "gmail";
return fetchApi<PostApplicationProviderActionResponse>(
`/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<PostApplicationProviderActionResponse> {
const provider = input?.provider ?? "gmail";
return fetchApi<PostApplicationProviderActionResponse>(
`/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<PostApplicationProviderActionResponse> {
const provider = input?.provider ?? "gmail";
return fetchApi<PostApplicationProviderActionResponse>(
`/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<BulkPostApplicationActionResponse> {
return fetchApi<BulkPostApplicationActionResponse>(
"/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<DemoInfoResponse> {
return fetchApi<DemoInfoResponse>("/demo/info");
}

View File

@ -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 },
];

View File

@ -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 (
<main className="flex min-h-screen items-center justify-center px-4">
<div className="text-center">
<h1 className="text-lg font-semibold">Completing Gmail connection</h1>
<p className="mt-2 text-sm text-muted-foreground">
You can close this window if it does not close automatically.
</p>
</div>
</main>
);
};

View File

@ -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<ReturnType<typeof api.getJobs>>);
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(
<MemoryRouter>
<TrackingInboxPage />
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText("Interview invite")).toBeInTheDocument();
});
});
it("submits approve action", async () => {
render(
<MemoryRouter>
<TrackingInboxPage />
</MemoryRouter>,
);
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(
<MemoryRouter>
<TrackingInboxPage />
</MemoryRouter>,
);
await waitFor(() => {
expect(api.getJobs).toHaveBeenCalledWith({
statuses: ["applied"],
view: "list",
});
});
});
});

View File

@ -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<PostApplicationProvider>("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<ReturnType<typeof api.postApplicationProviderStatus>>["status"]
| null
>(null);
const [inbox, setInbox] = useState<PostApplicationInboxItem[]>([]);
const [runs, setRuns] = useState<PostApplicationSyncRun[]>([]);
const [isRunModalOpen, setIsRunModalOpen] = useState(false);
const [isRunMessagesLoading, setIsRunMessagesLoading] = useState(false);
const [selectedRun, setSelectedRun] = useState<PostApplicationSyncRun | null>(
null,
);
const [selectedRunItems, setSelectedRunItems] = useState<
PostApplicationInboxItem[]
>([]);
const [appliedJobByMessageId, setAppliedJobByMessageId] = useState<
Record<string, string>
>({});
const [appliedJobs, setAppliedJobs] = useState<JobListItem[]>([]);
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<unknown>) => {
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 (
<>
<PageHeader
icon={Inbox}
title="Tracking Inbox"
subtitle="Post-application message review"
actions={
<Button
size="sm"
variant="outline"
onClick={() => void refresh()}
disabled={isRefreshing || isLoading}
className="gap-2"
>
{isRefreshing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
Refresh
</Button>
}
/>
<PageMain className="space-y-4">
<section className="space-y-1 px-1">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">
Application Inbox Matching
</h1>
</div>
<p className="text-sm text-muted-foreground">
Connect your inbox to ingest related emails, review the suggested
job matches, and approve or deny to automatically update your
tracking timeline.
</p>
</section>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Provider Controls</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="provider">Provider</Label>
<Select
value={provider}
onValueChange={(value) =>
setProvider(value as PostApplicationProvider)
}
>
<SelectTrigger id="provider">
<SelectValue placeholder="Provider" />
</SelectTrigger>
<SelectContent>
{PROVIDER_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="accountKey">Account Key</Label>
<Input
id="accountKey"
value={accountKey}
onChange={(event) => setAccountKey(event.target.value)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Gmail connect uses Google OAuth popup and stores credentials
server-side. No manual refresh token paste is needed.
</p>
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="maxMessages">Max Messages</Label>
<Input
id="maxMessages"
inputMode="numeric"
value={maxMessages}
onChange={(event) => setMaxMessages(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="searchDays">Search Days</Label>
<Input
id="searchDays"
inputMode="numeric"
value={searchDays}
onChange={(event) => setSearchDays(event.target.value)}
/>
</div>
<div className="md:col-span-2 flex flex-wrap items-end gap-2">
{!isConnected ? (
<Button
onClick={() => void runProviderAction("connect")}
disabled={isActionLoading}
className="gap-2"
>
<Link2 className="h-4 w-4" />
Connect
</Button>
) : null}
<Button
onClick={() => void runProviderAction("sync")}
disabled={isActionLoading || !isConnected}
variant="secondary"
className="gap-2"
>
{activeAction === "sync" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{activeAction === "sync" ? "Syncing..." : "Sync"}
</Button>
{isConnected ? (
<Button
onClick={() => void runProviderAction("disconnect")}
disabled={isActionLoading}
variant="outline"
className="gap-2"
>
<Unplug className="h-4 w-4" />
Disconnect
</Button>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm">
<Badge variant={status?.connected ? "default" : "outline"}>
{connectionLabel}
</Badge>
<span className="text-muted-foreground">
Pending review:{" "}
<span className="font-semibold">{pendingCount}</span>
</span>
{status?.integration?.lastSyncedAt ? (
<span className="text-muted-foreground">
Last synced: {formatEpochMs(status.integration.lastSyncedAt)}
</span>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-base">Pending Review Queue</CardTitle>
{inbox.length > 0 && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={isActionLoading}
onClick={() => openBulkActionDialog("approve")}
>
<CheckCircle className="h-4 w-4" />
Approve All
</Button>
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={isActionLoading}
onClick={() => openBulkActionDialog("deny")}
>
<XCircle className="h-4 w-4" />
Ignore All
</Button>
</div>
)}
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading inbox...
</div>
) : inbox.length === 0 ? (
<EmptyState
title="No pending messages"
description="Run sync to ingest new job-application emails."
/>
) : (
<EmailViewerList
items={inbox}
appliedJobs={appliedJobs}
appliedJobByMessageId={appliedJobByMessageId}
onAppliedJobChange={handleAppliedJobChange}
onDecision={(item, decision) =>
void handleDecision(item, decision)
}
isActionLoading={isActionLoading}
isAppliedJobsLoading={isAppliedJobsLoading}
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Recent Sync Runs</CardTitle>
</CardHeader>
<CardContent>
{runs.length === 0 ? (
<p className="text-sm text-muted-foreground">No sync runs yet.</p>
) : (
<div className="space-y-2">
{runs.map((run) => (
<button
key={run.id}
type="button"
className="w-full rounded-lg border px-3 py-2 text-left transition-colors hover:bg-muted/30"
onClick={() => void handleOpenRunMessages(run)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">
<p>{run.id}</p>
<p>{formatEpochMs(run.startedAt)}</p>
</div>
<div className="flex items-center gap-2 text-xs">
<Badge variant="outline">{run.status}</Badge>
<span className="text-muted-foreground">
discovered {run.messagesDiscovered}
</span>
<span className="text-muted-foreground">
relevant {run.messagesRelevant}
</span>
<span className="text-muted-foreground">
matched {run.messagesMatched}
</span>
</div>
</div>
</button>
))}
</div>
)}
</CardContent>
</Card>
</PageMain>
<Dialog
open={isRunModalOpen}
onOpenChange={(open) => {
setIsRunModalOpen(open);
if (!open) {
setSelectedRunItems([]);
setSelectedRun(null);
}
}}
>
<DialogContent className="max-h-[85vh] max-w-6xl overflow-hidden p-0">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>Run Messages</DialogTitle>
<DialogDescription>
{selectedRun
? `Run ${selectedRun.id} • discovered ${selectedRun.messagesDiscovered} • relevant ${selectedRun.messagesRelevant} • matched ${selectedRun.messagesMatched}`
: "Review all messages captured in this sync run, including partial matches."}
</DialogDescription>
</DialogHeader>
<div className="max-h-[calc(85vh-92px)] overflow-auto px-6 pb-6">
{isRunMessagesLoading ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading run messages...
</div>
) : selectedRunItems.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">
No messages found for this run.
</p>
) : (
<EmailViewerList
items={selectedRunItems}
appliedJobs={appliedJobs}
appliedJobByMessageId={appliedJobByMessageId}
onAppliedJobChange={handleAppliedJobChange}
onDecision={(item, decision) =>
void handleDecision(item, decision)
}
isActionLoading={isActionLoading}
isAppliedJobsLoading={isAppliedJobsLoading}
/>
)}
</div>
</DialogContent>
</Dialog>
<AlertDialog
open={bulkActionDialog.isOpen}
onOpenChange={(open) =>
setBulkActionDialog((previous) => ({ ...previous, isOpen: open }))
}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{bulkActionDialog.action === "approve"
? "Approve All Messages?"
: "Ignore All Messages?"}
</AlertDialogTitle>
<AlertDialogDescription>
{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"}.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (bulkActionDialog.action) {
void handleBulkAction(bulkActionDialog.action);
}
}}
>
{bulkActionDialog.action === "approve"
? "Approve All"
: "Ignore All"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View File

@ -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<AutomaticRunTabProps> = ({
}) => {
const [isSaving, setIsSaving] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [countryMenuOpen, setCountryMenuOpen] = useState(false);
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
{
defaultValues: {
@ -193,7 +183,6 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
searchTermDraft: "",
});
setAdvancedOpen(false);
setCountryMenuOpen(false);
}, [open, settings, reset]);
const addSearchTerms = (input: string) => {
@ -311,7 +300,14 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
}
};
const countryOptions = SUPPORTED_COUNTRY_KEYS;
const countryOptions = useMemo(
() =>
SUPPORTED_COUNTRY_KEYS.map((country) => ({
value: country,
label: formatCountryLabel(country),
})),
[],
);
return (
<div className="flex h-full min-h-0 flex-col">
@ -357,69 +353,20 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
<div className="grid items-center gap-3 md:grid-cols-[120px_1fr]">
<Label className="text-base font-semibold">Country</Label>
<Popover open={countryMenuOpen} onOpenChange={setCountryMenuOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={countryMenuOpen}
aria-label={formatCountryLabel(values.country)}
className="h-9 w-full justify-between md:max-w-xs"
>
{formatCountryLabel(values.country)}
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverPrimitive.Content
align="start"
sideOffset={4}
className={cn(
"z-50 w-[320px] rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"origin-[--radix-popover-content-transform-origin]",
)}
>
<Command loop>
<CommandInput placeholder="Search country..." />
<CommandList
className="max-h-56"
onWheelCapture={(event) => event.stopPropagation()}
>
<CommandEmpty>No matching countries.</CommandEmpty>
<CommandGroup>
{countryOptions.map((country) => {
const selected = values.country === country;
const label = formatCountryLabel(country);
return (
<CommandItem
key={country}
value={`${country} ${label}`}
onSelect={() => {
setValue("country", country, {
shouldDirty: true,
});
setCountryMenuOpen(false);
}}
>
{label}
<Check
className={cn(
"ml-auto h-4 w-4",
selected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverPrimitive.Content>
</Popover>
<SearchableDropdown
value={values.country}
options={countryOptions}
onValueChange={(country) =>
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)}
/>
</div>
<Separator />
<Accordion

View File

@ -0,0 +1,202 @@
import type { JobListItem, PostApplicationInboxItem } from "@shared/types";
import { CheckCircle2, CircleUserRound, XCircle } from "lucide-react";
import type React from "react";
import { Button } from "@/components/ui/button";
import { SearchableDropdown } from "@/components/ui/searchable-dropdown";
import { formatDateTime } from "@/lib/utils";
type EmailViewerRowProps = {
item: PostApplicationInboxItem;
jobs: JobListItem[];
selectedAppliedJobId: string;
onAppliedJobChange: (jobId: string) => void;
onApprove: () => void;
onDeny: () => void;
isActionLoading: boolean;
isAppliedJobsLoading: boolean;
};
export type EmailViewerListProps = {
items: PostApplicationInboxItem[];
appliedJobs: JobListItem[];
appliedJobByMessageId: Record<string, string>;
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<EmailViewerRowProps> = ({
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 (
<div className="flex flex-col gap-3 border-b bg-card/40 px-3 py-3 last:border-b-0 lg:flex-row lg:items-center">
<div className="min-w-0 space-y-2">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border bg-muted/50 text-muted-foreground">
<CircleUserRound className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">
{getSenderLabel(
item.message.senderName,
item.message.fromAddress,
)}
</p>
<p className="truncate text-xs text-muted-foreground">
{item.message.fromAddress} ·{" "}
{formatEpochMs(item.message.receivedAt)}
</p>
</div>
</div>
<p className="truncate text-sm font-medium">{item.message.subject}</p>
{item.message.matchedJobId ? null : (
<p className="text-xs text-amber-600">
Relevant email with no reliable job match. Please select the correct
job.
</p>
)}
</div>
<div className="flex min-w-0 items-center gap-2 lg:ml-auto lg:w-[440px]">
<SearchableDropdown
value={selectedAppliedJobId}
options={appliedJobOptions}
onValueChange={onAppliedJobChange}
placeholder={isAppliedJobsLoading ? "Loading jobs..." : "Select job"}
searchPlaceholder="Search jobs..."
emptyText={
isAppliedJobsLoading ? "Loading jobs..." : "No jobs found."
}
disabled={isActionLoading}
triggerClassName="min-w-0 flex-1"
contentClassName="w-[360px]"
ariaLabel="Select job"
/>
<span
className={`shrink-0 text-xs tabular-nums ${scoreTextClass(score)}`}
>
{score === null ? "n/a" : `${Math.round(score)}%`}
</span>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
aria-label="Confirm email-job match"
title="Confirm email-job match"
onClick={onApprove}
disabled={isActionLoading || !canDecide}
className="h-8 w-8 p-0"
>
<CheckCircle2 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
aria-label="Ignore this match"
title="Ignore this match"
onClick={onDeny}
disabled={isActionLoading || !isActionable}
className="h-8 w-8 p-0"
>
<XCircle className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
};
export const EmailViewerList: React.FC<EmailViewerListProps> = ({
items,
appliedJobs,
appliedJobByMessageId,
onAppliedJobChange,
onDecision,
isActionLoading,
isAppliedJobsLoading,
}) => {
return (
<div className="overflow-hidden rounded-lg border">
{items.map((item) => {
const selectedAppliedJobId =
appliedJobByMessageId[item.message.id] ||
item.message.matchedJobId ||
"";
return (
<EmailViewerRow
key={item.message.id}
item={item}
jobs={appliedJobs}
selectedAppliedJobId={selectedAppliedJobId}
onAppliedJobChange={(value) =>
onAppliedJobChange(item.message.id, value)
}
onApprove={() => onDecision(item, "approve")}
onDeny={() => onDecision(item, "deny")}
isActionLoading={isActionLoading}
isAppliedJobsLoading={isAppliedJobsLoading}
/>
);
})}
</div>
);
};

View File

@ -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<SearchableDropdownProps> = ({
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={ariaLabel ?? triggerLabel}
disabled={disabled}
className={cn("justify-between", triggerClassName)}
>
<span className="truncate">{triggerLabel}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className={cn("w-[320px] p-0", contentClassName)}
>
<Command loop>
<CommandInput placeholder={searchPlaceholder} />
<CommandList
className={cn("max-h-56", listClassName)}
onWheelCapture={(event) => event.stopPropagation()}
>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const selected = value === option.value;
const searchableValue = [
option.label,
option.searchText ?? "",
option.value,
]
.join(" ")
.trim();
return (
<CommandItem
key={option.value}
value={searchableValue}
disabled={option.disabled}
onSelect={() => {
onValueChange(option.value);
setOpen(false);
}}
>
<span className="truncate">{option.label}</span>
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
selected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};

View File

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

View File

@ -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");
});
});

View File

@ -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<string, unknown>;
}
| 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);
}),
);

View File

@ -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");
});
});

View File

@ -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;
}
}),
);

View File

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

View File

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

View File

@ -127,6 +127,25 @@ export async function getJobById(id: string): Promise<Job | null> {
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).
*/

View File

@ -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<string, unknown>;
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<PostApplicationIntegration | null> {
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<PostApplicationIntegration> {
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<PostApplicationIntegration | null> {
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<PostApplicationIntegration | null> {
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);
}

View File

@ -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");
});
});

View File

@ -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<string, unknown> | null;
relevanceLlmScore?: number | null;
relevanceDecision: PostApplicationRelevanceDecision;
matchConfidence?: number | null;
stageTarget?: PostApplicationRouterStageTarget | null;
messageType: PostApplicationMessageType;
stageEventPayload?: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<PostApplicationMessage | null> {
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<PostApplicationMessage | null> {
const [row] = await db
.select()
.from(postApplicationMessages)
.where(eq(postApplicationMessages.id, id));
return row ? mapRowToPostApplicationMessage(row) : null;
}
export async function upsertPostApplicationMessage(
input: UpsertPostApplicationMessageInput,
): Promise<UpsertPostApplicationMessageResult> {
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<PostApplicationMessage | null> {
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<PostApplicationMessage[]> {
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<PostApplicationMessage[]> {
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<PostApplicationMessage | null> {
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;
}

View File

@ -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<PostApplicationSyncRunStatus, "running">;
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<PostApplicationSyncRun> {
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<PostApplicationSyncRun | null> {
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<PostApplicationSyncRun | null> {
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<PostApplicationSyncRun[]> {
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);
}

View File

@ -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 <jobs@example.com>" },
{ 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);
});
});

View File

@ -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<AppError>);
});
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<AppError>);
});
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(`
<html>
<head>
<style>.hidden { display: none; }</style>
<script>console.log("secret");</script>
</head>
<body>
<p>Hello <strong>there</strong>.</p>
<a href="https://example.com/apply?token=abc">Apply now</a>
<img src="https://example.com/banner.png" alt="Banner">
</body>
</html>
`),
},
};
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(
"<p>HTML version should be ignored when plain text is long enough.</p>",
),
},
},
],
};
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("<p>Preferred <b>HTML</b> content</p>"),
},
},
],
};
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:");
});
});

View File

@ -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<string, unknown> | 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<string, unknown> | 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<GmailCredentials> {
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<T>(token: string, url: string): Promise<T> {
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<Response> {
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<GmailListMessage[]> {
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<GmailFullMessage["payload"]>,
): 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<string>();
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<GmailFullMessage["payload"]>): void => {
const mimeType = String(part.mimeType ?? "").toLowerCase();
if (mimeType === "multipart/alternative") {
const children = (part.parts ?? []) as Array<
NonNullable<GmailFullMessage["payload"]>
>;
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<GmailFullMessage["payload"]>);
}
};
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<SmartRouterResult> {
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<string, unknown> | 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<GmailMetadataMessage> {
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<GmailFullMessage> {
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<void> {
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<T>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<void>,
): Promise<void> {
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<GmailSyncSummary> {
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;
}
}

View File

@ -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,
});
}

View File

@ -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<string, unknown>).refreshToken);
if (!refreshToken) {
throw providerInvalidRequest(
"Gmail connect requires a non-empty refreshToken in body.payload.refreshToken.",
);
}
return {
refreshToken,
accessToken: asString((raw as Record<string, unknown>).accessToken),
expiryDate: asNumber((raw as Record<string, unknown>).expiryDate),
scope: asString((raw as Record<string, unknown>).scope),
tokenType: asString((raw as Record<string, unknown>).tokenType),
email: asString((raw as Record<string, unknown>).email),
displayName: asString((raw as Record<string, unknown>).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<void> {
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<PostApplicationProviderActionResult> {
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<PostApplicationProviderActionResult> {
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<PostApplicationProviderActionResult> {
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<PostApplicationProviderActionResult> {
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.",
);
},
};

View File

@ -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<PostApplicationProviderActionResult> {
return notImplemented(args.accountKey);
},
async status(
args: PostApplicationProviderStatusArgs,
): Promise<PostApplicationProviderActionResult> {
return notImplemented(args.accountKey);
},
async sync(
args: PostApplicationProviderSyncArgs,
): Promise<PostApplicationProviderActionResult> {
return notImplemented(args.accountKey);
},
async disconnect(
args: PostApplicationProviderDisconnectArgs,
): Promise<PostApplicationProviderActionResult> {
return notImplemented(args.accountKey);
},
};

View File

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

View File

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

View File

@ -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<string, unknown>;
}) =>
({
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");
});
});

View File

@ -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<PostApplicationProviderActionResponse> {
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);
}
}

View File

@ -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<PostApplicationProviderActionResult>;
status(
args: PostApplicationProviderStatusArgs,
): Promise<PostApplicationProviderActionResult>;
sync(
args: PostApplicationProviderSyncArgs,
): Promise<PostApplicationProviderActionResult>;
disconnect(
args: PostApplicationProviderDisconnectArgs,
): Promise<PostApplicationProviderActionResult>;
}
export type ExecutePostApplicationProviderActionInput = {
provider: string;
action: PostApplicationProviderActionResponse["action"];
accountKey: string;
initiatedBy?: string | null;
connectPayload?: PostApplicationProviderActionConnectRequest;
syncPayload?: PostApplicationProviderActionSyncRequest;
};

View File

@ -0,0 +1,8 @@
export {
approvePostApplicationInboxItem,
bulkPostApplicationInboxAction,
denyPostApplicationInboxItem,
listPostApplicationInbox,
listPostApplicationReviewRuns,
listPostApplicationRunMessages,
} from "./service";

View File

@ -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<ReturnType<typeof listJobSummariesByIds>>,
): 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<PostApplicationInboxItem[]> {
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<BulkPostApplicationActionResponse> {
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<PostApplicationSyncRun[]> {
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) };
}

View File

@ -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<PostApplicationRouterStageTarget>(
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,
};
}

View File

@ -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<Storage>;
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<string, string>();
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,
});
}

109
package-lock.json generated
View File

@ -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",

View File

@ -364,6 +364,215 @@ export type ApiResponse<T> =
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<string, unknown> | 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<string, unknown> | null;
relevanceLlmScore: number | null;
relevanceDecision: PostApplicationRelevanceDecision;
matchedJobId: string | null;
matchConfidence: number | null;
stageTarget: PostApplicationRouterStageTarget | null;
messageType: PostApplicationMessageType;
stageEventPayload: Record<string, unknown> | 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<string, unknown>;
}
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<TJob = Job> {
jobs: TJob[];
total: number;