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:
parent
05f1c62de2
commit
687fd5e91f
11
.env.example
11
.env.example
@ -19,6 +19,17 @@ RXRESUME_PASSWORD=your_password_here
|
||||
BASIC_AUTH_USER=
|
||||
BASIC_AUTH_PASSWORD=
|
||||
|
||||
# =============================================================================
|
||||
# Gmail OAuth (Tracking Inbox) - optional
|
||||
# =============================================================================
|
||||
# Required to connect Gmail from the UI.
|
||||
GMAIL_OAUTH_CLIENT_ID=
|
||||
GMAIL_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# Optional override for OAuth callback URL.
|
||||
# If unset, defaults to <request-origin>/oauth/gmail/callback
|
||||
# GMAIL_OAUTH_REDIRECT_URI=http://localhost:3005/oauth/gmail/callback
|
||||
|
||||
# =============================================================================
|
||||
# UKVisaJobs (UK visa sponsorship jobs) - optional
|
||||
# =============================================================================
|
||||
|
||||
16
README.md
16
README.md
@ -9,6 +9,7 @@ AI-powered job discovery and application pipeline. Automatically finds jobs, sco
|
||||
3. **Tailor**: Generates a custom resume summary for top-tier matches.
|
||||
4. **Export**: Uses [RxResume v4](https://v4.rxresu.me) to create tailored PDFs.
|
||||
5. **Manage**: Review and mark jobs as "Applied" via the dashboard (calls webhooks for lifecycle events).
|
||||
6. **Track**: Connect your Gmail to automatically track post-application emails (interviews, offers, rejections) via the **Tracking Inbox**. The Smart Router AI matches emails to your applied jobs and updates job application status automatically.
|
||||
|
||||
## Example of generating a tailored resume for a job
|
||||
|
||||
@ -37,6 +38,19 @@ https://github.com/user-attachments/assets/06e5e782-47f5-42d0-8b28-b89102d7ea1b
|
||||
- Run the pipeline to fetch jobs → score → tailor → export PDFs
|
||||
|
||||
- Review jobs in the dashboard and mark stages
|
||||
- (Optional) Connect Gmail to enable automatic post-application email tracking
|
||||
|
||||
### Post-Application Tracking (Optional)
|
||||
|
||||
Once you've applied to jobs, connect your Gmail account to automatically track responses:
|
||||
|
||||
1. Go to **Tracking Inbox** in the dashboard
|
||||
2. Click **Connect Gmail** and authorize access
|
||||
3. The Smart Router AI will analyze incoming emails and match them to your applied jobs
|
||||
4. High-confidence matches (95%+) are auto-linked; others appear in your Inbox for review
|
||||
5. Interview invites, offers, and rejections automatically update your job timeline
|
||||
|
||||
See `documentation/self-hosting.md` for Gmail OAuth setup instructions.
|
||||
|
||||
### Quick Start (commands)
|
||||
|
||||
@ -76,6 +90,8 @@ The app will guide you through setup on first launch. The onboarding wizard help
|
||||
|
||||
- Technical breakdowns here: `documentation/extractors/README.md`
|
||||
- Orchestrator docs here: `documentation/orchestrator.md`
|
||||
- Post-application tracking: `documentation/post-application-tracking.md`
|
||||
- Full documentation index: `documentation/README.md`
|
||||
- Persistent data lives in `./data` (bind-mounted into the container).
|
||||
|
||||
## Notes / sharp edges
|
||||
|
||||
85
documentation/README.md
Normal file
85
documentation/README.md
Normal 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
|
||||
@ -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`.
|
||||
|
||||
241
documentation/post-application-tracking.md
Normal file
241
documentation/post-application-tracking.md
Normal 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
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
38
orchestrator/src/client/pages/GmailOauthCallbackPage.tsx
Normal file
38
orchestrator/src/client/pages/GmailOauthCallbackPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
246
orchestrator/src/client/pages/TrackingInboxPage.test.tsx
Normal file
246
orchestrator/src/client/pages/TrackingInboxPage.test.tsx
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
867
orchestrator/src/client/pages/TrackingInboxPage.tsx
Normal file
867
orchestrator/src/client/pages/TrackingInboxPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
|
||||
202
orchestrator/src/client/pages/tracking-inbox/EmailViewerList.tsx
Normal file
202
orchestrator/src/client/pages/tracking-inbox/EmailViewerList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
121
orchestrator/src/components/ui/searchable-dropdown.tsx
Normal file
121
orchestrator/src/components/ui/searchable-dropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
398
orchestrator/src/server/api/routes/post-application-providers.ts
Normal file
398
orchestrator/src/server/api/routes/post-application-providers.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
203
orchestrator/src/server/api/routes/post-application-review.ts
Normal file
203
orchestrator/src/server/api/routes/post-application-review.ts
Normal 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;
|
||||
}
|
||||
}),
|
||||
);
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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).
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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:");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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.",
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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";
|
||||
@ -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];
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
export {
|
||||
approvePostApplicationInboxItem,
|
||||
bulkPostApplicationInboxAction,
|
||||
denyPostApplicationInboxItem,
|
||||
listPostApplicationInbox,
|
||||
listPostApplicationReviewRuns,
|
||||
listPostApplicationRunMessages,
|
||||
} from "./service";
|
||||
@ -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) };
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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
109
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user