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:
@@ -19,6 +19,17 @@ RXRESUME_PASSWORD=your_password_here
|
|||||||
BASIC_AUTH_USER=
|
BASIC_AUTH_USER=
|
||||||
BASIC_AUTH_PASSWORD=
|
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
|
# UKVisaJobs (UK visa sponsorship jobs) - optional
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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.
|
3. **Tailor**: Generates a custom resume summary for top-tier matches.
|
||||||
4. **Export**: Uses [RxResume v4](https://v4.rxresu.me) to create tailored PDFs.
|
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).
|
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
|
## 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
|
- Run the pipeline to fetch jobs → score → tailor → export PDFs
|
||||||
|
|
||||||
- Review jobs in the dashboard and mark stages
|
- 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)
|
### 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`
|
- Technical breakdowns here: `documentation/extractors/README.md`
|
||||||
- Orchestrator docs here: `documentation/orchestrator.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).
|
- Persistent data lives in `./data` (bind-mounted into the container).
|
||||||
|
|
||||||
## Notes / sharp edges
|
## Notes / sharp edges
|
||||||
|
|||||||
@@ -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:
|
There are two main ways a job becomes Ready:
|
||||||
|
|
||||||
1) **Manual flow (most common)**
|
1. **Manual flow (most common)**
|
||||||
- A job starts in `discovered`.
|
- A job starts in `discovered`.
|
||||||
- You open it in the Discovered panel, decide to Tailor.
|
- 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.
|
- In Tailor mode you can edit job description (optional), tailored summary, tailored headline, tailored skills, and project picks.
|
||||||
- You click **Finalize & Move to Ready**.
|
- You click **Finalize & Move to Ready**.
|
||||||
- This runs summarization (if needed), generates the PDF, and sets status 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.
|
- The pipeline scores all discovered jobs.
|
||||||
- It auto-processes the top N above the score threshold.
|
- It auto-processes the top N above the score threshold.
|
||||||
- Those jobs go directly to `ready` with PDFs generated.
|
- 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
|
### Typical UI flow
|
||||||
|
|
||||||
1) Edit job description or tailoring in the Discovered/Tailor view, or use ?Edit job description? in 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).
|
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).
|
3. Click **Finalize & Move to Ready** (if still in Discovered) or **Regenerate PDF** (if already Ready).
|
||||||
|
|
||||||
### API flow (for automation)
|
### API flow (for automation)
|
||||||
|
|
||||||
1) Update the data:
|
1. Update the data:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PATCH /api/jobs/:id
|
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
|
```bash
|
||||||
POST /api/jobs/:id/summarize?force=true
|
POST /api/jobs/:id/summarize?force=true
|
||||||
```
|
```
|
||||||
|
|
||||||
3) Generate the PDF using current stored fields:
|
3. Generate the PDF using current stored fields:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/jobs/:id/generate-pdf
|
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
|
## Notes and gotchas
|
||||||
|
|
||||||
- `processing` is transient. If PDF generation fails, the job is reverted back to `discovered`.
|
- `processing` is transient. If PDF generation fails, the job is reverted back to `discovered`.
|
||||||
|
|||||||
@@ -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
|
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`.
|
If you want to build it yourself, you can run `docker compose up -d --build`.
|
||||||
|
|
||||||
## 2) Access the app and Onboard
|
## 2) Access the app and Onboard
|
||||||
|
|
||||||
Open your browser to:
|
Open your browser to:
|
||||||
|
|
||||||
- **Dashboard**: http://localhost:3005
|
- **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:
|
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.
|
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
|
## Persistent data
|
||||||
|
|
||||||
`./data` is bind-mounted into the container. It stores:
|
`./data` is bind-mounted into the container. It stores:
|
||||||
|
|
||||||
- SQLite DB: `data/jobs.db` (contains your API keys and configuration)
|
- SQLite DB: `data/jobs.db` (contains your API keys and configuration)
|
||||||
- Generated PDFs: `data/pdfs/`
|
- Generated PDFs: `data/pdfs/`
|
||||||
- Template resume selection: Stored internally after selection.
|
- 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.
|
For a public sandbox website, set `DEMO_MODE=true` on the container.
|
||||||
|
|
||||||
Behavior in demo mode:
|
Behavior in demo mode:
|
||||||
|
|
||||||
- **Works (local demo DB):** browsing, filtering, job status updates, timeline edits.
|
- **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.
|
- **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.
|
- **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/pipeline/run` | Trigger pipeline manually |
|
||||||
| POST | `/api/webhook/trigger` | Webhook for n8n (use `WEBHOOK_SECRET`) |
|
| 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
|
## Daily Flow
|
||||||
|
|
||||||
1. **17:00 - n8n triggers pipeline:**
|
1. **17:00 - n8n triggers pipeline:**
|
||||||
@@ -94,6 +105,11 @@ orchestrator/
|
|||||||
- Download PDF and apply manually
|
- Download PDF and apply manually
|
||||||
- Click "Mark Applied" to mark application status
|
- 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
|
## n8n Setup
|
||||||
|
|
||||||
Create a workflow with:
|
Create a workflow with:
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"drizzle-orm": "^0.38.2",
|
"drizzle-orm": "^0.38.2",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"get-tsconfig": "^4.10.0",
|
"get-tsconfig": "^4.10.0",
|
||||||
|
"html-to-text": "^9.0.5",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
"@types/better-sqlite3": "^7.6.8",
|
"@types/better-sqlite3": "^7.6.8",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/html-to-text": "^9.0.4",
|
||||||
"@types/jsdom": "^27.0.0",
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import { Toaster } from "@/components/ui/sonner";
|
|||||||
import { BasicAuthPrompt } from "./components/BasicAuthPrompt";
|
import { BasicAuthPrompt } from "./components/BasicAuthPrompt";
|
||||||
import { OnboardingGate } from "./components/OnboardingGate";
|
import { OnboardingGate } from "./components/OnboardingGate";
|
||||||
import { useDemoInfo } from "./hooks/useDemoInfo";
|
import { useDemoInfo } from "./hooks/useDemoInfo";
|
||||||
|
import { GmailOauthCallbackPage } from "./pages/GmailOauthCallbackPage";
|
||||||
import { HomePage } from "./pages/HomePage";
|
import { HomePage } from "./pages/HomePage";
|
||||||
import { JobPage } from "./pages/JobPage";
|
import { JobPage } from "./pages/JobPage";
|
||||||
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||||
import { SettingsPage } from "./pages/SettingsPage";
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
|
import { TrackingInboxPage } from "./pages/TrackingInboxPage";
|
||||||
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
||||||
|
|
||||||
/** Backwards-compatibility redirects: old URL paths -> new URL paths */
|
/** Backwards-compatibility redirects: old URL paths -> new URL paths */
|
||||||
@@ -76,9 +78,14 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
{/* Application routes */}
|
{/* Application routes */}
|
||||||
<Route path="/overview" element={<HomePage />} />
|
<Route path="/overview" element={<HomePage />} />
|
||||||
|
<Route
|
||||||
|
path="/oauth/gmail/callback"
|
||||||
|
element={<GmailOauthCallbackPage />}
|
||||||
|
/>
|
||||||
<Route path="/job/:id" element={<JobPage />} />
|
<Route path="/job/:id" element={<JobPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
|
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
|
||||||
|
<Route path="/tracking-inbox" element={<TrackingInboxPage />} />
|
||||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/jobs/:tab/:jobId"
|
path="/jobs/:tab/:jobId"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
BackupInfo,
|
BackupInfo,
|
||||||
BulkJobActionRequest,
|
BulkJobActionRequest,
|
||||||
BulkJobActionResponse,
|
BulkJobActionResponse,
|
||||||
|
BulkPostApplicationAction,
|
||||||
|
BulkPostApplicationActionResponse,
|
||||||
DemoInfoResponse,
|
DemoInfoResponse,
|
||||||
Job,
|
Job,
|
||||||
JobListItem,
|
JobListItem,
|
||||||
@@ -22,6 +24,11 @@ import type {
|
|||||||
ManualJobFetchResponse,
|
ManualJobFetchResponse,
|
||||||
ManualJobInferenceResponse,
|
ManualJobInferenceResponse,
|
||||||
PipelineStatusResponse,
|
PipelineStatusResponse,
|
||||||
|
PostApplicationInboxItem,
|
||||||
|
PostApplicationProvider,
|
||||||
|
PostApplicationProviderActionResponse,
|
||||||
|
PostApplicationRouterStageTarget,
|
||||||
|
PostApplicationSyncRun,
|
||||||
ProfileStatusResponse,
|
ProfileStatusResponse,
|
||||||
ResumeProfile,
|
ResumeProfile,
|
||||||
ResumeProjectCatalogItem,
|
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> {
|
export async function getDemoInfo(): Promise<DemoInfoResponse> {
|
||||||
return fetchApi<DemoInfoResponse>("/demo/info");
|
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 = {
|
export type NavLink = {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -20,6 +20,7 @@ export const NAV_LINKS: NavLink[] = [
|
|||||||
"/jobs/all",
|
"/jobs/all",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ to: "/tracking-inbox", label: "Tracking Inbox", icon: Inbox },
|
||||||
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
||||||
{ to: "/settings", label: "Settings", icon: Settings },
|
{ to: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
import {
|
||||||
formatCountryLabel,
|
formatCountryLabel,
|
||||||
isSourceAllowedForCountry,
|
isSourceAllowedForCountry,
|
||||||
@@ -6,7 +5,7 @@ import {
|
|||||||
SUPPORTED_COUNTRY_KEYS,
|
SUPPORTED_COUNTRY_KEYS,
|
||||||
} from "@shared/location-support.js";
|
} from "@shared/location-support.js";
|
||||||
import type { AppSettings, JobSource } from "@shared/types";
|
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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
@@ -17,17 +16,9 @@ import {
|
|||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -35,7 +26,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn, sourceLabel } from "@/lib/utils";
|
import { sourceLabel } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
AUTOMATIC_PRESETS,
|
AUTOMATIC_PRESETS,
|
||||||
type AutomaticPresetId,
|
type AutomaticPresetId,
|
||||||
@@ -131,7 +122,6 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const [countryMenuOpen, setCountryMenuOpen] = useState(false);
|
|
||||||
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
|
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
|
||||||
{
|
{
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -193,7 +183,6 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
searchTermDraft: "",
|
searchTermDraft: "",
|
||||||
});
|
});
|
||||||
setAdvancedOpen(false);
|
setAdvancedOpen(false);
|
||||||
setCountryMenuOpen(false);
|
|
||||||
}, [open, settings, reset]);
|
}, [open, settings, reset]);
|
||||||
|
|
||||||
const addSearchTerms = (input: string) => {
|
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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<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]">
|
<div className="grid items-center gap-3 md:grid-cols-[120px_1fr]">
|
||||||
<Label className="text-base font-semibold">Country</Label>
|
<Label className="text-base font-semibold">Country</Label>
|
||||||
<Popover open={countryMenuOpen} onOpenChange={setCountryMenuOpen}>
|
<SearchableDropdown
|
||||||
<PopoverTrigger asChild>
|
value={values.country}
|
||||||
<Button
|
options={countryOptions}
|
||||||
type="button"
|
onValueChange={(country) =>
|
||||||
variant="outline"
|
setValue("country", country, {
|
||||||
role="combobox"
|
shouldDirty: true,
|
||||||
aria-expanded={countryMenuOpen}
|
})
|
||||||
aria-label={formatCountryLabel(values.country)}
|
}
|
||||||
className="h-9 w-full justify-between md:max-w-xs"
|
placeholder="Select country"
|
||||||
>
|
searchPlaceholder="Search country..."
|
||||||
{formatCountryLabel(values.country)}
|
emptyText="No matching countries."
|
||||||
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
|
triggerClassName="h-9 w-full md:max-w-xs"
|
||||||
</Button>
|
ariaLabel={formatCountryLabel(values.country)}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Accordion
|
<Accordion
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 { manualJobsRouter } from "./routes/manual-jobs";
|
||||||
import { onboardingRouter } from "./routes/onboarding";
|
import { onboardingRouter } from "./routes/onboarding";
|
||||||
import { pipelineRouter } from "./routes/pipeline";
|
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 { profileRouter } from "./routes/profile";
|
||||||
import { settingsRouter } from "./routes/settings";
|
import { settingsRouter } from "./routes/settings";
|
||||||
import { visaSponsorsRouter } from "./routes/visa-sponsors";
|
import { visaSponsorsRouter } from "./routes/visa-sponsors";
|
||||||
@@ -21,6 +23,8 @@ apiRouter.use("/jobs", jobsRouter);
|
|||||||
apiRouter.use("/demo", demoRouter);
|
apiRouter.use("/demo", demoRouter);
|
||||||
apiRouter.use("/settings", settingsRouter);
|
apiRouter.use("/settings", settingsRouter);
|
||||||
apiRouter.use("/pipeline", pipelineRouter);
|
apiRouter.use("/pipeline", pipelineRouter);
|
||||||
|
apiRouter.use("/post-application", postApplicationProvidersRouter);
|
||||||
|
apiRouter.use("/post-application", postApplicationReviewRouter);
|
||||||
apiRouter.use("/manual-jobs", manualJobsRouter);
|
apiRouter.use("/manual-jobs", manualJobsRouter);
|
||||||
apiRouter.use("/webhook", webhookRouter);
|
apiRouter.use("/webhook", webhookRouter);
|
||||||
apiRouter.use("/profile", profileRouter);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
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)
|
// Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run)
|
||||||
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
|
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
|
||||||
SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`,
|
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 title TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE stage_events ADD COLUMN group_id TEXT`,
|
`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.
|
// Ensure pipeline_runs status supports "cancelled" for existing databases.
|
||||||
`CREATE TABLE IF NOT EXISTS pipeline_runs_new (
|
`CREATE TABLE IF NOT EXISTS pipeline_runs_new (
|
||||||
id TEXT PRIMARY KEY,
|
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_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_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_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
|
// 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)
|
`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 =
|
const isDuplicateColumn =
|
||||||
(migration.toLowerCase().includes("alter table jobs add column") ||
|
(migration.toLowerCase().includes("alter table jobs add column") ||
|
||||||
migration.toLowerCase().includes("alter table tasks add column") ||
|
migration.toLowerCase().includes("alter table tasks add column") ||
|
||||||
|
migration
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("alter table post_application_messages add column") ||
|
||||||
migration
|
migration
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes("alter table stage_events add column")) &&
|
.includes("alter table stage_events add column")) &&
|
||||||
@@ -249,6 +352,14 @@ for (const migration of migrations) {
|
|||||||
continue;
|
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
|
// Optional performance-only migration: if this fails we should still boot
|
||||||
// existing databases and continue without the index.
|
// existing databases and continue without the index.
|
||||||
const isOptionalOptimizationMigration = migration.includes(
|
const isOptionalOptimizationMigration = migration.includes(
|
||||||
|
|||||||
@@ -8,9 +8,22 @@ import {
|
|||||||
APPLICATION_TASK_TYPES,
|
APPLICATION_TASK_TYPES,
|
||||||
INTERVIEW_OUTCOMES,
|
INTERVIEW_OUTCOMES,
|
||||||
INTERVIEW_TYPES,
|
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";
|
} from "@shared/types";
|
||||||
import { sql } from "drizzle-orm";
|
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", {
|
export const jobs = sqliteTable("jobs", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@@ -163,6 +176,129 @@ export const settings = sqliteTable("settings", {
|
|||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
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 JobRow = typeof jobs.$inferSelect;
|
||||||
export type NewJobRow = typeof jobs.$inferInsert;
|
export type NewJobRow = typeof jobs.$inferInsert;
|
||||||
export type StageEventRow = typeof stageEvents.$inferSelect;
|
export type StageEventRow = typeof stageEvents.$inferSelect;
|
||||||
@@ -175,3 +311,15 @@ export type PipelineRunRow = typeof pipelineRuns.$inferSelect;
|
|||||||
export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert;
|
export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert;
|
||||||
export type SettingsRow = typeof settings.$inferSelect;
|
export type SettingsRow = typeof settings.$inferSelect;
|
||||||
export type NewSettingsRow = typeof settings.$inferInsert;
|
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;
|
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).
|
* 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);
|
||||||
|
}
|
||||||
+164
@@ -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;
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Generated
+109
@@ -2702,6 +2702,19 @@
|
|||||||
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
|
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@sindresorhus/is": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
|
||||||
@@ -2787,6 +2800,13 @@
|
|||||||
"integrity": "sha512-Hq9IMnfekuOCsEmYl4QX2HBrT+XsfXiupfrLLY8Dcf3Puf4BkBOxSbWYTITSOQAhJoYPBez+b4MJRpIYL65z8A==",
|
"integrity": "sha512-Hq9IMnfekuOCsEmYl4QX2HBrT+XsfXiupfrLLY8Dcf3Puf4BkBOxSbWYTITSOQAhJoYPBez+b4MJRpIYL65z8A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/http-cache-semantics": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
"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": ">=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": {
|
"node_modules/defaults": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
||||||
@@ -4375,6 +4404,41 @@
|
|||||||
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
|
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/htmlparser2": {
|
||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
|
||||||
@@ -5043,6 +5107,15 @@
|
|||||||
"node": ">=22"
|
"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": {
|
"node_modules/lightningcss-android-arm64": {
|
||||||
"version": "1.30.2",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
"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"
|
"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": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -5895,6 +5981,15 @@
|
|||||||
"through": "~2.3"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -6410,6 +6505,18 @@
|
|||||||
"loose-envify": "^1.1.0"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
@@ -7375,6 +7482,7 @@
|
|||||||
"drizzle-orm": "^0.38.2",
|
"drizzle-orm": "^0.38.2",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"get-tsconfig": "^4.10.0",
|
"get-tsconfig": "^4.10.0",
|
||||||
|
"html-to-text": "^9.0.5",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -7399,6 +7507,7 @@
|
|||||||
"@types/better-sqlite3": "^7.6.8",
|
"@types/better-sqlite3": "^7.6.8",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/html-to-text": "^9.0.4",
|
||||||
"@types/jsdom": "^27.0.0",
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
|
|||||||
@@ -364,6 +364,215 @@ export type ApiResponse<T> =
|
|||||||
meta: ApiMeta;
|
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> {
|
export interface JobsListResponse<TJob = Job> {
|
||||||
jobs: TJob[];
|
jobs: TJob[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user