mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
Merge pull request #4293 from simstudioai/staging
v0.6.57: mothership reliability, ashby refactor, tables row count, copilot id fix, bun upgrade
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1.3.11-alpine
|
||||
FROM oven/bun:1.3.13-alpine
|
||||
|
||||
# Install necessary packages for development
|
||||
RUN apk add --no-cache \
|
||||
|
||||
2
.github/workflows/docs-embeddings.yml
vendored
2
.github/workflows/docs-embeddings.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
bun-version: 1.3.13
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
4
.github/workflows/i18n.yml
vendored
4
.github/workflows/i18n.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
bun-version: 1.3.13
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
bun-version: 1.3.13
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
|
||||
2
.github/workflows/migrations.yml
vendored
2
.github/workflows/migrations.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
bun-version: 1.3.13
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
|
||||
2
.github/workflows/publish-cli.yml
vendored
2
.github/workflows/publish-cli.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
bun-version: 1.3.13
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
2
.github/workflows/publish-ts-sdk.yml
vendored
2
.github/workflows/publish-ts-sdk.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
bun-version: 1.3.13
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
2
.github/workflows/test-build.yml
vendored
2
.github/workflows/test-build.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
bun-version: 1.3.13
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -38,7 +38,7 @@ Integrate Ashby into the workflow. Manage candidates (list, get, create, update,
|
||||
|
||||
### `ashby_add_candidate_tag`
|
||||
|
||||
Adds a tag to a candidate in Ashby.
|
||||
Adds a tag to a candidate in Ashby and returns the updated candidate.
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -52,7 +52,37 @@ Adds a tag to a candidate in Ashby.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the tag was successfully added |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
|
||||
### `ashby_change_application_stage`
|
||||
|
||||
@@ -71,8 +101,37 @@ Moves an application to a different interview stage. Requires an archive reason
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `applicationId` | string | Application UUID |
|
||||
| `stageId` | string | New interview stage UUID |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
|
||||
### `ashby_create_application`
|
||||
|
||||
@@ -95,7 +154,37 @@ Creates a new application for a candidate on a job. Optionally specify interview
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `applicationId` | string | Created application UUID |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
|
||||
### `ashby_create_candidate`
|
||||
|
||||
@@ -107,7 +196,7 @@ Creates a new candidate record in Ashby.
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `name` | string | Yes | The candidate full name |
|
||||
| `email` | string | Yes | Primary email address for the candidate |
|
||||
| `email` | string | No | Primary email address for the candidate |
|
||||
| `phoneNumber` | string | No | Primary phone number for the candidate |
|
||||
| `linkedInUrl` | string | No | LinkedIn profile URL |
|
||||
| `githubUrl` | string | No | GitHub profile URL |
|
||||
@@ -117,17 +206,37 @@ Creates a new candidate record in Ashby.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Created candidate UUID |
|
||||
| `name` | string | Full name |
|
||||
| `primaryEmailAddress` | object | Primary email contact info |
|
||||
| ↳ `value` | string | Email address |
|
||||
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
|
||||
| ↳ `isPrimary` | boolean | Whether this is the primary email |
|
||||
| `primaryPhoneNumber` | object | Primary phone contact info |
|
||||
| ↳ `value` | string | Phone number |
|
||||
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
|
||||
| ↳ `isPrimary` | boolean | Whether this is the primary phone |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
|
||||
### `ashby_create_note`
|
||||
|
||||
@@ -147,7 +256,15 @@ Creates a note on a candidate in Ashby. Supports plain text and HTML content (bo
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `noteId` | string | Created note UUID |
|
||||
| `id` | string | Created note UUID |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `content` | string | Note content |
|
||||
| `author` | object | Author of the note |
|
||||
| ↳ `id` | string | Author user UUID |
|
||||
| ↳ `firstName` | string | Author first name |
|
||||
| ↳ `lastName` | string | Author last name |
|
||||
| ↳ `email` | string | Author email |
|
||||
|
||||
### `ashby_get_application`
|
||||
|
||||
@@ -164,28 +281,37 @@ Retrieves full details about a single application by its ID.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Application UUID |
|
||||
| `status` | string | Application status \(Active, Hired, Archived, Lead\) |
|
||||
| `candidate` | object | Associated candidate |
|
||||
| ↳ `id` | string | Candidate UUID |
|
||||
| ↳ `name` | string | Candidate name |
|
||||
| `job` | object | Associated job |
|
||||
| ↳ `id` | string | Job UUID |
|
||||
| ↳ `title` | string | Job title |
|
||||
| `currentInterviewStage` | object | Current interview stage |
|
||||
| ↳ `id` | string | Stage UUID |
|
||||
| ↳ `title` | string | Stage title |
|
||||
| ↳ `type` | string | Stage type |
|
||||
| `source` | object | Application source |
|
||||
| ↳ `id` | string | Source UUID |
|
||||
| ↳ `title` | string | Source title |
|
||||
| `archiveReason` | object | Reason for archival |
|
||||
| ↳ `id` | string | Reason UUID |
|
||||
| ↳ `text` | string | Reason text |
|
||||
| ↳ `reasonType` | string | Reason type |
|
||||
| `archivedAt` | string | ISO 8601 archive timestamp |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
|
||||
### `ashby_get_candidate`
|
||||
|
||||
@@ -202,27 +328,37 @@ Retrieves full details about a single candidate by their ID.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Candidate UUID |
|
||||
| `name` | string | Full name |
|
||||
| `primaryEmailAddress` | object | Primary email contact info |
|
||||
| ↳ `value` | string | Email address |
|
||||
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
|
||||
| ↳ `isPrimary` | boolean | Whether this is the primary email |
|
||||
| `primaryPhoneNumber` | object | Primary phone contact info |
|
||||
| ↳ `value` | string | Phone number |
|
||||
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
|
||||
| ↳ `isPrimary` | boolean | Whether this is the primary phone |
|
||||
| `profileUrl` | string | URL to the candidate Ashby profile |
|
||||
| `position` | string | Current position or title |
|
||||
| `company` | string | Current company |
|
||||
| `linkedInUrl` | string | LinkedIn profile URL |
|
||||
| `githubUrl` | string | GitHub profile URL |
|
||||
| `tags` | array | Tags applied to the candidate |
|
||||
| ↳ `id` | string | Tag UUID |
|
||||
| ↳ `title` | string | Tag title |
|
||||
| `applicationIds` | array | IDs of associated applications |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
|
||||
### `ashby_get_job`
|
||||
|
||||
@@ -239,16 +375,37 @@ Retrieves full details about a single job by its ID.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Job UUID |
|
||||
| `title` | string | Job title |
|
||||
| `status` | string | Job status \(Open, Closed, Draft, Archived\) |
|
||||
| `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) |
|
||||
| `departmentId` | string | Department UUID |
|
||||
| `locationId` | string | Location UUID |
|
||||
| `descriptionPlain` | string | Job description in plain text |
|
||||
| `isArchived` | boolean | Whether the job is archived |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
|
||||
### `ashby_get_job_posting`
|
||||
|
||||
@@ -260,6 +417,8 @@ Retrieves full details about a single job posting by its ID.
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `jobPostingId` | string | Yes | The UUID of the job posting to fetch |
|
||||
| `expandApplicationFormDefinition` | boolean | No | Include application form definition in the response |
|
||||
| `expandSurveyFormDefinitions` | boolean | No | Include survey form definitions in the response |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -267,14 +426,56 @@ Retrieves full details about a single job posting by its ID.
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Job posting UUID |
|
||||
| `title` | string | Job posting title |
|
||||
| `jobId` | string | Associated job UUID |
|
||||
| `locationName` | string | Location name |
|
||||
| `descriptionPlain` | string | Full description in plain text |
|
||||
| `descriptionHtml` | string | Full description in HTML |
|
||||
| `descriptionSocial` | string | Shortened description for social sharing \(max 200 chars\) |
|
||||
| `descriptionParts` | object | Description broken into opening, body, and closing sections |
|
||||
| ↳ `descriptionOpening` | object | Opening \(from Job Boards theme settings\) |
|
||||
| ↳ `html` | string | HTML content |
|
||||
| ↳ `plain` | string | Plain text content |
|
||||
| ↳ `descriptionBody` | object | Main description body |
|
||||
| ↳ `html` | string | HTML content |
|
||||
| ↳ `plain` | string | Plain text content |
|
||||
| ↳ `descriptionClosing` | object | Closing \(from Job Boards theme settings\) |
|
||||
| ↳ `html` | string | HTML content |
|
||||
| ↳ `plain` | string | Plain text content |
|
||||
| `departmentName` | string | Department name |
|
||||
| `employmentType` | string | Employment type \(e.g. FullTime, PartTime, Contract\) |
|
||||
| `descriptionPlain` | string | Job posting description in plain text |
|
||||
| `isListed` | boolean | Whether the posting is publicly listed |
|
||||
| `teamName` | string | Team name |
|
||||
| `teamNameHierarchy` | array | Hierarchy of team names from root to team |
|
||||
| `jobId` | string | Associated job UUID |
|
||||
| `locationName` | string | Primary location name |
|
||||
| `locationIds` | object | Primary and secondary location UUIDs |
|
||||
| ↳ `primaryLocationId` | string | Primary location UUID |
|
||||
| ↳ `secondaryLocationIds` | array | Secondary location UUIDs |
|
||||
| `address` | object | Postal address of the posting location |
|
||||
| ↳ `postalAddress` | object | Structured postal address |
|
||||
| ↳ `addressCountry` | string | Country |
|
||||
| ↳ `addressRegion` | string | State or region |
|
||||
| ↳ `addressLocality` | string | City or locality |
|
||||
| ↳ `postalCode` | string | Postal code |
|
||||
| ↳ `streetAddress` | string | Street address |
|
||||
| `isRemote` | boolean | Whether the posting is remote |
|
||||
| `workplaceType` | string | Workplace type \(OnSite, Remote, Hybrid\) |
|
||||
| `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) |
|
||||
| `isListed` | boolean | Whether publicly listed on the job board |
|
||||
| `suppressDescriptionOpening` | boolean | Whether the theme opening is hidden on this posting |
|
||||
| `suppressDescriptionClosing` | boolean | Whether the theme closing is hidden on this posting |
|
||||
| `publishedDate` | string | ISO 8601 published date |
|
||||
| `applicationDeadline` | string | ISO 8601 application deadline |
|
||||
| `externalLink` | string | External link to the job posting |
|
||||
| `applyLink` | string | Direct apply link |
|
||||
| `compensation` | object | Compensation details for the posting |
|
||||
| ↳ `compensationTierSummary` | string | Human-readable tier summary |
|
||||
| ↳ `summaryComponents` | array | Structured compensation components |
|
||||
| ↳ `summary` | string | Component summary |
|
||||
| ↳ `compensationTypeLabel` | string | Component type label \(Salary, Commission, Bonus, Equity, etc.\) |
|
||||
| ↳ `interval` | string | Payment interval \(e.g. annual, hourly\) |
|
||||
| ↳ `currencyCode` | string | ISO 4217 currency code |
|
||||
| ↳ `minValue` | number | Minimum value |
|
||||
| ↳ `maxValue` | number | Maximum value |
|
||||
| ↳ `shouldDisplayCompensationOnJobBoard` | boolean | Whether compensation is shown on the job board |
|
||||
| `applicationLimitCalloutHtml` | string | HTML callout shown when application limit is reached |
|
||||
| `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
|
||||
### `ashby_get_offer`
|
||||
|
||||
@@ -291,20 +492,41 @@ Retrieves full details about a single offer by its ID.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Offer UUID |
|
||||
| `offerStatus` | string | Offer status \(e.g. WaitingOnCandidateResponse, CandidateAccepted\) |
|
||||
| `acceptanceStatus` | string | Acceptance status \(e.g. Accepted, Declined, Pending\) |
|
||||
| `applicationId` | string | Associated application UUID |
|
||||
| `startDate` | string | Offer start date |
|
||||
| `salary` | object | Salary details |
|
||||
| ↳ `currencyCode` | string | ISO 4217 currency code |
|
||||
| ↳ `value` | number | Salary amount |
|
||||
| `openingId` | string | Associated opening UUID |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp \(from latest version\) |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
|
||||
### `ashby_list_applications`
|
||||
|
||||
Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date.
|
||||
Lists all applications in an Ashby organization with pagination and optional filters for status, job, and creation date.
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -315,7 +537,6 @@ Lists all applications in an Ashby organization with pagination and optional fil
|
||||
| `perPage` | number | No | Number of results per page \(default 100\) |
|
||||
| `status` | string | No | Filter by application status: Active, Hired, Archived, or Lead |
|
||||
| `jobId` | string | No | Filter applications by a specific job UUID |
|
||||
| `candidateId` | string | No | Filter applications by a specific candidate UUID |
|
||||
| `createdAfter` | string | No | Filter to applications created after this ISO 8601 timestamp \(e.g. 2024-01-01T00:00:00Z\) |
|
||||
|
||||
#### Output
|
||||
@@ -323,23 +544,6 @@ Lists all applications in an Ashby organization with pagination and optional fil
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `applications` | array | List of applications |
|
||||
| ↳ `id` | string | Application UUID |
|
||||
| ↳ `status` | string | Application status \(Active, Hired, Archived, Lead\) |
|
||||
| ↳ `candidate` | object | Associated candidate |
|
||||
| ↳ `id` | string | Candidate UUID |
|
||||
| ↳ `name` | string | Candidate name |
|
||||
| ↳ `job` | object | Associated job |
|
||||
| ↳ `id` | string | Job UUID |
|
||||
| ↳ `title` | string | Job title |
|
||||
| ↳ `currentInterviewStage` | object | Current interview stage |
|
||||
| ↳ `id` | string | Stage UUID |
|
||||
| ↳ `title` | string | Stage title |
|
||||
| ↳ `type` | string | Stage type |
|
||||
| ↳ `source` | object | Application source |
|
||||
| ↳ `id` | string | Source UUID |
|
||||
| ↳ `title` | string | Source title |
|
||||
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
@@ -352,6 +556,7 @@ Lists all archive reasons configured in Ashby.
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `includeArchived` | boolean | No | Whether to include archived archive reasons in the response \(default false\) |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -360,7 +565,7 @@ Lists all archive reasons configured in Ashby.
|
||||
| `archiveReasons` | array | List of archive reasons |
|
||||
| ↳ `id` | string | Archive reason UUID |
|
||||
| ↳ `text` | string | Archive reason text |
|
||||
| ↳ `reasonType` | string | Reason type |
|
||||
| ↳ `reasonType` | string | Reason type \(RejectedByCandidate, RejectedByOrg, Other\) |
|
||||
| ↳ `isArchived` | boolean | Whether the reason is archived |
|
||||
|
||||
### `ashby_list_candidate_tags`
|
||||
@@ -372,6 +577,10 @@ Lists all candidate tags configured in Ashby.
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `includeArchived` | boolean | No | Whether to include archived candidate tags \(default false\) |
|
||||
| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value |
|
||||
| `syncToken` | string | No | Sync token from a previous response to fetch only changed results |
|
||||
| `perPage` | number | No | Number of results per page \(default 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -381,6 +590,9 @@ Lists all candidate tags configured in Ashby.
|
||||
| ↳ `id` | string | Tag UUID |
|
||||
| ↳ `title` | string | Tag title |
|
||||
| ↳ `isArchived` | boolean | Whether the tag is archived |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
| `syncToken` | string | Sync token to use for incremental updates in future requests |
|
||||
|
||||
### `ashby_list_candidates`
|
||||
|
||||
@@ -399,18 +611,6 @@ Lists all candidates in an Ashby organization with cursor-based pagination.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `candidates` | array | List of candidates |
|
||||
| ↳ `id` | string | Candidate UUID |
|
||||
| ↳ `name` | string | Full name |
|
||||
| ↳ `primaryEmailAddress` | object | Primary email contact info |
|
||||
| ↳ `value` | string | Email address |
|
||||
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
|
||||
| ↳ `isPrimary` | boolean | Whether this is the primary email |
|
||||
| ↳ `primaryPhoneNumber` | object | Primary phone contact info |
|
||||
| ↳ `value` | string | Phone number |
|
||||
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
|
||||
| ↳ `isPrimary` | boolean | Whether this is the primary phone |
|
||||
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
@@ -431,9 +631,15 @@ Lists all custom field definitions configured in Ashby.
|
||||
| `customFields` | array | List of custom field definitions |
|
||||
| ↳ `id` | string | Custom field UUID |
|
||||
| ↳ `title` | string | Custom field title |
|
||||
| ↳ `fieldType` | string | Field type \(e.g. String, Number, Boolean\) |
|
||||
| ↳ `objectType` | string | Object type the field applies to \(e.g. Candidate, Application, Job\) |
|
||||
| ↳ `isPrivate` | boolean | Whether the custom field is private |
|
||||
| ↳ `fieldType` | string | Field data type \(MultiValueSelect, NumberRange, String, Date, ValueSelect, Number, Currency, Boolean, LongText, CompensationRange\) |
|
||||
| ↳ `objectType` | string | Object type the field applies to \(Application, Candidate, Employee, Job, Offer, Opening, Talent_Project\) |
|
||||
| ↳ `isArchived` | boolean | Whether the custom field is archived |
|
||||
| ↳ `isRequired` | boolean | Whether a value is required |
|
||||
| ↳ `selectableValues` | array | Selectable values for MultiValueSelect fields \(empty for other field types\) |
|
||||
| ↳ `label` | string | Display label |
|
||||
| ↳ `value` | string | Stored value |
|
||||
| ↳ `isArchived` | boolean | Whether archived |
|
||||
|
||||
### `ashby_list_departments`
|
||||
|
||||
@@ -452,8 +658,11 @@ Lists all departments in Ashby.
|
||||
| `departments` | array | List of departments |
|
||||
| ↳ `id` | string | Department UUID |
|
||||
| ↳ `name` | string | Department name |
|
||||
| ↳ `externalName` | string | Candidate-facing name used on job boards |
|
||||
| ↳ `isArchived` | boolean | Whether the department is archived |
|
||||
| ↳ `parentId` | string | Parent department UUID |
|
||||
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
|
||||
### `ashby_list_interviews`
|
||||
|
||||
@@ -475,10 +684,24 @@ Lists interview schedules in Ashby, optionally filtered by application or interv
|
||||
| --------- | ---- | ----------- |
|
||||
| `interviewSchedules` | array | List of interview schedules |
|
||||
| ↳ `id` | string | Interview schedule UUID |
|
||||
| ↳ `status` | string | Schedule status \(NeedsScheduling, WaitingOnCandidateBooking, Scheduled, Complete, Cancelled, OnHold, etc.\) |
|
||||
| ↳ `applicationId` | string | Associated application UUID |
|
||||
| ↳ `interviewStageId` | string | Interview stage UUID |
|
||||
| ↳ `status` | string | Schedule status |
|
||||
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
| ↳ `interviewEvents` | array | Scheduled interview events on this schedule |
|
||||
| ↳ `id` | string | Event UUID |
|
||||
| ↳ `interviewId` | string | Interview template UUID |
|
||||
| ↳ `interviewScheduleId` | string | Parent schedule UUID |
|
||||
| ↳ `interviewerUserIds` | array | User UUIDs of interviewers assigned to the event |
|
||||
| ↳ `createdAt` | string | Event creation timestamp |
|
||||
| ↳ `updatedAt` | string | Event last updated timestamp |
|
||||
| ↳ `startTime` | string | Event start time |
|
||||
| ↳ `endTime` | string | Event end time |
|
||||
| ↳ `feedbackLink` | string | URL to submit feedback for the event |
|
||||
| ↳ `location` | string | Physical location |
|
||||
| ↳ `meetingLink` | string | Virtual meeting URL |
|
||||
| ↳ `hasSubmittedFeedback` | boolean | Whether any feedback has been submitted |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
@@ -500,11 +723,22 @@ Lists all job postings in Ashby.
|
||||
| ↳ `id` | string | Job posting UUID |
|
||||
| ↳ `title` | string | Job posting title |
|
||||
| ↳ `jobId` | string | Associated job UUID |
|
||||
| ↳ `locationName` | string | Location name |
|
||||
| ↳ `departmentName` | string | Department name |
|
||||
| ↳ `employmentType` | string | Employment type \(e.g. FullTime, PartTime, Contract\) |
|
||||
| ↳ `teamName` | string | Team name |
|
||||
| ↳ `locationName` | string | Primary location display name |
|
||||
| ↳ `locationIds` | object | Primary and secondary location UUIDs |
|
||||
| ↳ `primaryLocationId` | string | Primary location UUID |
|
||||
| ↳ `secondaryLocationIds` | array | Secondary location UUIDs |
|
||||
| ↳ `workplaceType` | string | Workplace type \(OnSite, Remote, Hybrid\) |
|
||||
| ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) |
|
||||
| ↳ `isListed` | boolean | Whether the posting is publicly listed |
|
||||
| ↳ `publishedDate` | string | ISO 8601 published date |
|
||||
| ↳ `applicationDeadline` | string | ISO 8601 application deadline |
|
||||
| ↳ `externalLink` | string | External link to the job posting |
|
||||
| ↳ `applyLink` | string | Direct apply link for the job posting |
|
||||
| ↳ `compensationTierSummary` | string | Compensation tier summary for job boards |
|
||||
| ↳ `shouldDisplayCompensationOnJobBoard` | boolean | Whether compensation is shown on the job board |
|
||||
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
|
||||
### `ashby_list_jobs`
|
||||
|
||||
@@ -524,14 +758,6 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `jobs` | array | List of jobs |
|
||||
| ↳ `id` | string | Job UUID |
|
||||
| ↳ `title` | string | Job title |
|
||||
| ↳ `status` | string | Job status \(Open, Closed, Archived, Draft\) |
|
||||
| ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) |
|
||||
| ↳ `departmentId` | string | Department UUID |
|
||||
| ↳ `locationId` | string | Location UUID |
|
||||
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
@@ -552,12 +778,18 @@ Lists all locations configured in Ashby.
|
||||
| `locations` | array | List of locations |
|
||||
| ↳ `id` | string | Location UUID |
|
||||
| ↳ `name` | string | Location name |
|
||||
| ↳ `externalName` | string | Candidate-facing name used on job boards |
|
||||
| ↳ `isArchived` | boolean | Whether the location is archived |
|
||||
| ↳ `isRemote` | boolean | Whether this is a remote location |
|
||||
| ↳ `address` | object | Location address |
|
||||
| ↳ `city` | string | City |
|
||||
| ↳ `region` | string | State or region |
|
||||
| ↳ `country` | string | Country |
|
||||
| ↳ `isRemote` | boolean | Whether the location is remote \(use workplaceType instead\) |
|
||||
| ↳ `workplaceType` | string | Workplace type \(OnSite, Hybrid, Remote\) |
|
||||
| ↳ `parentLocationId` | string | Parent location UUID |
|
||||
| ↳ `type` | string | Location component type \(Location, LocationHierarchy\) |
|
||||
| ↳ `address` | object | Location postal address |
|
||||
| ↳ `addressCountry` | string | Country |
|
||||
| ↳ `addressRegion` | string | State or region |
|
||||
| ↳ `addressLocality` | string | City or locality |
|
||||
| ↳ `postalCode` | string | Postal code |
|
||||
| ↳ `streetAddress` | string | Street address |
|
||||
|
||||
### `ashby_list_notes`
|
||||
|
||||
@@ -579,6 +811,7 @@ Lists all notes on a candidate with pagination support.
|
||||
| `notes` | array | List of notes on the candidate |
|
||||
| ↳ `id` | string | Note UUID |
|
||||
| ↳ `content` | string | Note content |
|
||||
| ↳ `isPrivate` | boolean | Whether the note is private |
|
||||
| ↳ `author` | object | Note author |
|
||||
| ↳ `id` | string | Author user UUID |
|
||||
| ↳ `firstName` | string | First name |
|
||||
@@ -605,16 +838,6 @@ Lists all offers with their latest version in an Ashby organization.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `offers` | array | List of offers |
|
||||
| ↳ `id` | string | Offer UUID |
|
||||
| ↳ `offerStatus` | string | Offer status |
|
||||
| ↳ `acceptanceStatus` | string | Acceptance status |
|
||||
| ↳ `applicationId` | string | Associated application UUID |
|
||||
| ↳ `startDate` | string | Offer start date |
|
||||
| ↳ `salary` | object | Salary details |
|
||||
| ↳ `currencyCode` | string | ISO 4217 currency code |
|
||||
| ↳ `value` | number | Salary amount |
|
||||
| ↳ `openingId` | string | Associated opening UUID |
|
||||
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
@@ -634,12 +857,6 @@ Lists all openings in Ashby with pagination.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `openings` | array | List of openings |
|
||||
| ↳ `id` | string | Opening UUID |
|
||||
| ↳ `openingState` | string | Opening state \(Approved, Closed, Draft, Filled, Open\) |
|
||||
| ↳ `isArchived` | boolean | Whether the opening is archived |
|
||||
| ↳ `openedAt` | string | ISO 8601 opened timestamp |
|
||||
| ↳ `closedAt` | string | ISO 8601 closed timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
@@ -661,6 +878,10 @@ Lists all candidate sources configured in Ashby.
|
||||
| ↳ `id` | string | Source UUID |
|
||||
| ↳ `title` | string | Source title |
|
||||
| ↳ `isArchived` | boolean | Whether the source is archived |
|
||||
| ↳ `sourceType` | object | Source type grouping |
|
||||
| ↳ `id` | string | Source type UUID |
|
||||
| ↳ `title` | string | Source type title |
|
||||
| ↳ `isArchived` | boolean | Whether archived |
|
||||
|
||||
### `ashby_list_users`
|
||||
|
||||
@@ -679,18 +900,12 @@ Lists all users in Ashby with pagination.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `users` | array | List of users |
|
||||
| ↳ `id` | string | User UUID |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `email` | string | Email address |
|
||||
| ↳ `isEnabled` | boolean | Whether the user account is enabled |
|
||||
| ↳ `globalRole` | string | User role \(Organization Admin, Elevated Access, Limited Access, External Recruiter\) |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
### `ashby_remove_candidate_tag`
|
||||
|
||||
Removes a tag from a candidate in Ashby.
|
||||
Removes a tag from a candidate in Ashby and returns the updated candidate.
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -704,7 +919,37 @@ Removes a tag from a candidate in Ashby.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the tag was successfully removed |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
|
||||
### `ashby_search_candidates`
|
||||
|
||||
@@ -723,18 +968,6 @@ Searches for candidates by name and/or email with AND logic. Results are limited
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `candidates` | array | Matching candidates \(max 100 results\) |
|
||||
| ↳ `id` | string | Candidate UUID |
|
||||
| ↳ `name` | string | Full name |
|
||||
| ↳ `primaryEmailAddress` | object | Primary email contact info |
|
||||
| ↳ `value` | string | Email address |
|
||||
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
|
||||
| ↳ `isPrimary` | boolean | Whether this is the primary email |
|
||||
| ↳ `primaryPhoneNumber` | object | Primary phone contact info |
|
||||
| ↳ `value` | string | Phone number |
|
||||
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
|
||||
| ↳ `isPrimary` | boolean | Whether this is the primary phone |
|
||||
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
|
||||
### `ashby_update_candidate`
|
||||
|
||||
@@ -758,26 +991,36 @@ Updates an existing candidate record in Ashby. Only provided fields are changed.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Candidate UUID |
|
||||
| `name` | string | Full name |
|
||||
| `primaryEmailAddress` | object | Primary email contact info |
|
||||
| ↳ `value` | string | Email address |
|
||||
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
|
||||
| ↳ `isPrimary` | boolean | Whether this is the primary email |
|
||||
| `primaryPhoneNumber` | object | Primary phone contact info |
|
||||
| ↳ `value` | string | Phone number |
|
||||
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
|
||||
| ↳ `isPrimary` | boolean | Whether this is the primary phone |
|
||||
| `profileUrl` | string | URL to the candidate Ashby profile |
|
||||
| `position` | string | Current position or title |
|
||||
| `company` | string | Current company |
|
||||
| `linkedInUrl` | string | LinkedIn profile URL |
|
||||
| `githubUrl` | string | GitHub profile URL |
|
||||
| `tags` | array | Tags applied to the candidate |
|
||||
| ↳ `id` | string | Tag UUID |
|
||||
| ↳ `title` | string | Tag title |
|
||||
| `applicationIds` | array | IDs of associated applications |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
|
||||
|
||||
|
||||
@@ -97,6 +97,14 @@ Trigger workflow when a candidate is hired
|
||||
| ↳ `job` | object | job output from the tool |
|
||||
| ↳ `id` | string | Job UUID |
|
||||
| ↳ `title` | string | Job title |
|
||||
| `offer` | object | offer output from the tool |
|
||||
| ↳ `id` | string | Accepted offer UUID |
|
||||
| ↳ `applicationId` | string | Associated application UUID |
|
||||
| ↳ `acceptanceStatus` | string | Offer acceptance status |
|
||||
| ↳ `offerStatus` | string | Offer process status |
|
||||
| ↳ `decidedAt` | string | Offer decision timestamp \(ISO 8601\) |
|
||||
| ↳ `latestVersion` | object | latestVersion output from the tool |
|
||||
| ↳ `id` | string | Latest offer version UUID |
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -1031,7 +1031,7 @@
|
||||
},
|
||||
{
|
||||
"name": "List Applications",
|
||||
"description": "Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date."
|
||||
"description": "Lists all applications in an Ashby organization with pagination and optional filters for status, job, and creation date."
|
||||
},
|
||||
{
|
||||
"name": "Get Application",
|
||||
@@ -1051,11 +1051,11 @@
|
||||
},
|
||||
{
|
||||
"name": "Add Candidate Tag",
|
||||
"description": "Adds a tag to a candidate in Ashby."
|
||||
"description": "Adds a tag to a candidate in Ashby and returns the updated candidate."
|
||||
},
|
||||
{
|
||||
"name": "Remove Candidate Tag",
|
||||
"description": "Removes a tag from a candidate in Ashby."
|
||||
"description": "Removes a tag from a candidate in Ashby and returns the updated candidate."
|
||||
},
|
||||
{
|
||||
"name": "Get Offer",
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock('@/lib/copilot/request/session', () => ({
|
||||
}),
|
||||
encodeSSEEnvelope: (event: Record<string, unknown>) =>
|
||||
new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`),
|
||||
encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`),
|
||||
SSE_RESPONSE_HEADERS: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
},
|
||||
@@ -132,6 +133,7 @@ describe('copilot chat stream replay route', () => {
|
||||
)
|
||||
|
||||
const chunks = await readAllChunks(response)
|
||||
expect(chunks[0]).toBe(': accepted\n\n')
|
||||
expect(chunks.join('')).toContain(
|
||||
JSON.stringify({
|
||||
status: MothershipStreamV1CompletionStatus.cancelled,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getCopilotTracer, markSpanForError } from '@/lib/copilot/request/otel'
|
||||
import {
|
||||
checkForReplayGap,
|
||||
createEvent,
|
||||
encodeSSEComment,
|
||||
encodeSSEEnvelope,
|
||||
readEvents,
|
||||
readFilePreviewSessions,
|
||||
@@ -31,6 +32,7 @@ export const maxDuration = 3600
|
||||
|
||||
const logger = createLogger('CopilotChatStreamAPI')
|
||||
const POLL_INTERVAL_MS = 250
|
||||
const REPLAY_KEEPALIVE_INTERVAL_MS = 15_000
|
||||
const MAX_STREAM_MS = 60 * 60 * 1000
|
||||
|
||||
function extractCanonicalRequestId(value: unknown): string {
|
||||
@@ -266,6 +268,7 @@ async function handleResumeRequestBody({
|
||||
let controllerClosed = false
|
||||
let sawTerminalEvent = false
|
||||
let currentRequestId = extractRunRequestId(run)
|
||||
let lastWriteTime = Date.now()
|
||||
// Stamp the logical request id + chat id on the resume root as soon
|
||||
// as we resolve them from the run row, so TraceQL joins work on
|
||||
// resume legs the same way they do on the original POST.
|
||||
@@ -291,6 +294,19 @@ async function handleResumeRequestBody({
|
||||
if (controllerClosed) return false
|
||||
try {
|
||||
controller.enqueue(encodeSSEEnvelope(payload))
|
||||
lastWriteTime = Date.now()
|
||||
return true
|
||||
} catch {
|
||||
controllerClosed = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const enqueueComment = (comment: string) => {
|
||||
if (controllerClosed) return false
|
||||
try {
|
||||
controller.enqueue(encodeSSEComment(comment))
|
||||
lastWriteTime = Date.now()
|
||||
return true
|
||||
} catch {
|
||||
controllerClosed = true
|
||||
@@ -306,7 +322,6 @@ async function handleResumeRequestBody({
|
||||
const flushEvents = async () => {
|
||||
const events = await readEvents(streamId, cursor)
|
||||
if (events.length > 0) {
|
||||
totalEventsFlushed += events.length
|
||||
logger.debug('[Resume] Flushing events', {
|
||||
streamId,
|
||||
afterCursor: cursor,
|
||||
@@ -314,14 +329,15 @@ async function handleResumeRequestBody({
|
||||
})
|
||||
}
|
||||
for (const envelope of events) {
|
||||
if (!enqueueEvent(envelope)) {
|
||||
break
|
||||
}
|
||||
totalEventsFlushed += 1
|
||||
cursor = envelope.stream.cursor ?? String(envelope.seq)
|
||||
currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId
|
||||
if (envelope.type === MothershipStreamV1EventType.complete) {
|
||||
sawTerminalEvent = true
|
||||
}
|
||||
if (!enqueueEvent(envelope)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,21 +357,30 @@ async function handleResumeRequestBody({
|
||||
reason: options?.reason,
|
||||
requestId: currentRequestId,
|
||||
})) {
|
||||
if (!enqueueEvent(envelope)) {
|
||||
break
|
||||
}
|
||||
cursor = envelope.stream.cursor ?? String(envelope.seq)
|
||||
if (envelope.type === MothershipStreamV1EventType.complete) {
|
||||
sawTerminalEvent = true
|
||||
}
|
||||
if (!enqueueEvent(envelope)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
enqueueComment('accepted')
|
||||
|
||||
const gap = await checkForReplayGap(streamId, afterCursor, currentRequestId)
|
||||
if (gap) {
|
||||
for (const envelope of gap.envelopes) {
|
||||
enqueueEvent(envelope)
|
||||
if (!enqueueEvent(envelope)) {
|
||||
break
|
||||
}
|
||||
cursor = envelope.stream.cursor ?? String(envelope.seq)
|
||||
currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId
|
||||
if (envelope.type === MothershipStreamV1EventType.complete) {
|
||||
sawTerminalEvent = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -408,6 +433,10 @@ async function handleResumeRequestBody({
|
||||
break
|
||||
}
|
||||
|
||||
if (Date.now() - lastWriteTime >= REPLAY_KEEPALIVE_INTERVAL_MS) {
|
||||
enqueueComment('keepalive')
|
||||
}
|
||||
|
||||
await sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) {
|
||||
|
||||
@@ -66,6 +66,12 @@ const QueryRowsSchema = z.object({
|
||||
.min(0, 'Offset must be 0 or greater')
|
||||
.optional()
|
||||
.default(0),
|
||||
includeTotal: z
|
||||
.preprocess(
|
||||
(val) => (val === null || val === undefined || val === '' ? undefined : val === 'true'),
|
||||
z.boolean().optional()
|
||||
)
|
||||
.default(true),
|
||||
})
|
||||
|
||||
const nonEmptyFilter = z
|
||||
@@ -328,6 +334,7 @@ export const GET = withRouteHandler(
|
||||
const sortParam = searchParams.get('sort')
|
||||
const limit = searchParams.get('limit')
|
||||
const offset = searchParams.get('offset')
|
||||
const includeTotalParam = searchParams.get('includeTotal')
|
||||
|
||||
let filter: Record<string, unknown> | undefined
|
||||
let sort: Sort | undefined
|
||||
@@ -349,6 +356,7 @@ export const GET = withRouteHandler(
|
||||
sort,
|
||||
limit,
|
||||
offset,
|
||||
includeTotal: includeTotalParam,
|
||||
})
|
||||
|
||||
const accessResult = await checkAccess(tableId, authResult.userId, 'read')
|
||||
@@ -398,17 +406,19 @@ export const GET = withRouteHandler(
|
||||
query = query.orderBy(userTableRows.position) as typeof query
|
||||
}
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
const [{ count: totalCount }] = await countQuery
|
||||
let totalCount: number | null = null
|
||||
if (validated.includeTotal) {
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
totalCount = Number(count)
|
||||
}
|
||||
|
||||
const rows = await query.limit(validated.limit).offset(validated.offset)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount})`
|
||||
`[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount ?? 'n/a'})`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -424,7 +434,7 @@ export const GET = withRouteHandler(
|
||||
r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
|
||||
})),
|
||||
rowCount: rows.length,
|
||||
totalCount: Number(totalCount),
|
||||
totalCount,
|
||||
limit: validated.limit,
|
||||
offset: validated.offset,
|
||||
},
|
||||
|
||||
@@ -71,6 +71,12 @@ const QueryRowsSchema = z.object({
|
||||
.optional()
|
||||
)
|
||||
.default(0),
|
||||
includeTotal: z
|
||||
.preprocess(
|
||||
(val) => (val === null || val === undefined || val === '' ? undefined : val === 'true'),
|
||||
z.boolean().optional()
|
||||
)
|
||||
.default(true),
|
||||
})
|
||||
|
||||
const nonEmptyFilter = z
|
||||
@@ -219,6 +225,7 @@ export const GET = withRouteHandler(
|
||||
sort,
|
||||
limit: searchParams.get('limit'),
|
||||
offset: searchParams.get('offset'),
|
||||
includeTotal: searchParams.get('includeTotal'),
|
||||
})
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
@@ -268,16 +275,37 @@ export const GET = withRouteHandler(
|
||||
query = query.orderBy(userTableRows.position) as typeof query
|
||||
}
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
const rowsPromise = query.limit(validated.limit).offset(validated.offset)
|
||||
|
||||
const [countResult, rows] = await Promise.all([
|
||||
countQuery,
|
||||
query.limit(validated.limit).offset(validated.offset),
|
||||
])
|
||||
const totalCount = countResult[0].count
|
||||
let totalCount: number | null = null
|
||||
if (validated.includeTotal) {
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
const [countResult, rows] = await Promise.all([countQuery, rowsPromise])
|
||||
totalCount = Number(countResult[0].count)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: rows.map((r) => ({
|
||||
id: r.id,
|
||||
data: r.data,
|
||||
position: r.position,
|
||||
createdAt:
|
||||
r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
|
||||
updatedAt:
|
||||
r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
|
||||
})),
|
||||
rowCount: rows.length,
|
||||
totalCount,
|
||||
limit: validated.limit,
|
||||
offset: validated.offset,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const rows = await rowsPromise
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -292,7 +320,7 @@ export const GET = withRouteHandler(
|
||||
r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
|
||||
})),
|
||||
rowCount: rows.length,
|
||||
totalCount: Number(totalCount),
|
||||
totalCount,
|
||||
limit: validated.limit,
|
||||
offset: validated.offset,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
QueuedMessage,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { useAutoScroll } from '@/hooks/use-auto-scroll'
|
||||
import { useProgressiveList } from '@/hooks/use-progressive-list'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { MothershipChatSkeleton } from './mothership-chat-skeleton'
|
||||
|
||||
@@ -104,6 +105,21 @@ export function MothershipChat({
|
||||
scrollOnMount: true,
|
||||
})
|
||||
const hasMessages = messages.length > 0
|
||||
const stagingKey = chatId ?? 'pending-chat'
|
||||
const { staged: stagedMessages, isStaging } = useProgressiveList(messages, stagingKey)
|
||||
const stagedMessageCount = stagedMessages.length
|
||||
const stagedOffset = messages.length - stagedMessages.length
|
||||
const precedingUserContentByIndex = useMemo(() => {
|
||||
const contentByIndex: Array<string | undefined> = []
|
||||
let lastUserContent: string | undefined
|
||||
for (const [index, message] of messages.entries()) {
|
||||
contentByIndex[index] = lastUserContent
|
||||
if (message.role === 'user') {
|
||||
lastUserContent = message.content
|
||||
}
|
||||
}
|
||||
return contentByIndex
|
||||
}, [messages])
|
||||
const initialScrollDoneRef = useRef(false)
|
||||
const userInputRef = useRef<UserInputHandle>(null)
|
||||
const handleSendQueuedHead = useCallback(() => {
|
||||
@@ -134,6 +150,11 @@ export function MothershipChat({
|
||||
scrollToBottom()
|
||||
}, [hasMessages, initialScrollBlocked, scrollToBottom])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isStaging || initialScrollBlocked || !initialScrollDoneRef.current) return
|
||||
scrollToBottom()
|
||||
}, [isStaging, stagedMessageCount, initialScrollBlocked, scrollToBottom])
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full min-h-0 flex-col', className)}>
|
||||
<div ref={scrollContainerRef} className={styles.scrollContainer}>
|
||||
@@ -141,7 +162,8 @@ export function MothershipChat({
|
||||
<MothershipChatSkeleton layout={layout} />
|
||||
) : (
|
||||
<div className={styles.content}>
|
||||
{messages.map((msg, index) => {
|
||||
{stagedMessages.map((msg, localIndex) => {
|
||||
const index = stagedOffset + localIndex
|
||||
if (msg.role === 'user') {
|
||||
const hasAttachments = Boolean(msg.attachments?.length)
|
||||
return (
|
||||
@@ -177,10 +199,7 @@ export function MothershipChat({
|
||||
}
|
||||
|
||||
const isLastMessage = index === messages.length - 1
|
||||
const precedingUserMsg = [...messages]
|
||||
.slice(0, index)
|
||||
.reverse()
|
||||
.find((m) => m.role === 'user')
|
||||
const precedingUserContent = precedingUserContentByIndex[index]
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={styles.assistantRow}>
|
||||
@@ -196,7 +215,7 @@ export function MothershipChat({
|
||||
<MessageActions
|
||||
content={msg.content}
|
||||
chatId={chatId}
|
||||
userQuery={precedingUserMsg?.content}
|
||||
userQuery={precedingUserContent}
|
||||
requestId={msg.requestId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ export function useTableData({
|
||||
offset: 0,
|
||||
filter: queryOptions.filter,
|
||||
sort: queryOptions.sort,
|
||||
includeTotal: false,
|
||||
enabled: Boolean(workspaceId && tableId),
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ComboboxOption } from '@/components/emcn'
|
||||
import { useTableColumns } from '@/lib/table/hooks'
|
||||
import type { FilterRule } from '@/lib/table/query-builder/constants'
|
||||
import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder'
|
||||
import { useCanonicalSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value'
|
||||
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { FilterRuleRow } from './components/filter-rule-row'
|
||||
@@ -40,7 +41,7 @@ export function FilterBuilder({
|
||||
tableIdSubBlockId = 'tableId',
|
||||
}: FilterBuilderProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<FilterRule[]>(blockId, subBlockId)
|
||||
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
|
||||
const tableIdValue = useCanonicalSubBlockValue<string>(blockId, tableIdSubBlockId)
|
||||
|
||||
const dynamicColumns = useTableColumns({ tableId: tableIdValue })
|
||||
const columns = useMemo(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { generateId } from '@sim/utils/id'
|
||||
import type { ComboboxOption } from '@/components/emcn'
|
||||
import { useTableColumns } from '@/lib/table/hooks'
|
||||
import { SORT_DIRECTIONS, type SortRule } from '@/lib/table/query-builder/constants'
|
||||
import { useCanonicalSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { SortRuleRow } from './components/sort-rule-row'
|
||||
|
||||
@@ -36,7 +37,7 @@ export function SortBuilder({
|
||||
tableIdSubBlockId = 'tableId',
|
||||
}: SortBuilderProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<SortRule[]>(blockId, subBlockId)
|
||||
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
|
||||
const tableIdValue = useCanonicalSubBlockValue<string>(blockId, tableIdSubBlockId)
|
||||
|
||||
const dynamicColumns = useTableColumns({ tableId: tableIdValue, includeBuiltIn: true })
|
||||
const columns = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/**
|
||||
* Read a sub-block value by either its raw subBlockId or its canonicalParamId.
|
||||
*
|
||||
* `useSubBlockValue` only looks up the raw subBlockId. For fields that use
|
||||
* `canonicalParamId` to unify basic/advanced inputs (e.g. `tableSelector` vs
|
||||
* `manualTableId` both mapping to `tableId`), this hook resolves to whichever
|
||||
* member of the canonical group currently holds the value.
|
||||
*/
|
||||
export function useCanonicalSubBlockValue<T = unknown>(
|
||||
blockId: string,
|
||||
canonicalOrSubBlockId: string
|
||||
): T | null {
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
|
||||
return useStoreWithEqualityFn(
|
||||
useSubBlockStore,
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!activeWorkflowId) return null
|
||||
const blockValues = state.workflowValues[activeWorkflowId]?.[blockId] || {}
|
||||
const resolved = resolveDependencyValue(
|
||||
canonicalOrSubBlockId,
|
||||
blockValues,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
return (resolved ?? null) as T | null
|
||||
},
|
||||
[activeWorkflowId, blockId, canonicalOrSubBlockId, canonicalIndex, canonicalModeOverrides]
|
||||
),
|
||||
(a, b) => isEqual(a, b)
|
||||
)
|
||||
}
|
||||
@@ -113,7 +113,6 @@ export const AshbyBlock: BlockConfig = {
|
||||
id: 'email',
|
||||
title: 'Email',
|
||||
type: 'short-input',
|
||||
required: { field: 'operation', value: 'create_candidate' },
|
||||
placeholder: 'Email address',
|
||||
condition: { field: 'operation', value: ['create_candidate', 'update_candidate'] },
|
||||
},
|
||||
@@ -308,14 +307,6 @@ Output only the ISO 8601 timestamp string, nothing else.`,
|
||||
condition: { field: 'operation', value: 'list_applications' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'filterCandidateId',
|
||||
title: 'Candidate ID Filter',
|
||||
type: 'short-input',
|
||||
placeholder: 'Filter by candidate UUID',
|
||||
condition: { field: 'operation', value: 'list_applications' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'createdAfter',
|
||||
title: 'Created After',
|
||||
@@ -366,6 +357,7 @@ Output only the ISO 8601 timestamp string, nothing else.`,
|
||||
'list_openings',
|
||||
'list_users',
|
||||
'list_interviews',
|
||||
'list_candidate_tags',
|
||||
],
|
||||
},
|
||||
mode: 'advanced',
|
||||
@@ -386,10 +378,43 @@ Output only the ISO 8601 timestamp string, nothing else.`,
|
||||
'list_openings',
|
||||
'list_users',
|
||||
'list_interviews',
|
||||
'list_candidate_tags',
|
||||
],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'syncToken',
|
||||
title: 'Sync Token',
|
||||
type: 'short-input',
|
||||
placeholder: 'Sync token for incremental updates',
|
||||
condition: { field: 'operation', value: 'list_candidate_tags' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'includeArchived',
|
||||
title: 'Include Archived',
|
||||
type: 'switch',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['list_candidate_tags', 'list_archive_reasons'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'expandApplicationFormDefinition',
|
||||
title: 'Include Application Form Definition',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'get_job_posting' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'expandSurveyFormDefinitions',
|
||||
title: 'Include Survey Form Definitions',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'get_job_posting' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tagId',
|
||||
title: 'Tag ID',
|
||||
@@ -476,11 +501,25 @@ Output only the ISO 8601 timestamp string, nothing else.`,
|
||||
if (params.searchEmail) result.email = params.searchEmail
|
||||
if (params.filterStatus) result.status = params.filterStatus
|
||||
if (params.filterJobId) result.jobId = params.filterJobId
|
||||
if (params.filterCandidateId) result.candidateId = params.filterCandidateId
|
||||
if (params.jobStatus) result.status = params.jobStatus
|
||||
if (params.sendNotifications === 'true' || params.sendNotifications === true) {
|
||||
result.sendNotifications = true
|
||||
}
|
||||
if (params.includeArchived === 'true' || params.includeArchived === true) {
|
||||
result.includeArchived = true
|
||||
}
|
||||
if (
|
||||
params.expandApplicationFormDefinition === 'true' ||
|
||||
params.expandApplicationFormDefinition === true
|
||||
) {
|
||||
result.expandApplicationFormDefinition = true
|
||||
}
|
||||
if (
|
||||
params.expandSurveyFormDefinitions === 'true' ||
|
||||
params.expandSurveyFormDefinitions === true
|
||||
) {
|
||||
result.expandSurveyFormDefinitions = true
|
||||
}
|
||||
if (params.appCandidateId) result.candidateId = params.appCandidateId
|
||||
if (params.appCreatedAt) result.createdAt = params.appCreatedAt
|
||||
if (params.updateName) result.name = params.updateName
|
||||
@@ -515,11 +554,20 @@ Output only the ISO 8601 timestamp string, nothing else.`,
|
||||
sendNotifications: { type: 'boolean', description: 'Send notifications' },
|
||||
filterStatus: { type: 'string', description: 'Application status filter' },
|
||||
filterJobId: { type: 'string', description: 'Job UUID filter' },
|
||||
filterCandidateId: { type: 'string', description: 'Candidate UUID filter' },
|
||||
createdAfter: { type: 'string', description: 'Filter by creation date' },
|
||||
jobStatus: { type: 'string', description: 'Job status filter' },
|
||||
cursor: { type: 'string', description: 'Pagination cursor' },
|
||||
perPage: { type: 'number', description: 'Results per page' },
|
||||
syncToken: { type: 'string', description: 'Sync token for incremental updates' },
|
||||
includeArchived: { type: 'boolean', description: 'Include archived records' },
|
||||
expandApplicationFormDefinition: {
|
||||
type: 'boolean',
|
||||
description: 'Include application form definition in job posting',
|
||||
},
|
||||
expandSurveyFormDefinitions: {
|
||||
type: 'boolean',
|
||||
description: 'Include survey form definitions in job posting',
|
||||
},
|
||||
tagId: { type: 'string', description: 'Tag UUID' },
|
||||
offerId: { type: 'string', description: 'Offer UUID' },
|
||||
jobPostingId: { type: 'string', description: 'Job posting UUID' },
|
||||
@@ -530,93 +578,113 @@ Output only the ISO 8601 timestamp string, nothing else.`,
|
||||
candidates: {
|
||||
type: 'json',
|
||||
description:
|
||||
'List of candidates (id, name, primaryEmailAddress, primaryPhoneNumber, createdAt, updatedAt)',
|
||||
'List of candidates with rich fields (id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses[], phoneNumbers[], socialLinks[], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents[], tags[], applicationIds[], customFields[], resumeFileHandle, fileHandles[], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt)',
|
||||
},
|
||||
jobs: {
|
||||
type: 'json',
|
||||
description:
|
||||
'List of jobs (id, title, status, employmentType, departmentId, locationId, createdAt, updatedAt)',
|
||||
'List of jobs (id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds[], customFields[], jobPostingIds[], customRequisitionId, brandId, hiringTeam[], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings[] with latestVersion, compensation with compensationTiers[])',
|
||||
},
|
||||
applications: {
|
||||
type: 'json',
|
||||
description:
|
||||
'List of applications (id, status, candidate, job, currentInterviewStage, source, createdAt, updatedAt)',
|
||||
'List of applications (id, status, customFields[], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields[], archivedAt, job summary, creditedToUser, hiringTeam[], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt)',
|
||||
},
|
||||
notes: {
|
||||
type: 'json',
|
||||
description: 'List of notes (id, content, author, createdAt)',
|
||||
description: 'List of notes (id, content, author, isPrivate, createdAt)',
|
||||
},
|
||||
offers: {
|
||||
type: 'json',
|
||||
description:
|
||||
'List of offers (id, offerStatus, acceptanceStatus, applicationId, startDate, salary, openingId)',
|
||||
'List of offers (id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields[]/fileHandles[]/author/approvalStatus)',
|
||||
},
|
||||
archiveReasons: {
|
||||
type: 'json',
|
||||
description: 'List of archive reasons (id, text, reasonType, isArchived)',
|
||||
description:
|
||||
'List of archive reasons (id, text, reasonType [RejectedByCandidate/RejectedByOrg/Other], isArchived)',
|
||||
},
|
||||
sources: {
|
||||
type: 'json',
|
||||
description: 'List of sources (id, title, isArchived)',
|
||||
description: 'List of sources (id, title, isArchived, sourceType {id, title, isArchived})',
|
||||
},
|
||||
customFields: {
|
||||
type: 'json',
|
||||
description: 'List of custom fields (id, title, fieldType, objectType, isArchived)',
|
||||
description:
|
||||
'List of custom field definitions (id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues[] {label, value, isArchived})',
|
||||
},
|
||||
departments: {
|
||||
type: 'json',
|
||||
description: 'List of departments (id, name, isArchived, parentId)',
|
||||
description:
|
||||
'List of departments (id, name, externalName, isArchived, parentId, createdAt, updatedAt)',
|
||||
},
|
||||
locations: {
|
||||
type: 'json',
|
||||
description: 'List of locations (id, name, isArchived, isRemote, address)',
|
||||
description:
|
||||
'List of locations (id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress)',
|
||||
},
|
||||
jobPostings: {
|
||||
type: 'json',
|
||||
description:
|
||||
'List of job postings (id, title, jobId, locationName, departmentName, employmentType, isListed, publishedDate)',
|
||||
'List of job postings (id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt)',
|
||||
},
|
||||
openings: {
|
||||
type: 'json',
|
||||
description: 'List of openings (id, openingState, isArchived, openedAt, closedAt)',
|
||||
description:
|
||||
'List of openings (id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds[]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds[]/hiringTeam[]/customFields[])',
|
||||
},
|
||||
users: {
|
||||
type: 'json',
|
||||
description: 'List of users (id, firstName, lastName, email, isEnabled, globalRole)',
|
||||
description:
|
||||
'List of users (id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId)',
|
||||
},
|
||||
interviewSchedules: {
|
||||
type: 'json',
|
||||
description:
|
||||
'List of interview schedules (id, applicationId, interviewStageId, status, createdAt)',
|
||||
'List of interview schedules (id, applicationId, interviewStageId, interviewEvents[] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt)',
|
||||
},
|
||||
tags: {
|
||||
type: 'json',
|
||||
description: 'List of candidate tags (id, title, isArchived)',
|
||||
},
|
||||
stageId: { type: 'string', description: 'Interview stage UUID after stage change' },
|
||||
success: { type: 'boolean', description: 'Whether the operation succeeded' },
|
||||
offerStatus: {
|
||||
type: 'string',
|
||||
description: 'Offer status (e.g. WaitingOnCandidateResponse, CandidateAccepted)',
|
||||
},
|
||||
acceptanceStatus: {
|
||||
type: 'string',
|
||||
description: 'Acceptance status (e.g. Accepted, Declined, Pending)',
|
||||
},
|
||||
applicationId: { type: 'string', description: 'Associated application UUID' },
|
||||
openingId: { type: 'string', description: 'Opening UUID associated with the offer' },
|
||||
salary: {
|
||||
type: 'json',
|
||||
description: 'Salary details from latest version (currencyCode, value)',
|
||||
},
|
||||
startDate: { type: 'string', description: 'Offer start date from latest version' },
|
||||
id: { type: 'string', description: 'Resource UUID' },
|
||||
name: { type: 'string', description: 'Resource name' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
title: { type: 'string', description: 'Job title or job posting title' },
|
||||
status: { type: 'string', description: 'Status' },
|
||||
noteId: { type: 'string', description: 'Created note UUID' },
|
||||
candidate: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Candidate details (id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses[], phoneNumbers[], socialLinks[], customFields[], source, creditedToUser, createdAt, updatedAt)',
|
||||
},
|
||||
job: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Job details (id, title, status, employmentType, locationId, departmentId, hiringTeam[], author, location, openings[], compensation, createdAt, updatedAt)',
|
||||
},
|
||||
application: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Application details (id, status, customFields[], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam[], createdAt, updatedAt)',
|
||||
},
|
||||
offer: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Offer details (id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion)',
|
||||
},
|
||||
jobPosting: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Job posting details (id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy[], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt)',
|
||||
},
|
||||
content: { type: 'string', description: 'Note content' },
|
||||
author: {
|
||||
type: 'json',
|
||||
description: 'Note author (id, firstName, lastName, email, globalRole, isEnabled)',
|
||||
},
|
||||
isPrivate: { type: 'boolean', description: 'Whether the note is private' },
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
moreDataAvailable: { type: 'boolean', description: 'Whether more pages exist' },
|
||||
nextCursor: { type: 'string', description: 'Pagination cursor for next page' },
|
||||
syncToken: { type: 'string', description: 'Sync token for incremental updates' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -38,11 +38,14 @@ interface TableRowsParams {
|
||||
offset: number
|
||||
filter?: Filter | null
|
||||
sort?: Sort | null
|
||||
/** When `false`, skip the server-side `COUNT(*)` and receive `totalCount: null`. */
|
||||
includeTotal?: boolean
|
||||
}
|
||||
|
||||
interface TableRowsResponse {
|
||||
rows: TableRow[]
|
||||
totalCount: number
|
||||
/** `null` when the request opted out of the count via `includeTotal: false`. */
|
||||
totalCount: number | null
|
||||
}
|
||||
|
||||
interface RowMutationContext {
|
||||
@@ -64,12 +67,14 @@ function createRowsParamsKey({
|
||||
offset,
|
||||
filter,
|
||||
sort,
|
||||
includeTotal,
|
||||
}: Omit<TableRowsParams, 'workspaceId' | 'tableId'>): string {
|
||||
return JSON.stringify({
|
||||
limit,
|
||||
offset,
|
||||
filter: filter ?? null,
|
||||
sort: sort ?? null,
|
||||
includeTotal: includeTotal ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,6 +103,7 @@ async function fetchTableRows({
|
||||
offset,
|
||||
filter,
|
||||
sort,
|
||||
includeTotal,
|
||||
signal,
|
||||
}: TableRowsParams & { signal?: AbortSignal }): Promise<TableRowsResponse> {
|
||||
const searchParams = new URLSearchParams({
|
||||
@@ -114,6 +120,10 @@ async function fetchTableRows({
|
||||
searchParams.set('sort', JSON.stringify(sort))
|
||||
}
|
||||
|
||||
if (includeTotal === false) {
|
||||
searchParams.set('includeTotal', 'false')
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`, { signal })
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}))
|
||||
@@ -121,15 +131,15 @@ async function fetchTableRows({
|
||||
}
|
||||
|
||||
const json: {
|
||||
data?: { rows: TableRow[]; totalCount: number }
|
||||
data?: { rows: TableRow[]; totalCount: number | null }
|
||||
rows?: TableRow[]
|
||||
totalCount?: number
|
||||
totalCount?: number | null
|
||||
} = await res.json()
|
||||
|
||||
const data = json.data || json
|
||||
return {
|
||||
rows: (data.rows || []) as TableRow[],
|
||||
totalCount: data.totalCount || 0,
|
||||
totalCount: data.totalCount ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,9 +219,10 @@ export function useTableRows({
|
||||
offset,
|
||||
filter,
|
||||
sort,
|
||||
includeTotal,
|
||||
enabled = true,
|
||||
}: TableRowsParams & { enabled?: boolean }) {
|
||||
const paramsKey = createRowsParamsKey({ limit, offset, filter, sort })
|
||||
const paramsKey = createRowsParamsKey({ limit, offset, filter, sort, includeTotal })
|
||||
|
||||
return useQuery({
|
||||
queryKey: tableKeys.rows(tableId, paramsKey),
|
||||
@@ -223,6 +234,7 @@ export function useTableRows({
|
||||
offset,
|
||||
filter,
|
||||
sort,
|
||||
includeTotal,
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(workspaceId && tableId) && enabled,
|
||||
@@ -393,7 +405,11 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext)
|
||||
r.position >= row.position ? { ...r, position: r.position + 1 } : r
|
||||
)
|
||||
const rows: TableRow[] = [...shifted, row].sort((a, b) => a.position - b.position)
|
||||
return { ...old, rows, totalCount: old.totalCount + 1 }
|
||||
return {
|
||||
...old,
|
||||
rows,
|
||||
totalCount: old.totalCount === null ? null : old.totalCount + 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
interface ProgressiveListOptions {
|
||||
/** Number of items to render in the initial batch (most recent items) */
|
||||
@@ -14,15 +14,31 @@ const DEFAULTS = {
|
||||
batchSize: 5,
|
||||
} satisfies Required<ProgressiveListOptions>
|
||||
|
||||
interface ProgressiveListState {
|
||||
key: string
|
||||
count: number
|
||||
caughtUp: boolean
|
||||
}
|
||||
|
||||
function createInitialState(
|
||||
key: string,
|
||||
itemCount: number,
|
||||
initialBatch: number
|
||||
): ProgressiveListState {
|
||||
const count = Math.min(itemCount, initialBatch)
|
||||
return {
|
||||
key,
|
||||
count,
|
||||
caughtUp: itemCount > 0 && count >= itemCount,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Progressively renders a list of items so that first paint is fast.
|
||||
*
|
||||
* On mount (or when `key` changes), only the most recent `initialBatch`
|
||||
* items are rendered. The rest are added in `batchSize` increments via
|
||||
* `requestAnimationFrame` so the browser never blocks on a large DOM mount.
|
||||
*
|
||||
* Once staging completes for a given key it never re-stages -- new items
|
||||
* appended to the list are rendered immediately.
|
||||
* `requestAnimationFrame`.
|
||||
*
|
||||
* @param items Full list of items to render.
|
||||
* @param key A session/conversation identifier. When it changes,
|
||||
@@ -35,67 +51,83 @@ export function useProgressiveList<T>(
|
||||
key: string,
|
||||
options?: ProgressiveListOptions
|
||||
): { staged: T[]; isStaging: boolean } {
|
||||
const initialBatch = options?.initialBatch ?? DEFAULTS.initialBatch
|
||||
const batchSize = options?.batchSize ?? DEFAULTS.batchSize
|
||||
const initialBatch = Math.max(0, options?.initialBatch ?? DEFAULTS.initialBatch)
|
||||
const batchSize = Math.max(1, options?.batchSize ?? DEFAULTS.batchSize)
|
||||
const [state, setState] = useState(() => createInitialState(key, items.length, initialBatch))
|
||||
const latestItemCountRef = useRef(items.length)
|
||||
|
||||
const completedKeysRef = useRef(new Set<string>())
|
||||
const prevKeyRef = useRef(key)
|
||||
const stagingCountRef = useRef(initialBatch)
|
||||
const [count, setCount] = useState(() => {
|
||||
if (items.length <= initialBatch) return items.length
|
||||
return initialBatch
|
||||
})
|
||||
useLayoutEffect(() => {
|
||||
latestItemCountRef.current = items.length
|
||||
}, [items.length])
|
||||
|
||||
const renderState =
|
||||
state.key === key && (state.count > 0 || items.length === 0 || state.caughtUp)
|
||||
? state
|
||||
: createInitialState(key, items.length, initialBatch)
|
||||
|
||||
useEffect(() => {
|
||||
if (completedKeysRef.current.has(key)) {
|
||||
setCount(items.length)
|
||||
return
|
||||
}
|
||||
|
||||
if (items.length <= initialBatch) {
|
||||
setCount(items.length)
|
||||
completedKeysRef.current.add(key)
|
||||
return
|
||||
}
|
||||
|
||||
let current = Math.max(stagingCountRef.current, initialBatch)
|
||||
setCount(current)
|
||||
|
||||
let frame: number | undefined
|
||||
|
||||
const step = () => {
|
||||
const total = items.length
|
||||
current = Math.min(total, current + batchSize)
|
||||
stagingCountRef.current = current
|
||||
setCount(current)
|
||||
if (current >= total) {
|
||||
completedKeysRef.current.add(key)
|
||||
frame = undefined
|
||||
return
|
||||
setState((prev) => {
|
||||
if (prev.key !== key) {
|
||||
return createInitialState(key, items.length, initialBatch)
|
||||
}
|
||||
frame = requestAnimationFrame(step)
|
||||
|
||||
if (items.length === 0) {
|
||||
if (prev.count === 0 && !prev.caughtUp) {
|
||||
return prev
|
||||
}
|
||||
return { key, count: 0, caughtUp: false }
|
||||
}
|
||||
|
||||
if (prev.caughtUp) {
|
||||
if (prev.count === items.length) {
|
||||
return prev
|
||||
}
|
||||
return { key, count: items.length, caughtUp: true }
|
||||
}
|
||||
|
||||
const minimumCount = Math.min(items.length, initialBatch)
|
||||
if (prev.count >= minimumCount && prev.count <= items.length) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const count = Math.min(items.length, Math.max(prev.count, minimumCount))
|
||||
return {
|
||||
key,
|
||||
count,
|
||||
caughtUp: count >= items.length,
|
||||
}
|
||||
})
|
||||
}, [key, items.length, initialBatch])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.key !== key || state.caughtUp || state.count >= items.length) {
|
||||
return
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(step)
|
||||
const frame = requestAnimationFrame(() => {
|
||||
setState((prev) => {
|
||||
if (prev.key !== key || prev.caughtUp) {
|
||||
return prev
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [key, items.length, initialBatch, batchSize])
|
||||
const itemCount = latestItemCountRef.current
|
||||
const count = Math.min(itemCount, prev.count + batchSize)
|
||||
return {
|
||||
key,
|
||||
count,
|
||||
caughtUp: count >= itemCount,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
let effectiveCount = count
|
||||
if (prevKeyRef.current !== key) {
|
||||
effectiveCount = items.length <= initialBatch ? items.length : initialBatch
|
||||
stagingCountRef.current = initialBatch
|
||||
}
|
||||
prevKeyRef.current = key
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [state.key, state.count, state.caughtUp, key, items.length, batchSize])
|
||||
|
||||
const isCompleted = completedKeysRef.current.has(key)
|
||||
const isStaging = !isCompleted && effectiveCount < items.length
|
||||
const staged =
|
||||
isCompleted || effectiveCount >= items.length
|
||||
? items
|
||||
: items.slice(Math.max(0, items.length - effectiveCount))
|
||||
const effectiveCount = renderState.caughtUp
|
||||
? items.length
|
||||
: Math.min(renderState.count, items.length)
|
||||
const staged = items.slice(Math.max(0, items.length - effectiveCount))
|
||||
const isStaging = effectiveCount < items.length
|
||||
|
||||
return { staged, isStaging }
|
||||
}
|
||||
|
||||
@@ -194,6 +194,64 @@ describe('copilot go stream helpers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retry transient backend statuses because stream requests are not idempotent', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(new Response('bad gateway', { status: 502 }))
|
||||
|
||||
const context = createStreamingContext()
|
||||
const execContext: ExecutionContext = {
|
||||
userId: 'user-1',
|
||||
workflowId: 'workflow-1',
|
||||
}
|
||||
|
||||
await expect(
|
||||
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
|
||||
timeout: 1000,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: 'CopilotBackendError',
|
||||
status: 502,
|
||||
body: 'bad gateway',
|
||||
})
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not retry non-transient backend statuses before the SSE stream opens', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(new Response('limit reached', { status: 402 }))
|
||||
|
||||
const context = createStreamingContext()
|
||||
const execContext: ExecutionContext = {
|
||||
userId: 'user-1',
|
||||
workflowId: 'workflow-1',
|
||||
}
|
||||
|
||||
await expect(
|
||||
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
|
||||
timeout: 1000,
|
||||
})
|
||||
).rejects.toThrow('Usage limit reached')
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not retry network errors because Go may already be executing the request', async () => {
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed'))
|
||||
|
||||
const context = createStreamingContext()
|
||||
const execContext: ExecutionContext = {
|
||||
userId: 'user-1',
|
||||
workflowId: 'workflow-1',
|
||||
}
|
||||
|
||||
await expect(
|
||||
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
|
||||
timeout: 1000,
|
||||
})
|
||||
).rejects.toThrow('fetch failed')
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fails closed when the shared stream ends before a terminal event', async () => {
|
||||
const textEvent = createEvent({
|
||||
streamId: 'stream-1',
|
||||
|
||||
@@ -134,17 +134,27 @@ export async function runStreamLoop(
|
||||
requestBodyBytes,
|
||||
})
|
||||
const fetchStart = performance.now()
|
||||
const response = await fetchGo(fetchUrl, {
|
||||
...fetchOptions,
|
||||
signal: abortSignal,
|
||||
otelContext: options.otelContext,
|
||||
spanName: `sim → go ${pathname}`,
|
||||
operation: 'stream',
|
||||
attributes: {
|
||||
[TraceAttr.CopilotStream]: true,
|
||||
...(requestBodyBytes ? { [TraceAttr.HttpRequestContentLength]: requestBodyBytes } : {}),
|
||||
},
|
||||
})
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchGo(fetchUrl, {
|
||||
...fetchOptions,
|
||||
signal: abortSignal,
|
||||
otelContext: options.otelContext,
|
||||
spanName: `sim → go ${pathname}`,
|
||||
operation: 'stream',
|
||||
attributes: {
|
||||
[TraceAttr.CopilotStream]: true,
|
||||
...(requestBodyBytes ? { [TraceAttr.HttpRequestContentLength]: requestBodyBytes } : {}),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
fetchSpan.attributes = {
|
||||
...(fetchSpan.attributes ?? {}),
|
||||
headersMs: Math.round(performance.now() - fetchStart),
|
||||
}
|
||||
context.trace.endSpan(fetchSpan, abortSignal?.aborted ? 'cancelled' : 'error')
|
||||
throw error
|
||||
}
|
||||
const headersElapsedMs = Math.round(performance.now() - fetchStart)
|
||||
fetchSpan.attributes = {
|
||||
...(fetchSpan.attributes ?? {}),
|
||||
@@ -561,14 +571,14 @@ function stampSseReadLoopSpan(
|
||||
const nowWall = Date.now()
|
||||
const startWall = nowWall - (nowPerf - startPerfMs)
|
||||
|
||||
const terminalEventSeen = counters.eventsByType.complete > 0
|
||||
const terminalEventSeen = counters.eventsByType.complete > 0 || counters.eventsByType.error > 0
|
||||
// `terminal_event_missing` is the single-attribute dashboard signal
|
||||
// for the "disappeared response" bug class: the caller considered
|
||||
// this leg to be the final one (`context.streamComplete === true`)
|
||||
// but no `complete` event arrived on the wire. Tool-pause legs have
|
||||
// expectedTerminal=false and never trip this, so dashboards can
|
||||
// filter on `{ .copilot.sse.terminal_event_missing = true }` without
|
||||
// false positives.
|
||||
// but no terminal `complete` or `error` event arrived on the wire.
|
||||
// Tool-pause legs have expectedTerminal=false and never trip this, so
|
||||
// dashboards can filter on `{ .copilot.sse.terminal_event_missing = true }`
|
||||
// without false positives.
|
||||
const terminalEventMissing = opts.expectedTerminal && !terminalEventSeen
|
||||
|
||||
const tracer = getCopilotTracer()
|
||||
|
||||
@@ -210,6 +210,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
|
||||
|
||||
const abortPoller = startAbortPoller(streamId, abortController, {
|
||||
requestId,
|
||||
chatId,
|
||||
})
|
||||
publisher.startKeepalive()
|
||||
|
||||
|
||||
120
apps/sim/lib/copilot/request/session/abort.test.ts
Normal file
120
apps/sim/lib/copilot/request/session/abort.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockHasAbortMarker, mockClearAbortMarker, mockWriteAbortMarker } = vi.hoisted(() => ({
|
||||
mockHasAbortMarker: vi.fn().mockResolvedValue(false),
|
||||
mockClearAbortMarker: vi.fn().mockResolvedValue(undefined),
|
||||
mockWriteAbortMarker: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
|
||||
vi.mock('@/lib/copilot/request/session/buffer', () => ({
|
||||
hasAbortMarker: mockHasAbortMarker,
|
||||
clearAbortMarker: mockClearAbortMarker,
|
||||
writeAbortMarker: mockWriteAbortMarker,
|
||||
}))
|
||||
vi.mock('@/lib/copilot/request/otel', () => ({
|
||||
withCopilotSpan: (_span: unknown, _attrs: unknown, fn: (span: unknown) => unknown) =>
|
||||
fn({ setAttribute: vi.fn() }),
|
||||
}))
|
||||
|
||||
import { startAbortPoller } from '@/lib/copilot/request/session/abort'
|
||||
|
||||
describe('startAbortPoller heartbeat', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
mockHasAbortMarker.mockResolvedValue(false)
|
||||
redisConfigMockFns.mockExtendLock.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('extends the chat stream lock approximately every heartbeat interval', async () => {
|
||||
const controller = new AbortController()
|
||||
const streamId = 'stream-heartbeat-1'
|
||||
const chatId = 'chat-heartbeat-1'
|
||||
|
||||
const interval = startAbortPoller(streamId, controller, { chatId })
|
||||
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenLastCalledWith(
|
||||
`copilot:chat-stream-lock:${chatId}`,
|
||||
streamId,
|
||||
60
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not extend the lock when no chatId is passed (backward compat)', async () => {
|
||||
const controller = new AbortController()
|
||||
const interval = startAbortPoller('stream-no-chat', controller, {})
|
||||
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(90_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
it('retries on the next tick when extendLock throws (no 20s backoff)', async () => {
|
||||
const controller = new AbortController()
|
||||
const streamId = 'stream-retry'
|
||||
const chatId = 'chat-retry'
|
||||
|
||||
redisConfigMockFns.mockExtendLock.mockRejectedValueOnce(new Error('redis down'))
|
||||
|
||||
const interval = startAbortPoller(streamId, controller, { chatId })
|
||||
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
it('stops heartbeating after ownership is lost', async () => {
|
||||
const controller = new AbortController()
|
||||
const streamId = 'stream-lost'
|
||||
const chatId = 'chat-lost'
|
||||
|
||||
redisConfigMockFns.mockExtendLock.mockResolvedValueOnce(false)
|
||||
|
||||
const interval = startAbortPoller(streamId, controller, { chatId })
|
||||
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(21_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { AbortBackend } from '@/lib/copilot/generated/trace-attribute-values-v1'
|
||||
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
|
||||
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
|
||||
import { withCopilotSpan } from '@/lib/copilot/request/otel'
|
||||
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
||||
import { acquireLock, extendLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
||||
import { AbortReason } from './abort-reason'
|
||||
import { clearAbortMarker, hasAbortMarker, writeAbortMarker } from './buffer'
|
||||
|
||||
@@ -18,7 +18,22 @@ const pendingChatStreams = new Map<
|
||||
>()
|
||||
|
||||
const DEFAULT_ABORT_POLL_MS = 1000
|
||||
const CHAT_STREAM_LOCK_TTL_SECONDS = 2 * 60 * 60
|
||||
|
||||
/**
|
||||
* TTL for the per-chat stream lock. Kept short so that if the Sim pod
|
||||
* holding the lock dies (SIGKILL, OOM, a SIGTERM drain that doesn't
|
||||
* reach the release path), the lock self-heals inside a minute rather
|
||||
* than stranding the chat for hours. A live stream keeps the lock alive
|
||||
* via `CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS` heartbeats.
|
||||
*/
|
||||
const CHAT_STREAM_LOCK_TTL_SECONDS = 60
|
||||
|
||||
/**
|
||||
* Heartbeat cadence for extending the per-chat stream lock. Set to a
|
||||
* third of the TTL so one missed beat still leaves room for recovery
|
||||
* before the lock expires under a still-live stream.
|
||||
*/
|
||||
const CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS = 20_000
|
||||
|
||||
function registerPendingChatStream(chatId: string, streamId: string): void {
|
||||
let resolve!: () => void
|
||||
@@ -262,10 +277,14 @@ const pollingStreams = new Set<string>()
|
||||
export function startAbortPoller(
|
||||
streamId: string,
|
||||
abortController: AbortController,
|
||||
options?: { pollMs?: number; requestId?: string }
|
||||
options?: { pollMs?: number; requestId?: string; chatId?: string }
|
||||
): ReturnType<typeof setInterval> {
|
||||
const pollMs = options?.pollMs ?? DEFAULT_ABORT_POLL_MS
|
||||
const requestId = options?.requestId
|
||||
const chatId = options?.chatId
|
||||
|
||||
let lastHeartbeatAt = Date.now()
|
||||
let heartbeatOwnershipLost = false
|
||||
|
||||
return setInterval(() => {
|
||||
if (pollingStreams.has(streamId)) return
|
||||
@@ -287,6 +306,33 @@ export function startAbortPoller(
|
||||
} finally {
|
||||
pollingStreams.delete(streamId)
|
||||
}
|
||||
|
||||
if (!chatId || heartbeatOwnershipLost) return
|
||||
if (Date.now() - lastHeartbeatAt < CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS) return
|
||||
|
||||
try {
|
||||
const owned = await extendLock(
|
||||
getChatStreamLockKey(chatId),
|
||||
streamId,
|
||||
CHAT_STREAM_LOCK_TTL_SECONDS
|
||||
)
|
||||
lastHeartbeatAt = Date.now()
|
||||
if (!owned) {
|
||||
heartbeatOwnershipLost = true
|
||||
logger.warn('Lost ownership of chat stream lock — stopping heartbeat', {
|
||||
chatId,
|
||||
streamId,
|
||||
...(requestId ? { requestId } : {}),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to extend chat stream lock TTL', {
|
||||
chatId,
|
||||
streamId,
|
||||
...(requestId ? { requestId } : {}),
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
})()
|
||||
}, pollMs)
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ function isStreamRef(value: unknown): value is MothershipStreamV1StreamRef {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.streamId === 'string' &&
|
||||
value.streamId.length > 0 &&
|
||||
isOptionalString(value.chatId) &&
|
||||
isOptionalString(value.cursor)
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('ioredis', () => ({
|
||||
|
||||
import {
|
||||
closeRedisConnection,
|
||||
extendLock,
|
||||
getRedisClient,
|
||||
onRedisReconnect,
|
||||
resetForTesting,
|
||||
@@ -120,6 +121,48 @@ describe('redis config', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('extendLock', () => {
|
||||
const lockKey = 'copilot:chat-stream-lock:chat-1'
|
||||
const value = 'stream-abc'
|
||||
const ttlSeconds = 60
|
||||
|
||||
it('returns true when the caller still owns the lock and EXPIRE succeeds', async () => {
|
||||
mockRedisInstance.eval.mockResolvedValueOnce(1)
|
||||
|
||||
const extended = await extendLock(lockKey, value, ttlSeconds)
|
||||
|
||||
expect(extended).toBe(true)
|
||||
expect(mockRedisInstance.eval).toHaveBeenCalledWith(
|
||||
expect.stringContaining('expire'),
|
||||
1,
|
||||
lockKey,
|
||||
value,
|
||||
ttlSeconds
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when the value does not match (lock owned by another)', async () => {
|
||||
mockRedisInstance.eval.mockResolvedValueOnce(0)
|
||||
|
||||
const extended = await extendLock(lockKey, value, ttlSeconds)
|
||||
|
||||
expect(extended).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true as a no-op when Redis is unavailable', async () => {
|
||||
vi.resetModules()
|
||||
vi.doMock('@/lib/core/config/env', () =>
|
||||
createEnvMock({ REDIS_URL: undefined as unknown as string })
|
||||
)
|
||||
const { extendLock: extendLockNoRedis } = await import('@/lib/core/config/redis')
|
||||
|
||||
const extended = await extendLockNoRedis(lockKey, value, ttlSeconds)
|
||||
|
||||
expect(extended).toBe(true)
|
||||
vi.doUnmock('@/lib/core/config/env')
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryStrategy', () => {
|
||||
function captureRetryStrategy(): (times: number) => number {
|
||||
let capturedConfig: Record<string, unknown> = {}
|
||||
|
||||
@@ -136,6 +136,21 @@ else
|
||||
end
|
||||
`
|
||||
|
||||
/**
|
||||
* Lua script for safe lock TTL extension.
|
||||
* Only refreshes the expiry if the value matches (ownership verification),
|
||||
* so a stale heartbeat from a prior owner cannot extend a lock currently
|
||||
* held by someone else after a TTL eviction.
|
||||
* Returns 1 if the TTL was extended, 0 if not (value mismatch or key gone).
|
||||
*/
|
||||
const EXTEND_LOCK_SCRIPT = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("expire", KEYS[1], ARGV[2])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
|
||||
/**
|
||||
* Acquire a distributed lock using Redis SET NX.
|
||||
* Returns true if lock acquired, false if already held.
|
||||
@@ -175,6 +190,29 @@ export async function releaseLock(lockKey: string, value: string): Promise<boole
|
||||
return result === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the TTL of a distributed lock if still owned by the caller.
|
||||
* Returns true if the caller still owns the lock and the TTL was refreshed,
|
||||
* false if the lock has been taken over by another owner or has expired.
|
||||
*
|
||||
* When Redis is not available, returns true (no-op) to match the behavior
|
||||
* of `acquireLock` / `releaseLock`: single-replica deployments without
|
||||
* Redis never held a real lock, so heartbeat success is implicit.
|
||||
*/
|
||||
export async function extendLock(
|
||||
lockKey: string,
|
||||
value: string,
|
||||
expirySeconds: number
|
||||
): Promise<boolean> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
return true
|
||||
}
|
||||
|
||||
const result = await redis.eval(EXTEND_LOCK_SCRIPT, 1, lockKey, value, expirySeconds)
|
||||
return result === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the Redis connection.
|
||||
* Use for graceful shutdown.
|
||||
|
||||
@@ -3,11 +3,58 @@
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { updateRow } from '@/lib/table/service'
|
||||
import {
|
||||
batchInsertRows,
|
||||
deleteColumn,
|
||||
insertRow,
|
||||
renameColumn,
|
||||
replaceTableRows,
|
||||
updateRow,
|
||||
upsertRow,
|
||||
} from '@/lib/table/service'
|
||||
import type { TableDefinition } from '@/lib/table/types'
|
||||
import { getUniqueColumns } from '@/lib/table/validation'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
vi.mock('@/lib/table/validation', () => ({
|
||||
validateRowSize: vi.fn(() => ({ valid: true, errors: [] })),
|
||||
validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })),
|
||||
validateTableName: vi.fn(() => ({ valid: true, errors: [] })),
|
||||
validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })),
|
||||
getUniqueColumns: vi.fn(() => []),
|
||||
checkUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })),
|
||||
checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Inspects the queued `trx.execute(...)` calls for SQL containing `substring`.
|
||||
* Works with both `sql\`...\`` (produces `{ strings, values }`) and `sql.raw(...)`
|
||||
* (produces `{ rawSql }`) from the global drizzle mock.
|
||||
*/
|
||||
function findExecutedSqlContaining(substring: string): boolean {
|
||||
return dbChainMockFns.execute.mock.calls.some(([arg]) => {
|
||||
if (!arg || typeof arg !== 'object') return false
|
||||
const a = arg as Record<string, unknown>
|
||||
if (Array.isArray(a.strings)) {
|
||||
return (a.strings as string[]).some((s) => typeof s === 'string' && s.includes(substring))
|
||||
}
|
||||
if (typeof a.rawSql === 'string') {
|
||||
return (a.rawSql as string).includes(substring)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function findExecutedRawSql(substring: string): string | undefined {
|
||||
for (const [arg] of dbChainMockFns.execute.mock.calls) {
|
||||
if (!arg || typeof arg !== 'object') continue
|
||||
const raw = (arg as { rawSql?: unknown }).rawSql
|
||||
if (typeof raw === 'string' && raw.includes(substring)) return raw
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const EXISTING_ROW = {
|
||||
id: 'row-1',
|
||||
tableId: 'tbl-1',
|
||||
@@ -106,3 +153,289 @@ describe('updateRow — partial merge', () => {
|
||||
).rejects.toThrow('Row not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertRow — position race safety (migration 0198 + advisory lock)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
resetDbChainMock()
|
||||
vi.mocked(getUniqueColumns).mockReturnValue([])
|
||||
})
|
||||
|
||||
it('auto-position inserts acquire the per-table advisory lock before reading max(position)', async () => {
|
||||
await expect(
|
||||
insertRow({ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1' }, TABLE, 'req-1')
|
||||
).rejects.toBeDefined()
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
expect(findExecutedSqlContaining('hashtextextended')).toBe(true)
|
||||
})
|
||||
|
||||
it('explicit-position inserts also acquire the advisory lock to serialize position shifts', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'row-1',
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'a' },
|
||||
position: 5,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
])
|
||||
|
||||
await insertRow(
|
||||
{ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1', position: 5 },
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
// `(table_id, position)` index is non-unique, so concurrent explicit-position
|
||||
// inserts at the same slot could both skip the shift and duplicate — lock
|
||||
// serializes them.
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
})
|
||||
|
||||
it('batchInsertRows acquires the advisory lock (always auto-positioned)', async () => {
|
||||
await expect(
|
||||
batchInsertRows(
|
||||
{ tableId: 'tbl-1', rows: [{ name: 'a' }, { name: 'b' }], workspaceId: 'ws-1' },
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
).rejects.toBeDefined()
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
})
|
||||
|
||||
it('batchInsertRows with explicit positions acquires the advisory lock', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'row-1',
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'a' },
|
||||
position: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'row-2',
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'b' },
|
||||
position: 4,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
])
|
||||
|
||||
await batchInsertRows(
|
||||
{
|
||||
tableId: 'tbl-1',
|
||||
rows: [{ name: 'a' }, { name: 'b' }],
|
||||
workspaceId: 'ws-1',
|
||||
positions: [3, 4],
|
||||
},
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
})
|
||||
|
||||
it('upsertRow skips the advisory lock on the update path (match found)', async () => {
|
||||
vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'row-1',
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Alice', age: 30 },
|
||||
position: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
])
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'row-1',
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Alice', age: 31 },
|
||||
position: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
])
|
||||
|
||||
await upsertRow(
|
||||
{
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Alice', age: 31 },
|
||||
conflictTarget: 'name',
|
||||
},
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(false)
|
||||
})
|
||||
|
||||
it('upsertRow acquires the advisory lock on the insert path (no match)', async () => {
|
||||
vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }])
|
||||
// Initial existing-row check + post-lock re-check both find no match.
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
|
||||
await expect(
|
||||
upsertRow(
|
||||
{
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Bob', age: 25 },
|
||||
conflictTarget: 'name',
|
||||
},
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
).rejects.toBeDefined()
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
})
|
||||
|
||||
it('upsertRow re-checks after acquiring the lock and switches to UPDATE when a racing tx inserted the row', async () => {
|
||||
vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }])
|
||||
// Initial existing-row check: no match (another tx has not committed yet).
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
// Post-lock re-check: a racing tx just inserted the row.
|
||||
const racedRow = {
|
||||
id: 'row-raced',
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Bob', age: 25 },
|
||||
position: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([racedRow])
|
||||
// UPDATE returning the patched row.
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||||
{ ...racedRow, data: { name: 'Bob', age: 26 } },
|
||||
])
|
||||
|
||||
const result = await upsertRow(
|
||||
{
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Bob', age: 26 },
|
||||
conflictTarget: 'name',
|
||||
},
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
expect(result.operation).toBe('update')
|
||||
expect(result.row.id).toBe('row-raced')
|
||||
expect(dbChainMockFns.update).toHaveBeenCalled()
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutation paths — SET LOCAL timeouts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('insertRow sets the default 10s/3s/5s timeouts', async () => {
|
||||
await expect(
|
||||
insertRow({ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1' }, TABLE, 'req-1')
|
||||
).rejects.toBeDefined()
|
||||
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '10000ms'")).toBeDefined()
|
||||
expect(findExecutedRawSql("SET LOCAL lock_timeout = '3000ms'")).toBeDefined()
|
||||
expect(
|
||||
findExecutedRawSql("SET LOCAL idle_in_transaction_session_timeout = '5000ms'")
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('batchInsertRows raises statement_timeout to 60s', async () => {
|
||||
await expect(
|
||||
batchInsertRows(
|
||||
{ tableId: 'tbl-1', rows: [{ name: 'a' }], workspaceId: 'ws-1' },
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
).rejects.toBeDefined()
|
||||
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '60000ms'")).toBeDefined()
|
||||
})
|
||||
|
||||
it('replaceTableRows scales statement_timeout with (existing + new) row count', async () => {
|
||||
const bigTable: TableDefinition = { ...TABLE, rowCount: 100_000, maxRows: 1_000_000 }
|
||||
const payload = Array.from({ length: 50_000 }, (_, i) => ({ name: `row-${i}` }))
|
||||
|
||||
await replaceTableRows(
|
||||
{ tableId: 'tbl-1', workspaceId: 'ws-1', rows: payload },
|
||||
bigTable,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
// (100_000 + 50_000) × 3ms/row = 450_000ms; above 120_000 floor, below 600_000 cap
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '450000ms'")).toBeDefined()
|
||||
})
|
||||
|
||||
it('replaceTableRows caps scaled timeout at 10 minutes for very large tables', async () => {
|
||||
const hugeTable: TableDefinition = { ...TABLE, rowCount: 10_000_000, maxRows: 20_000_000 }
|
||||
|
||||
await replaceTableRows({ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [] }, hugeTable, 'req-1')
|
||||
|
||||
// 10M × 3ms = 30M ms, capped at 600_000ms (10 min)
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '600000ms'")).toBeDefined()
|
||||
})
|
||||
|
||||
it('replaceTableRows uses the 120s floor on small tables', async () => {
|
||||
const smallTable: TableDefinition = { ...TABLE, rowCount: 10 }
|
||||
|
||||
await replaceTableRows(
|
||||
{ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [{ name: 'a' }, { name: 'b' }] },
|
||||
smallTable,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
// 12 × 3ms = 36ms → floored at 120_000ms
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '120000ms'")).toBeDefined()
|
||||
})
|
||||
|
||||
it('renameColumn scales statement_timeout with table.rowCount', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ ...TABLE, rowCount: 500_000 }])
|
||||
|
||||
await renameColumn({ tableId: 'tbl-1', oldName: 'name', newName: 'full_name' }, 'req-1')
|
||||
|
||||
// 500_000 × 2ms = 1_000_000 → capped at 600_000
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '600000ms'")).toBeDefined()
|
||||
})
|
||||
|
||||
it('deleteColumn uses the 60s floor on small tables', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ ...TABLE, rowCount: 100 }])
|
||||
|
||||
await deleteColumn({ tableId: 'tbl-1', columnName: 'age' }, 'req-1')
|
||||
|
||||
// 100 × 2ms = 200ms → floored at 60_000ms
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '60000ms'")).toBeDefined()
|
||||
})
|
||||
|
||||
it('replaceTableRows acquires the per-table advisory lock to serialize concurrent replaces', async () => {
|
||||
await replaceTableRows(
|
||||
{ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [{ name: 'a' }] },
|
||||
{ ...TABLE, rowCount: 5 },
|
||||
'req-1'
|
||||
)
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
expect(findExecutedSqlContaining('hashtextextended')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,6 +64,80 @@ export class TableConflictError extends Error {
|
||||
|
||||
export type TableScope = 'active' | 'archived' | 'all'
|
||||
|
||||
type DbTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||
|
||||
/**
|
||||
* Sets per-transaction Postgres timeouts via `SET LOCAL`.
|
||||
*
|
||||
* `lock_timeout` is the critical one: without it, a waiter inherits the full
|
||||
* `statement_timeout` clock, so one stuck writer can drain the pool.
|
||||
*
|
||||
* Safe under pgBouncer transaction pooling — `SET LOCAL` is transaction-scoped
|
||||
* and cleared at COMMIT/ROLLBACK before the session returns to the pool.
|
||||
*/
|
||||
async function setTableTxTimeouts(
|
||||
trx: DbTransaction,
|
||||
opts?: { statementMs?: number; lockMs?: number; idleMs?: number }
|
||||
) {
|
||||
const s = opts?.statementMs ?? 10_000
|
||||
const l = opts?.lockMs ?? 3_000
|
||||
const i = opts?.idleMs ?? 5_000
|
||||
await trx.execute(sql.raw(`SET LOCAL statement_timeout = '${s}ms'`))
|
||||
await trx.execute(sql.raw(`SET LOCAL lock_timeout = '${l}ms'`))
|
||||
await trx.execute(sql.raw(`SET LOCAL idle_in_transaction_session_timeout = '${i}ms'`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes writers that compute `max(position) + 1` for the same table.
|
||||
*
|
||||
* The row-count trigger (migration 0198) serializes capacity via a row lock on
|
||||
* `user_table_definitions` — but it fires AFTER INSERT, so two concurrent
|
||||
* auto-positioned inserts can read the same snapshot and assign the same
|
||||
* position (the `(table_id, position)` index is non-unique). This advisory
|
||||
* lock restores the pre-trigger serialization scoped to a single table, with
|
||||
* no cross-table contention. Released automatically at COMMIT/ROLLBACK.
|
||||
*/
|
||||
async function acquireTablePositionLock(trx: DbTransaction, tableId: string) {
|
||||
await trx.execute(
|
||||
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_rows_pos:${tableId}`}, 0))`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next auto-assigned `position` for a table (max(position) + 1, or 0
|
||||
* if empty). Callers must hold `acquireTablePositionLock` to avoid two concurrent
|
||||
* writers computing the same value against the same snapshot.
|
||||
*/
|
||||
async function nextAutoPosition(trx: DbTransaction, tableId: string): Promise<number> {
|
||||
const [{ maxPos }] = await trx
|
||||
.select({
|
||||
maxPos: sql<number>`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number),
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(eq(userTableRows.tableId, tableId))
|
||||
return maxPos + 1
|
||||
}
|
||||
|
||||
const TIMEOUT_CAP_MS = 10 * 60_000
|
||||
|
||||
/**
|
||||
* Scales `statement_timeout` to the expected row-count work.
|
||||
*
|
||||
* Bulk operations that rewrite JSONB or cascade row triggers (e.g.
|
||||
* `replaceTableRows`, `deleteColumn`, `renameColumn`) scale roughly linearly
|
||||
* with row count. A fixed cap would regress large-table users who never saw a
|
||||
* timeout before `SET LOCAL` was introduced. This helper picks
|
||||
* `max(baseMs, rowCount * perRowMs)`, capped at 10 minutes so a single
|
||||
* runaway transaction cannot indefinitely pin a pool connection.
|
||||
*/
|
||||
function scaledStatementTimeoutMs(
|
||||
rowCount: number,
|
||||
opts: { baseMs: number; perRowMs: number }
|
||||
): number {
|
||||
const safeRowCount = Math.max(0, rowCount)
|
||||
return Math.min(TIMEOUT_CAP_MS, Math.max(opts.baseMs, safeRowCount * opts.perRowMs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a table by ID with full details.
|
||||
*
|
||||
@@ -88,16 +162,14 @@ export async function getTableById(
|
||||
archivedAt: userTableDefinitions.archivedAt,
|
||||
createdAt: userTableDefinitions.createdAt,
|
||||
updatedAt: userTableDefinitions.updatedAt,
|
||||
rowCount: sql<number>`coalesce(${count(userTableRows.id)}, 0)`.mapWith(Number),
|
||||
rowCount: userTableDefinitions.rowCount,
|
||||
})
|
||||
.from(userTableDefinitions)
|
||||
.leftJoin(userTableRows, eq(userTableRows.tableId, userTableDefinitions.id))
|
||||
.where(
|
||||
includeArchived
|
||||
? eq(userTableDefinitions.id, tableId)
|
||||
: and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt))
|
||||
)
|
||||
.groupBy(userTableDefinitions.id)
|
||||
.limit(1)
|
||||
|
||||
if (results.length === 0) return null
|
||||
@@ -156,10 +228,9 @@ export async function listTables(
|
||||
archivedAt: userTableDefinitions.archivedAt,
|
||||
createdAt: userTableDefinitions.createdAt,
|
||||
updatedAt: userTableDefinitions.updatedAt,
|
||||
rowCount: sql<number>`coalesce(${count(userTableRows.id)}, 0)`.mapWith(Number),
|
||||
rowCount: userTableDefinitions.rowCount,
|
||||
})
|
||||
.from(userTableDefinitions)
|
||||
.leftJoin(userTableRows, eq(userTableRows.tableId, userTableDefinitions.id))
|
||||
.where(
|
||||
scope === 'all'
|
||||
? eq(userTableDefinitions.workspaceId, workspaceId)
|
||||
@@ -173,7 +244,6 @@ export async function listTables(
|
||||
isNull(userTableDefinitions.archivedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(userTableDefinitions.id)
|
||||
.orderBy(userTableDefinitions.createdAt)
|
||||
|
||||
return tables.map((t) => ({
|
||||
@@ -240,6 +310,7 @@ export async function createTable(
|
||||
// to prevent TOCTOU race on the table count limit
|
||||
try {
|
||||
await db.transaction(async (trx) => {
|
||||
await setTableTxTimeouts(trx)
|
||||
await trx.execute(sql`SELECT 1 FROM workspace WHERE id = ${data.workspaceId} FOR UPDATE`)
|
||||
|
||||
const [{ count: existingCount }] = await trx
|
||||
@@ -510,6 +581,7 @@ export async function restoreTable(tableId: string, requestId: string): Promise<
|
||||
attemptedRestoreName = ''
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await setTableTxTimeouts(tx)
|
||||
await tx.execute(sql`SELECT 1 FROM user_table_definitions WHERE id = ${tableId} FOR UPDATE`)
|
||||
|
||||
attemptedRestoreName = await generateRestoreName(table.name, async (candidate) => {
|
||||
@@ -585,25 +657,22 @@ export async function insertRow(
|
||||
const rowId = `row_${generateId().replace(/-/g, '')}`
|
||||
const now = new Date()
|
||||
|
||||
// Atomic capacity check + insert inside a transaction.
|
||||
// FOR UPDATE on the table definition row serializes concurrent inserts,
|
||||
// preventing the TOCTOU race where multiple requests pass the count check.
|
||||
// Capacity enforcement lives in the `increment_user_table_row_count` trigger
|
||||
// (migration 0198): a single conditional UPDATE on user_table_definitions
|
||||
// increments row_count iff row_count < max_rows, taking the row lock
|
||||
// atomically. No app-level FOR UPDATE / COUNT needed.
|
||||
const [row] = await db.transaction(async (trx) => {
|
||||
await trx.execute(
|
||||
sql`SELECT 1 FROM user_table_definitions WHERE id = ${data.tableId} FOR UPDATE`
|
||||
)
|
||||
|
||||
const [{ count: currentCount }] = await trx
|
||||
.select({ count: count() })
|
||||
.from(userTableRows)
|
||||
.where(eq(userTableRows.tableId, data.tableId))
|
||||
|
||||
if (Number(currentCount) >= table.maxRows) {
|
||||
throw new Error(`Table has reached maximum row limit (${table.maxRows})`)
|
||||
}
|
||||
await setTableTxTimeouts(trx)
|
||||
|
||||
let targetPosition: number
|
||||
|
||||
// The `(table_id, position)` index is non-unique, so we serialize all
|
||||
// position-aware writes (explicit and auto) through the per-table
|
||||
// advisory lock. Without this, two concurrent explicit-position inserts
|
||||
// at the same position can both observe an empty slot, both skip the
|
||||
// shift, and each INSERT a row with a duplicate `(table_id, position)`.
|
||||
await acquireTablePositionLock(trx, data.tableId)
|
||||
|
||||
if (data.position !== undefined) {
|
||||
targetPosition = data.position
|
||||
|
||||
@@ -627,14 +696,7 @@ export async function insertRow(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const [{ maxPos }] = await trx
|
||||
.select({
|
||||
maxPos: sql<number>`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number),
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(eq(userTableRows.tableId, data.tableId))
|
||||
|
||||
targetPosition = maxPos + 1
|
||||
targetPosition = await nextAutoPosition(trx, data.tableId)
|
||||
}
|
||||
|
||||
return trx
|
||||
@@ -706,24 +768,12 @@ export async function batchInsertRows(
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Atomic capacity check + insert inside a transaction.
|
||||
// FOR UPDATE on the table definition row serializes concurrent inserts.
|
||||
// Capacity enforcement lives in the `increment_user_table_row_count` trigger
|
||||
// (migration 0198) — fires per row and raises `Maximum row limit (%) reached ...`
|
||||
// if the cap is hit mid-batch. The outer transaction means a partial batch
|
||||
// rolls back cleanly.
|
||||
const insertedRows = await db.transaction(async (trx) => {
|
||||
await trx.execute(
|
||||
sql`SELECT 1 FROM user_table_definitions WHERE id = ${data.tableId} FOR UPDATE`
|
||||
)
|
||||
|
||||
const [{ count: currentCount }] = await trx
|
||||
.select({ count: count() })
|
||||
.from(userTableRows)
|
||||
.where(eq(userTableRows.tableId, data.tableId))
|
||||
|
||||
const remainingCapacity = table.maxRows - Number(currentCount)
|
||||
if (remainingCapacity < data.rows.length) {
|
||||
throw new Error(
|
||||
`Insufficient capacity. Can only insert ${remainingCapacity} more rows (table has ${Number(currentCount)}/${table.maxRows} rows)`
|
||||
)
|
||||
}
|
||||
await setTableTxTimeouts(trx, { statementMs: 60_000 })
|
||||
|
||||
const buildRow = (rowData: RowData, position: number) => ({
|
||||
id: `row_${generateId().replace(/-/g, '')}`,
|
||||
@@ -736,6 +786,10 @@ export async function batchInsertRows(
|
||||
...(data.userId ? { createdBy: data.userId } : {}),
|
||||
})
|
||||
|
||||
// Serialize position-aware writes per-table. See `acquireTablePositionLock`
|
||||
// for why both explicit- and auto-position paths take this lock.
|
||||
await acquireTablePositionLock(trx, data.tableId)
|
||||
|
||||
if (data.positions && data.positions.length > 0) {
|
||||
// Position-aware insert: shift existing rows to create gaps, then insert.
|
||||
// Process positions ascending so each shift preserves gaps created by prior shifts.
|
||||
@@ -755,14 +809,8 @@ export async function batchInsertRows(
|
||||
return trx.insert(userTableRows).values(rowsToInsert).returning()
|
||||
}
|
||||
|
||||
const [{ maxPos }] = await trx
|
||||
.select({
|
||||
maxPos: sql<number>`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number),
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(eq(userTableRows.tableId, data.tableId))
|
||||
|
||||
const rowsToInsert = data.rows.map((rowData, i) => buildRow(rowData, maxPos + 1 + i))
|
||||
const startPos = await nextAutoPosition(trx, data.tableId)
|
||||
const rowsToInsert = data.rows.map((rowData, i) => buildRow(rowData, startPos + i))
|
||||
|
||||
return trx.insert(userTableRows).values(rowsToInsert).returning()
|
||||
})
|
||||
@@ -849,10 +897,21 @@ export async function replaceTableRows(
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const totalRowWork = Math.max(0, table.rowCount ?? 0) + data.rows.length
|
||||
const statementMs = scaledStatementTimeoutMs(totalRowWork, {
|
||||
baseMs: 120_000,
|
||||
perRowMs: 3,
|
||||
})
|
||||
|
||||
const result = await db.transaction(async (trx) => {
|
||||
await trx.execute(
|
||||
sql`SELECT 1 FROM user_table_definitions WHERE id = ${data.tableId} FOR UPDATE`
|
||||
)
|
||||
await setTableTxTimeouts(trx, { statementMs })
|
||||
|
||||
// Serialize concurrent replaces (and concurrent auto-position inserts) on the
|
||||
// same table. Without this, two concurrent replaces each see their own MVCC
|
||||
// snapshot for the DELETE; the second's DELETE would not observe rows the
|
||||
// first inserted, so both transactions commit and the table ends up with
|
||||
// the union of both row sets instead of only the last caller's rows.
|
||||
await acquireTablePositionLock(trx, data.tableId)
|
||||
|
||||
const deletedRows = await trx
|
||||
.delete(userTableRows)
|
||||
@@ -897,8 +956,11 @@ export async function replaceTableRows(
|
||||
* column, otherwise inserts a new row.
|
||||
*
|
||||
* Uses a single unique column for matching (not OR across all unique columns) to avoid
|
||||
* ambiguous matches when multiple unique columns exist. Capacity checks run inside the
|
||||
* transaction with a FOR UPDATE lock to prevent TOCTOU races.
|
||||
* ambiguous matches when multiple unique columns exist. Capacity enforcement lives
|
||||
* in the `increment_user_table_row_count` trigger (migration 0198). On the insert
|
||||
* path we acquire the per-table advisory lock and re-check for an existing match
|
||||
* before inserting, so a concurrent upsert racing on the same conflict target
|
||||
* cannot produce a duplicate row.
|
||||
*
|
||||
* @param data - Upsert data including optional conflictTarget
|
||||
* @param table - Table definition
|
||||
@@ -965,12 +1027,10 @@ export async function upsertRow(
|
||||
? sql`${userTableRows.data}->>${sql.raw(`'${targetColumnName}'`)} = ${String(targetValue)}`
|
||||
: sql`(${userTableRows.data}->${sql.raw(`'${targetColumnName}'`)})::jsonb = ${JSON.stringify(targetValue)}::jsonb`
|
||||
|
||||
// Entire upsert runs in a transaction with FOR UPDATE lock on the table definition.
|
||||
// This serializes concurrent upserts and prevents the TOCTOU race on row count.
|
||||
// Capacity enforcement for the insert path lives in the `increment_user_table_row_count`
|
||||
// trigger (migration 0198). The update path doesn't change row_count, so no check needed.
|
||||
const result = await db.transaction(async (trx) => {
|
||||
await trx.execute(
|
||||
sql`SELECT 1 FROM user_table_definitions WHERE id = ${data.tableId} FOR UPDATE`
|
||||
)
|
||||
await setTableTxTimeouts(trx)
|
||||
|
||||
// Find existing row by single conflict target column
|
||||
const [existingRow] = await trx
|
||||
@@ -998,14 +1058,33 @@ export async function upsertRow(
|
||||
|
||||
const now = new Date()
|
||||
|
||||
if (existingRow) {
|
||||
// Resolve which row (if any) we should update. If the initial SELECT missed,
|
||||
// acquire the lock and re-check — a concurrent upsert may have inserted the
|
||||
// matching row between our SELECT and the INSERT path, and without the
|
||||
// re-check both transactions would insert and produce a duplicate that
|
||||
// bypasses the app-level unique check.
|
||||
let matchedRowId = existingRow?.id
|
||||
if (!matchedRowId) {
|
||||
await acquireTablePositionLock(trx, data.tableId)
|
||||
const [racedRow] = await trx
|
||||
.select({ id: userTableRows.id })
|
||||
.from(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.tableId, data.tableId),
|
||||
eq(userTableRows.workspaceId, data.workspaceId),
|
||||
matchFilter
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
matchedRowId = racedRow?.id
|
||||
}
|
||||
|
||||
if (matchedRowId) {
|
||||
const [updatedRow] = await trx
|
||||
.update(userTableRows)
|
||||
.set({
|
||||
data: data.data,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(userTableRows.id, existingRow.id))
|
||||
.set({ data: data.data, updatedAt: now })
|
||||
.where(eq(userTableRows.id, matchedRowId))
|
||||
.returning()
|
||||
|
||||
return {
|
||||
@@ -1020,23 +1099,6 @@ export async function upsertRow(
|
||||
}
|
||||
}
|
||||
|
||||
// Check capacity atomically (inside the lock)
|
||||
const [{ count: currentCount }] = await trx
|
||||
.select({ count: count() })
|
||||
.from(userTableRows)
|
||||
.where(eq(userTableRows.tableId, data.tableId))
|
||||
|
||||
if (Number(currentCount) >= table.maxRows) {
|
||||
throw new Error(`Table row limit reached (${table.maxRows} rows max)`)
|
||||
}
|
||||
|
||||
const [{ maxPos }] = await trx
|
||||
.select({
|
||||
maxPos: sql<number>`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number),
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(eq(userTableRows.tableId, data.tableId))
|
||||
|
||||
const [insertedRow] = await trx
|
||||
.insert(userTableRows)
|
||||
.values({
|
||||
@@ -1044,7 +1106,7 @@ export async function upsertRow(
|
||||
tableId: data.tableId,
|
||||
workspaceId: data.workspaceId,
|
||||
data: data.data,
|
||||
position: maxPos + 1,
|
||||
position: await nextAutoPosition(trx, data.tableId),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...(data.userId ? { createdBy: data.userId } : {}),
|
||||
@@ -1073,6 +1135,14 @@ export async function upsertRow(
|
||||
/**
|
||||
* Queries rows from a table with filtering, sorting, and pagination.
|
||||
*
|
||||
* Filter cost model: equality filters (`$eq`, `$in`) compile to JSONB
|
||||
* containment (`@>`) and hit the GIN (jsonb_path_ops) index on
|
||||
* `user_table_rows.data`. Range operators (`$gt`, `$gte`, `$lt`, `$lte`) and
|
||||
* `$contains` compile to `data->>'field'` text extraction and bypass the GIN
|
||||
* index — they fall back to a sequential scan of the rows for the table
|
||||
* (bounded only by the btree on `table_id`). Prefer equality on hot paths; set
|
||||
* `includeTotal: false` when the caller does not need the `COUNT(*)`.
|
||||
*
|
||||
* @param tableId - Table ID to query
|
||||
* @param workspaceId - Workspace ID for access control
|
||||
* @param options - Query options (filter, sort, limit, offset)
|
||||
@@ -1085,7 +1155,13 @@ export async function queryRows(
|
||||
options: QueryOptions,
|
||||
requestId: string
|
||||
): Promise<QueryResult> {
|
||||
const { filter, sort, limit = TABLE_LIMITS.DEFAULT_QUERY_LIMIT, offset = 0 } = options
|
||||
const {
|
||||
filter,
|
||||
sort,
|
||||
limit = TABLE_LIMITS.DEFAULT_QUERY_LIMIT,
|
||||
offset = 0,
|
||||
includeTotal = true,
|
||||
} = options
|
||||
|
||||
const tableName = USER_TABLE_ROWS_SQL_NAME
|
||||
|
||||
@@ -1103,13 +1179,14 @@ export async function queryRows(
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const countResult = await db
|
||||
.select({ count: count() })
|
||||
.from(userTableRows)
|
||||
.where(whereClause ?? baseConditions)
|
||||
|
||||
const totalCount = Number(countResult[0].count)
|
||||
let totalCount: number | null = null
|
||||
if (includeTotal) {
|
||||
const countResult = await db
|
||||
.select({ count: count() })
|
||||
.from(userTableRows)
|
||||
.where(whereClause ?? baseConditions)
|
||||
totalCount = Number(countResult[0].count)
|
||||
}
|
||||
|
||||
// Build ORDER BY clause (default to position ASC for stable ordering)
|
||||
let orderByClause
|
||||
@@ -1273,6 +1350,7 @@ export async function deleteRow(
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
await db.transaction(async (trx) => {
|
||||
await setTableTxTimeouts(trx)
|
||||
const [deleted] = await trx
|
||||
.delete(userTableRows)
|
||||
.where(
|
||||
@@ -1351,49 +1429,45 @@ export async function updateRowsByFilter(
|
||||
}
|
||||
|
||||
const uniqueColumns = getUniqueColumns(table.schema)
|
||||
if (uniqueColumns.length > 0) {
|
||||
const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data)
|
||||
if (uniqueColumnsInUpdate.length > 0) {
|
||||
if (matchingRows.length > 1) {
|
||||
const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data)
|
||||
if (uniqueColumnsInUpdate.length > 0) {
|
||||
throw new Error(
|
||||
`Cannot set unique column values when updating multiple rows. ` +
|
||||
`Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` +
|
||||
`Updating ${matchingRows.length} rows with the same value would violate uniqueness.`
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`Cannot set unique column values when updating multiple rows. ` +
|
||||
`Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` +
|
||||
`Updating ${matchingRows.length} rows with the same value would violate uniqueness.`
|
||||
)
|
||||
}
|
||||
|
||||
for (const row of matchingRows) {
|
||||
const existingData = row.data as RowData
|
||||
const mergedData = { ...existingData, ...data.data }
|
||||
const uniqueValidation = await checkUniqueConstraintsDb(
|
||||
data.tableId,
|
||||
mergedData,
|
||||
table.schema,
|
||||
row.id
|
||||
)
|
||||
if (!uniqueValidation.valid) {
|
||||
throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`)
|
||||
}
|
||||
// Only one row — only the touched unique columns need re-checking.
|
||||
const row = matchingRows[0]
|
||||
const mergedData = { ...(row.data as RowData), ...data.data }
|
||||
const uniqueValidation = await checkUniqueConstraintsDb(
|
||||
data.tableId,
|
||||
mergedData,
|
||||
table.schema,
|
||||
row.id
|
||||
)
|
||||
if (!uniqueValidation.valid) {
|
||||
throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const ids = matchingRows.map((r) => r.id)
|
||||
const patchJson = JSON.stringify(data.data)
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
for (let i = 0; i < matchingRows.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
|
||||
const batch = matchingRows.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
|
||||
const updatePromises = batch.map((row) => {
|
||||
const existingData = row.data as RowData
|
||||
return trx
|
||||
.update(userTableRows)
|
||||
.set({
|
||||
data: { ...existingData, ...data.data },
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(userTableRows.id, row.id))
|
||||
})
|
||||
await Promise.all(updatePromises)
|
||||
await setTableTxTimeouts(trx, { statementMs: 60_000 })
|
||||
for (let i = 0; i < ids.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
|
||||
const batchIds = ids.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
|
||||
await trx
|
||||
.update(userTableRows)
|
||||
.set({
|
||||
data: sql`${userTableRows.data} || ${patchJson}::jsonb`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(inArray(userTableRows.id, batchIds))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1401,7 +1475,7 @@ export async function updateRowsByFilter(
|
||||
|
||||
return {
|
||||
affectedCount: matchingRows.length,
|
||||
affectedRowIds: matchingRows.map((r) => r.id),
|
||||
affectedRowIds: ids,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1473,6 +1547,7 @@ export async function batchUpdateRows(
|
||||
const now = new Date()
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await setTableTxTimeouts(trx, { statementMs: 60_000 })
|
||||
for (let i = 0; i < mergedUpdates.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
|
||||
const batch = mergedUpdates.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
|
||||
const updatePromises = batch.map(({ rowId, mergedData }) =>
|
||||
@@ -1493,20 +1568,38 @@ export async function batchUpdateRows(
|
||||
}
|
||||
}
|
||||
|
||||
type DbTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||
|
||||
/**
|
||||
* Recompacts row positions to be contiguous (0, 1, 2, ...) after batch deletions.
|
||||
* Recompacts row positions to be contiguous after batch deletions.
|
||||
*
|
||||
* When `minDeletedPos` is provided, only rows with `position >= minDeletedPos`
|
||||
* are re-numbered (starting from `minDeletedPos`). Rows before the earliest
|
||||
* deleted position are untouched since their position is unaffected.
|
||||
*
|
||||
* If `minDeletedPos` is omitted, the whole table is recompacted from 0.
|
||||
* Single-row deletes use the more efficient `position - 1` shift in {@link deleteRow}.
|
||||
*/
|
||||
async function recompactPositions(tableId: string, trx: DbTransaction) {
|
||||
async function recompactPositions(tableId: string, trx: DbTransaction, minDeletedPos?: number) {
|
||||
if (minDeletedPos === undefined) {
|
||||
await trx.execute(sql`
|
||||
UPDATE user_table_rows t
|
||||
SET position = r.new_pos
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos
|
||||
FROM user_table_rows
|
||||
WHERE table_id = ${tableId}
|
||||
) r
|
||||
WHERE t.id = r.id AND t.table_id = ${tableId} AND t.position != r.new_pos
|
||||
`)
|
||||
return
|
||||
}
|
||||
|
||||
await trx.execute(sql`
|
||||
UPDATE user_table_rows t
|
||||
SET position = r.new_pos
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos
|
||||
SELECT id, ${minDeletedPos}::int + ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos
|
||||
FROM user_table_rows
|
||||
WHERE table_id = ${tableId}
|
||||
WHERE table_id = ${tableId} AND position >= ${minDeletedPos}
|
||||
) r
|
||||
WHERE t.id = r.id AND t.table_id = ${tableId} AND t.position != r.new_pos
|
||||
`)
|
||||
@@ -1538,7 +1631,7 @@ export async function deleteRowsByFilter(
|
||||
)
|
||||
|
||||
let query = db
|
||||
.select({ id: userTableRows.id })
|
||||
.select({ id: userTableRows.id, position: userTableRows.position })
|
||||
.from(userTableRows)
|
||||
.where(and(baseConditions, filterClause))
|
||||
|
||||
@@ -1553,8 +1646,13 @@ export async function deleteRowsByFilter(
|
||||
}
|
||||
|
||||
const rowIds = matchingRows.map((r) => r.id)
|
||||
const minDeletedPos = matchingRows.reduce(
|
||||
(min, r) => (r.position < min ? r.position : min),
|
||||
matchingRows[0].position
|
||||
)
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await setTableTxTimeouts(trx, { statementMs: 60_000 })
|
||||
for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) {
|
||||
const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE)
|
||||
await trx.delete(userTableRows).where(
|
||||
@@ -1569,7 +1667,7 @@ export async function deleteRowsByFilter(
|
||||
)
|
||||
}
|
||||
|
||||
await recompactPositions(data.tableId, trx)
|
||||
await recompactPositions(data.tableId, trx, minDeletedPos)
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${data.tableId}`)
|
||||
@@ -1594,7 +1692,8 @@ export async function deleteRowsByIds(
|
||||
const uniqueRequestedRowIds = Array.from(new Set(data.rowIds))
|
||||
|
||||
const deletedRows = await db.transaction(async (trx) => {
|
||||
const deleted: { id: string }[] = []
|
||||
await setTableTxTimeouts(trx, { statementMs: 60_000 })
|
||||
const deleted: { id: string; position: number }[] = []
|
||||
for (let i = 0; i < uniqueRequestedRowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) {
|
||||
const batch = uniqueRequestedRowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE)
|
||||
const rows = await trx
|
||||
@@ -1609,11 +1708,17 @@ export async function deleteRowsByIds(
|
||||
)}])`
|
||||
)
|
||||
)
|
||||
.returning({ id: userTableRows.id })
|
||||
.returning({ id: userTableRows.id, position: userTableRows.position })
|
||||
deleted.push(...rows)
|
||||
}
|
||||
|
||||
await recompactPositions(data.tableId, trx)
|
||||
if (deleted.length > 0) {
|
||||
const minDeletedPos = deleted.reduce(
|
||||
(min, r) => (r.position < min ? r.position : min),
|
||||
deleted[0].position
|
||||
)
|
||||
await recompactPositions(data.tableId, trx, minDeletedPos)
|
||||
}
|
||||
|
||||
return deleted
|
||||
})
|
||||
@@ -1691,8 +1796,13 @@ export async function renameColumn(
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const statementMs = scaledStatementTimeoutMs(table.rowCount ?? 0, {
|
||||
baseMs: 60_000,
|
||||
perRowMs: 2,
|
||||
})
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await setTableTxTimeouts(trx, { statementMs })
|
||||
await trx
|
||||
.update(userTableDefinitions)
|
||||
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
|
||||
@@ -1752,8 +1862,13 @@ export async function deleteColumn(
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const statementMs = scaledStatementTimeoutMs(table.rowCount ?? 0, {
|
||||
baseMs: 60_000,
|
||||
perRowMs: 2,
|
||||
})
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await setTableTxTimeouts(trx, { statementMs })
|
||||
await trx
|
||||
.update(userTableDefinitions)
|
||||
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
|
||||
@@ -1815,8 +1930,13 @@ export async function deleteColumns(
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const statementMs = scaledStatementTimeoutMs(table.rowCount ?? 0, {
|
||||
baseMs: 60_000,
|
||||
perRowMs: 2 * namesToDelete.size,
|
||||
})
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await setTableTxTimeouts(trx, { statementMs })
|
||||
await trx
|
||||
.update(userTableDefinitions)
|
||||
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
|
||||
|
||||
@@ -30,6 +30,14 @@ const ALLOWED_OPERATORS = new Set([
|
||||
* Builds a WHERE clause from a filter object.
|
||||
* Recursively processes logical operators ($or, $and) and field conditions.
|
||||
*
|
||||
* Index behavior: equality ($eq, $in) uses the JSONB containment operator (@>) and
|
||||
* can leverage the GIN index on `user_table_rows.data` (jsonb_path_ops). Range
|
||||
* operators ($gt, $gte, $lt, $lte) and pattern match ($contains) fall back to
|
||||
* text extraction via `data->>'field'`, which defeats the GIN index and produces
|
||||
* a sequential scan over the table's rows (bounded by a btree prefix on
|
||||
* `table_id`). Prefer equality filters on hot paths; assume range filters are
|
||||
* O(rows per table) until a per-column expression index is added.
|
||||
*
|
||||
* @param filter - Filter object with field conditions and logical operators
|
||||
* @param tableName - Table name for the query (e.g., 'user_table_rows')
|
||||
* @returns SQL WHERE clause or undefined if no filter specified
|
||||
|
||||
@@ -139,12 +139,18 @@ export interface QueryOptions {
|
||||
sort?: Sort
|
||||
limit?: number
|
||||
offset?: number
|
||||
/**
|
||||
* When true (default), runs a `COUNT(*)` and returns `totalCount` as a number.
|
||||
* Pass `false` to skip the count query (grid UI doesn't need it); `totalCount`
|
||||
* is returned as `null` to signal it was not computed.
|
||||
*/
|
||||
includeTotal?: boolean
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
rows: TableRow[]
|
||||
rowCount: number
|
||||
totalCount: number
|
||||
totalCount: number | null
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
@@ -2,16 +2,18 @@ import { createLogger } from '@sim/logger'
|
||||
import { safeCompare } from '@sim/security/compare'
|
||||
import { hmacSha256Hex } from '@sim/security/hmac'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
|
||||
import type {
|
||||
AuthContext,
|
||||
DeleteSubscriptionContext,
|
||||
EventMatchContext,
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
SubscriptionContext,
|
||||
SubscriptionResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:Ashby')
|
||||
|
||||
@@ -48,17 +50,74 @@ export const ashbyHandler: WebhookProviderHandler = {
|
||||
input: {
|
||||
...((b.data as Record<string, unknown>) || {}),
|
||||
action: b.action,
|
||||
data: b.data || {},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
verifyAuth: createHmacVerifier({
|
||||
configKey: 'secretToken',
|
||||
headerName: 'ashby-signature',
|
||||
validateFn: validateAshbySignature,
|
||||
providerLabel: 'Ashby',
|
||||
}),
|
||||
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null {
|
||||
const secretToken = (providerConfig.secretToken as string | undefined)?.trim()
|
||||
if (!secretToken) {
|
||||
logger.warn(
|
||||
`[${requestId}] Ashby webhook missing secretToken in providerConfig — rejecting request`
|
||||
)
|
||||
return new NextResponse(
|
||||
'Unauthorized - Ashby webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.',
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const signature = request.headers.get('ashby-signature')
|
||||
if (!signature) {
|
||||
logger.warn(`[${requestId}] Ashby webhook missing signature header`)
|
||||
return new NextResponse('Unauthorized - Missing Ashby signature', { status: 401 })
|
||||
}
|
||||
|
||||
if (!validateAshbySignature(secretToken, signature, rawBody)) {
|
||||
logger.warn(`[${requestId}] Ashby signature verification failed`, {
|
||||
signatureLength: signature.length,
|
||||
secretLength: secretToken.length,
|
||||
})
|
||||
return new NextResponse('Unauthorized - Invalid Ashby signature', { status: 401 })
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
async matchEvent({
|
||||
webhook,
|
||||
body,
|
||||
requestId,
|
||||
providerConfig,
|
||||
}: EventMatchContext): Promise<boolean> {
|
||||
const triggerId = providerConfig.triggerId as string | undefined
|
||||
const obj = body as Record<string, unknown>
|
||||
const action = typeof obj?.action === 'string' ? obj.action : ''
|
||||
|
||||
if (action === 'ping') {
|
||||
logger.debug(`[${requestId}] Ashby ping event received. Skipping execution.`, {
|
||||
webhookId: webhook.id,
|
||||
triggerId,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!triggerId) return true
|
||||
|
||||
const { isAshbyEventMatch } = await import('@/triggers/ashby/utils')
|
||||
if (!isAshbyEventMatch(triggerId, action)) {
|
||||
logger.debug(
|
||||
`[${requestId}] Ashby event mismatch for trigger ${triggerId}. Action: ${action || '(missing)'}. Skipping execution.`,
|
||||
{
|
||||
webhookId: webhook.id,
|
||||
triggerId,
|
||||
receivedAction: action,
|
||||
}
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
|
||||
try {
|
||||
@@ -78,18 +137,12 @@ export const ashbyHandler: WebhookProviderHandler = {
|
||||
throw new Error('Trigger ID is required to create Ashby webhook.')
|
||||
}
|
||||
|
||||
const webhookTypeMap: Record<string, string> = {
|
||||
ashby_application_submit: 'applicationSubmit',
|
||||
ashby_candidate_stage_change: 'candidateStageChange',
|
||||
ashby_candidate_hire: 'candidateHire',
|
||||
ashby_candidate_delete: 'candidateDelete',
|
||||
ashby_job_create: 'jobCreate',
|
||||
ashby_offer_create: 'offerCreate',
|
||||
}
|
||||
|
||||
const webhookType = webhookTypeMap[triggerId]
|
||||
const { ASHBY_TRIGGER_ACTION_MAP } = await import('@/triggers/ashby/utils')
|
||||
const webhookType = ASHBY_TRIGGER_ACTION_MAP[triggerId]
|
||||
if (!webhookType) {
|
||||
throw new Error(`Unknown Ashby triggerId: ${triggerId}. Add it to webhookTypeMap.`)
|
||||
throw new Error(
|
||||
`Unknown Ashby triggerId: ${triggerId}. Add it to ASHBY_TRIGGER_ACTION_MAP.`
|
||||
)
|
||||
}
|
||||
|
||||
const notificationUrl = getNotificationUrl(ctx.webhook)
|
||||
|
||||
@@ -34,6 +34,7 @@ export const SUBBLOCK_ID_MIGRATIONS: Record<string, Record<string, string>> = {
|
||||
ashby: {
|
||||
emailType: '_removed_emailType',
|
||||
phoneType: '_removed_phoneType',
|
||||
filterCandidateId: '_removed_filterCandidateId',
|
||||
},
|
||||
rippling: {
|
||||
action: '_removed_action',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AshbyCandidate } from '@/tools/ashby/types'
|
||||
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyAddCandidateTagParams {
|
||||
@@ -7,9 +9,7 @@ interface AshbyAddCandidateTagParams {
|
||||
}
|
||||
|
||||
interface AshbyAddCandidateTagResponse extends ToolResponse {
|
||||
output: {
|
||||
success: boolean
|
||||
}
|
||||
output: AshbyCandidate
|
||||
}
|
||||
|
||||
export const addCandidateTagTool: ToolConfig<
|
||||
@@ -18,7 +18,7 @@ export const addCandidateTagTool: ToolConfig<
|
||||
> = {
|
||||
id: 'ashby_add_candidate_tag',
|
||||
name: 'Ashby Add Candidate Tag',
|
||||
description: 'Adds a tag to a candidate in Ashby.',
|
||||
description: 'Adds a tag to a candidate in Ashby and returns the updated candidate.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
@@ -50,8 +50,8 @@ export const addCandidateTagTool: ToolConfig<
|
||||
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
|
||||
}),
|
||||
body: (params) => ({
|
||||
candidateId: params.candidateId,
|
||||
tagId: params.tagId,
|
||||
candidateId: params.candidateId.trim(),
|
||||
tagId: params.tagId.trim(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -64,13 +64,9 @@ export const addCandidateTagTool: ToolConfig<
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
},
|
||||
output: mapCandidate(data.results),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the tag was successfully added' },
|
||||
},
|
||||
outputs: CANDIDATE_OUTPUTS,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AshbyApplication } from '@/tools/ashby/types'
|
||||
import { APPLICATION_OUTPUTS, mapApplication } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyChangeApplicationStageParams {
|
||||
@@ -8,10 +10,7 @@ interface AshbyChangeApplicationStageParams {
|
||||
}
|
||||
|
||||
interface AshbyChangeApplicationStageResponse extends ToolResponse {
|
||||
output: {
|
||||
applicationId: string
|
||||
stageId: string | null
|
||||
}
|
||||
output: AshbyApplication
|
||||
}
|
||||
|
||||
export const changeApplicationStageTool: ToolConfig<
|
||||
@@ -61,10 +60,10 @@ export const changeApplicationStageTool: ToolConfig<
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {
|
||||
applicationId: params.applicationId,
|
||||
interviewStageId: params.interviewStageId,
|
||||
applicationId: params.applicationId.trim(),
|
||||
interviewStageId: params.interviewStageId.trim(),
|
||||
}
|
||||
if (params.archiveReasonId) body.archiveReasonId = params.archiveReasonId
|
||||
if (params.archiveReasonId) body.archiveReasonId = params.archiveReasonId.trim()
|
||||
return body
|
||||
},
|
||||
},
|
||||
@@ -76,19 +75,11 @@ export const changeApplicationStageTool: ToolConfig<
|
||||
throw new Error(data.errorInfo?.message || 'Failed to change application stage')
|
||||
}
|
||||
|
||||
const r = data.results
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
applicationId: r.id ?? null,
|
||||
stageId: r.currentInterviewStage?.id ?? null,
|
||||
},
|
||||
output: mapApplication(data.results),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
applicationId: { type: 'string', description: 'Application UUID' },
|
||||
stageId: { type: 'string', description: 'New interview stage UUID' },
|
||||
},
|
||||
outputs: APPLICATION_OUTPUTS,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AshbyApplication } from '@/tools/ashby/types'
|
||||
import { APPLICATION_OUTPUTS, mapApplication } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyCreateApplicationParams {
|
||||
@@ -12,9 +14,7 @@ interface AshbyCreateApplicationParams {
|
||||
}
|
||||
|
||||
interface AshbyCreateApplicationResponse extends ToolResponse {
|
||||
output: {
|
||||
applicationId: string
|
||||
}
|
||||
output: AshbyApplication
|
||||
}
|
||||
|
||||
export const createApplicationTool: ToolConfig<
|
||||
@@ -88,13 +88,13 @@ export const createApplicationTool: ToolConfig<
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {
|
||||
candidateId: params.candidateId,
|
||||
jobId: params.jobId,
|
||||
candidateId: params.candidateId.trim(),
|
||||
jobId: params.jobId.trim(),
|
||||
}
|
||||
if (params.interviewPlanId) body.interviewPlanId = params.interviewPlanId
|
||||
if (params.interviewStageId) body.interviewStageId = params.interviewStageId
|
||||
if (params.sourceId) body.sourceId = params.sourceId
|
||||
if (params.creditedToUserId) body.creditedToUserId = params.creditedToUserId
|
||||
if (params.interviewPlanId) body.interviewPlanId = params.interviewPlanId.trim()
|
||||
if (params.interviewStageId) body.interviewStageId = params.interviewStageId.trim()
|
||||
if (params.sourceId) body.sourceId = params.sourceId.trim()
|
||||
if (params.creditedToUserId) body.creditedToUserId = params.creditedToUserId.trim()
|
||||
if (params.createdAt) body.createdAt = params.createdAt
|
||||
return body
|
||||
},
|
||||
@@ -107,17 +107,11 @@ export const createApplicationTool: ToolConfig<
|
||||
throw new Error(data.errorInfo?.message || 'Failed to create application')
|
||||
}
|
||||
|
||||
const r = data.results
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
applicationId: r.applicationId ?? null,
|
||||
},
|
||||
output: mapApplication(data.results),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
applicationId: { type: 'string', description: 'Created application UUID' },
|
||||
},
|
||||
outputs: APPLICATION_OUTPUTS,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AshbyCreateCandidateParams, AshbyCreateCandidateResponse } from '@/tools/ashby/types'
|
||||
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { AshbyCreateCandidateParams, AshbyCreateCandidateResponse } from './types'
|
||||
|
||||
export const createCandidateTool: ToolConfig<
|
||||
AshbyCreateCandidateParams,
|
||||
@@ -25,7 +26,7 @@ export const createCandidateTool: ToolConfig<
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Primary email address for the candidate',
|
||||
},
|
||||
@@ -65,12 +66,12 @@ export const createCandidateTool: ToolConfig<
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {
|
||||
name: params.name,
|
||||
email: params.email,
|
||||
}
|
||||
if (params.email) body.email = params.email
|
||||
if (params.phoneNumber) body.phoneNumber = params.phoneNumber
|
||||
if (params.linkedInUrl) body.linkedInUrl = params.linkedInUrl
|
||||
if (params.githubUrl) body.githubUrl = params.githubUrl
|
||||
if (params.sourceId) body.sourceId = params.sourceId
|
||||
if (params.sourceId) body.sourceId = params.sourceId.trim()
|
||||
return body
|
||||
},
|
||||
},
|
||||
@@ -82,55 +83,11 @@ export const createCandidateTool: ToolConfig<
|
||||
throw new Error(data.errorInfo?.message || 'Failed to create candidate')
|
||||
}
|
||||
|
||||
const r = data.results
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
id: r.id ?? null,
|
||||
name: r.name ?? null,
|
||||
primaryEmailAddress: r.primaryEmailAddress
|
||||
? {
|
||||
value: r.primaryEmailAddress.value ?? '',
|
||||
type: r.primaryEmailAddress.type ?? 'Other',
|
||||
isPrimary: r.primaryEmailAddress.isPrimary ?? true,
|
||||
}
|
||||
: null,
|
||||
primaryPhoneNumber: r.primaryPhoneNumber
|
||||
? {
|
||||
value: r.primaryPhoneNumber.value ?? '',
|
||||
type: r.primaryPhoneNumber.type ?? 'Other',
|
||||
isPrimary: r.primaryPhoneNumber.isPrimary ?? true,
|
||||
}
|
||||
: null,
|
||||
createdAt: r.createdAt ?? null,
|
||||
},
|
||||
output: mapCandidate(data.results),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
id: { type: 'string', description: 'Created candidate UUID' },
|
||||
name: { type: 'string', description: 'Full name' },
|
||||
primaryEmailAddress: {
|
||||
type: 'object',
|
||||
description: 'Primary email contact info',
|
||||
optional: true,
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Email address' },
|
||||
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether this is the primary email' },
|
||||
},
|
||||
},
|
||||
primaryPhoneNumber: {
|
||||
type: 'object',
|
||||
description: 'Primary phone contact info',
|
||||
optional: true,
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Phone number' },
|
||||
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' },
|
||||
},
|
||||
},
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
},
|
||||
outputs: CANDIDATE_OUTPUTS,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AshbyCreateNoteParams, AshbyCreateNoteResponse } from '@/tools/ashby/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { AshbyCreateNoteParams, AshbyCreateNoteResponse } from './types'
|
||||
|
||||
export const createNoteTool: ToolConfig<AshbyCreateNoteParams, AshbyCreateNoteResponse> = {
|
||||
id: 'ashby_create_note',
|
||||
@@ -51,7 +51,7 @@ export const createNoteTool: ToolConfig<AshbyCreateNoteParams, AshbyCreateNoteRe
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {
|
||||
candidateId: params.candidateId,
|
||||
candidateId: params.candidateId.trim(),
|
||||
sendNotifications: params.sendNotifications ?? false,
|
||||
}
|
||||
if (params.noteType === 'text/html') {
|
||||
@@ -74,16 +74,42 @@ export const createNoteTool: ToolConfig<AshbyCreateNoteParams, AshbyCreateNoteRe
|
||||
}
|
||||
|
||||
const r = data.results
|
||||
const author = r.author
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
noteId: r.id ?? null,
|
||||
id: r.id ?? '',
|
||||
createdAt: r.createdAt ?? null,
|
||||
isPrivate: r.isPrivate ?? false,
|
||||
content: r.content ?? null,
|
||||
author: author
|
||||
? {
|
||||
id: author.id ?? '',
|
||||
firstName: author.firstName ?? null,
|
||||
lastName: author.lastName ?? null,
|
||||
email: author.email ?? null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
noteId: { type: 'string', description: 'Created note UUID' },
|
||||
id: { type: 'string', description: 'Created note UUID' },
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp', optional: true },
|
||||
isPrivate: { type: 'boolean', description: 'Whether the note is private' },
|
||||
content: { type: 'string', description: 'Note content', optional: true },
|
||||
author: {
|
||||
type: 'object',
|
||||
description: 'Author of the note',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Author user UUID' },
|
||||
firstName: { type: 'string', description: 'Author first name', optional: true },
|
||||
lastName: { type: 'string', description: 'Author last name', optional: true },
|
||||
email: { type: 'string', description: 'Author email', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AshbyApplication } from '@/tools/ashby/types'
|
||||
import { APPLICATION_OUTPUTS, mapApplication } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyGetApplicationParams {
|
||||
@@ -6,35 +8,7 @@ interface AshbyGetApplicationParams {
|
||||
}
|
||||
|
||||
interface AshbyGetApplicationResponse extends ToolResponse {
|
||||
output: {
|
||||
id: string
|
||||
status: string
|
||||
candidate: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
job: {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
currentInterviewStage: {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
} | null
|
||||
source: {
|
||||
id: string
|
||||
title: string
|
||||
} | null
|
||||
archiveReason: {
|
||||
id: string
|
||||
text: string
|
||||
reasonType: string
|
||||
} | null
|
||||
archivedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
output: AshbyApplication
|
||||
}
|
||||
|
||||
export const getApplicationTool: ToolConfig<
|
||||
@@ -69,7 +43,7 @@ export const getApplicationTool: ToolConfig<
|
||||
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
|
||||
}),
|
||||
body: (params) => ({
|
||||
applicationId: params.applicationId,
|
||||
applicationId: params.applicationId.trim(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -80,98 +54,11 @@ export const getApplicationTool: ToolConfig<
|
||||
throw new Error(data.errorInfo?.message || 'Failed to get application')
|
||||
}
|
||||
|
||||
const r = data.results
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
id: r.id ?? null,
|
||||
status: r.status ?? null,
|
||||
candidate: {
|
||||
id: r.candidate?.id ?? null,
|
||||
name: r.candidate?.name ?? null,
|
||||
},
|
||||
job: {
|
||||
id: r.job?.id ?? null,
|
||||
title: r.job?.title ?? null,
|
||||
},
|
||||
currentInterviewStage: r.currentInterviewStage
|
||||
? {
|
||||
id: r.currentInterviewStage.id ?? null,
|
||||
title: r.currentInterviewStage.title ?? null,
|
||||
type: r.currentInterviewStage.type ?? null,
|
||||
}
|
||||
: null,
|
||||
source: r.source
|
||||
? {
|
||||
id: r.source.id ?? null,
|
||||
title: r.source.title ?? null,
|
||||
}
|
||||
: null,
|
||||
archiveReason: r.archiveReason
|
||||
? {
|
||||
id: r.archiveReason.id ?? null,
|
||||
text: r.archiveReason.text ?? null,
|
||||
reasonType: r.archiveReason.reasonType ?? null,
|
||||
}
|
||||
: null,
|
||||
archivedAt: r.archivedAt ?? null,
|
||||
createdAt: r.createdAt ?? null,
|
||||
updatedAt: r.updatedAt ?? null,
|
||||
},
|
||||
output: mapApplication(data.results),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
id: { type: 'string', description: 'Application UUID' },
|
||||
status: { type: 'string', description: 'Application status (Active, Hired, Archived, Lead)' },
|
||||
candidate: {
|
||||
type: 'object',
|
||||
description: 'Associated candidate',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Candidate UUID' },
|
||||
name: { type: 'string', description: 'Candidate name' },
|
||||
},
|
||||
},
|
||||
job: {
|
||||
type: 'object',
|
||||
description: 'Associated job',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
},
|
||||
},
|
||||
currentInterviewStage: {
|
||||
type: 'object',
|
||||
description: 'Current interview stage',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Stage UUID' },
|
||||
title: { type: 'string', description: 'Stage title' },
|
||||
type: { type: 'string', description: 'Stage type' },
|
||||
},
|
||||
},
|
||||
source: {
|
||||
type: 'object',
|
||||
description: 'Application source',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Source UUID' },
|
||||
title: { type: 'string', description: 'Source title' },
|
||||
},
|
||||
},
|
||||
archiveReason: {
|
||||
type: 'object',
|
||||
description: 'Reason for archival',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Reason UUID' },
|
||||
text: { type: 'string', description: 'Reason text' },
|
||||
reasonType: { type: 'string', description: 'Reason type' },
|
||||
},
|
||||
},
|
||||
archivedAt: { type: 'string', description: 'ISO 8601 archive timestamp', optional: true },
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
|
||||
},
|
||||
outputs: APPLICATION_OUTPUTS,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AshbyGetCandidateParams, AshbyGetCandidateResponse } from '@/tools/ashby/types'
|
||||
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { AshbyGetCandidateParams, AshbyGetCandidateResponse } from './types'
|
||||
|
||||
export const getCandidateTool: ToolConfig<AshbyGetCandidateParams, AshbyGetCandidateResponse> = {
|
||||
id: 'ashby_get_candidate',
|
||||
@@ -30,7 +31,7 @@ export const getCandidateTool: ToolConfig<AshbyGetCandidateParams, AshbyGetCandi
|
||||
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
|
||||
}),
|
||||
body: (params) => ({
|
||||
candidateId: params.candidateId.trim(),
|
||||
id: params.candidateId.trim(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -41,94 +42,11 @@ export const getCandidateTool: ToolConfig<AshbyGetCandidateParams, AshbyGetCandi
|
||||
throw new Error(data.errorInfo?.message || 'Failed to get candidate')
|
||||
}
|
||||
|
||||
const r = data.results
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
id: r.id ?? null,
|
||||
name: r.name ?? null,
|
||||
primaryEmailAddress: r.primaryEmailAddress
|
||||
? {
|
||||
value: r.primaryEmailAddress.value ?? '',
|
||||
type: r.primaryEmailAddress.type ?? 'Other',
|
||||
isPrimary: r.primaryEmailAddress.isPrimary ?? true,
|
||||
}
|
||||
: null,
|
||||
primaryPhoneNumber: r.primaryPhoneNumber
|
||||
? {
|
||||
value: r.primaryPhoneNumber.value ?? '',
|
||||
type: r.primaryPhoneNumber.type ?? 'Other',
|
||||
isPrimary: r.primaryPhoneNumber.isPrimary ?? true,
|
||||
}
|
||||
: null,
|
||||
profileUrl: r.profileUrl ?? null,
|
||||
position: r.position ?? null,
|
||||
company: r.company ?? null,
|
||||
linkedInUrl:
|
||||
(r.socialLinks ?? []).find((l: { type: string }) => l.type === 'LinkedIn')?.url ?? null,
|
||||
githubUrl:
|
||||
(r.socialLinks ?? []).find((l: { type: string }) => l.type === 'GitHub')?.url ?? null,
|
||||
tags: (r.tags ?? []).map((t: { id: string; title: string }) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
})),
|
||||
applicationIds: r.applicationIds ?? [],
|
||||
createdAt: r.createdAt ?? null,
|
||||
updatedAt: r.updatedAt ?? null,
|
||||
},
|
||||
output: mapCandidate(data.results),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
id: { type: 'string', description: 'Candidate UUID' },
|
||||
name: { type: 'string', description: 'Full name' },
|
||||
primaryEmailAddress: {
|
||||
type: 'object',
|
||||
description: 'Primary email contact info',
|
||||
optional: true,
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Email address' },
|
||||
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether this is the primary email' },
|
||||
},
|
||||
},
|
||||
primaryPhoneNumber: {
|
||||
type: 'object',
|
||||
description: 'Primary phone contact info',
|
||||
optional: true,
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Phone number' },
|
||||
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' },
|
||||
},
|
||||
},
|
||||
profileUrl: {
|
||||
type: 'string',
|
||||
description: 'URL to the candidate Ashby profile',
|
||||
optional: true,
|
||||
},
|
||||
position: { type: 'string', description: 'Current position or title', optional: true },
|
||||
company: { type: 'string', description: 'Current company', optional: true },
|
||||
linkedInUrl: { type: 'string', description: 'LinkedIn profile URL', optional: true },
|
||||
githubUrl: { type: 'string', description: 'GitHub profile URL', optional: true },
|
||||
tags: {
|
||||
type: 'array',
|
||||
description: 'Tags applied to the candidate',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Tag UUID' },
|
||||
title: { type: 'string', description: 'Tag title' },
|
||||
},
|
||||
},
|
||||
},
|
||||
applicationIds: {
|
||||
type: 'array',
|
||||
description: 'IDs of associated applications',
|
||||
items: { type: 'string', description: 'Application UUID' },
|
||||
},
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
|
||||
},
|
||||
outputs: CANDIDATE_OUTPUTS,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AshbyGetJobParams, AshbyGetJobResponse } from '@/tools/ashby/types'
|
||||
import { JOB_OUTPUTS, mapJob } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { AshbyGetJobParams, AshbyGetJobResponse } from './types'
|
||||
|
||||
export const getJobTool: ToolConfig<AshbyGetJobParams, AshbyGetJobResponse> = {
|
||||
id: 'ashby_get_job',
|
||||
@@ -30,7 +31,7 @@ export const getJobTool: ToolConfig<AshbyGetJobParams, AshbyGetJobResponse> = {
|
||||
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
|
||||
}),
|
||||
body: (params) => ({
|
||||
jobId: params.jobId.trim(),
|
||||
id: params.jobId.trim(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -41,43 +42,11 @@ export const getJobTool: ToolConfig<AshbyGetJobParams, AshbyGetJobResponse> = {
|
||||
throw new Error(data.errorInfo?.message || 'Failed to get job')
|
||||
}
|
||||
|
||||
const r = data.results
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
id: r.id ?? null,
|
||||
title: r.title ?? null,
|
||||
status: r.status ?? null,
|
||||
employmentType: r.employmentType ?? null,
|
||||
departmentId: r.departmentId ?? null,
|
||||
locationId: r.locationId ?? null,
|
||||
descriptionPlain: r.descriptionPlain ?? null,
|
||||
isArchived: r.isArchived ?? false,
|
||||
createdAt: r.createdAt ?? null,
|
||||
updatedAt: r.updatedAt ?? null,
|
||||
},
|
||||
output: mapJob(data.results),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
status: { type: 'string', description: 'Job status (Open, Closed, Draft, Archived)' },
|
||||
employmentType: {
|
||||
type: 'string',
|
||||
description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)',
|
||||
optional: true,
|
||||
},
|
||||
departmentId: { type: 'string', description: 'Department UUID', optional: true },
|
||||
locationId: { type: 'string', description: 'Location UUID', optional: true },
|
||||
descriptionPlain: {
|
||||
type: 'string',
|
||||
description: 'Job description in plain text',
|
||||
optional: true,
|
||||
},
|
||||
isArchived: { type: 'boolean', description: 'Whether the job is archived' },
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
|
||||
},
|
||||
outputs: JOB_OUTPUTS,
|
||||
}
|
||||
|
||||
@@ -3,20 +3,80 @@ import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
interface AshbyGetJobPostingParams {
|
||||
apiKey: string
|
||||
jobPostingId: string
|
||||
expandApplicationFormDefinition?: boolean
|
||||
expandSurveyFormDefinitions?: boolean
|
||||
}
|
||||
|
||||
interface AshbyDescriptionPart {
|
||||
html: string | null
|
||||
plain: string | null
|
||||
}
|
||||
|
||||
interface AshbyJobPosting {
|
||||
id: string
|
||||
title: string
|
||||
descriptionPlain: string | null
|
||||
descriptionHtml: string | null
|
||||
descriptionSocial: string | null
|
||||
descriptionParts: {
|
||||
descriptionOpening: AshbyDescriptionPart | null
|
||||
descriptionBody: AshbyDescriptionPart | null
|
||||
descriptionClosing: AshbyDescriptionPart | null
|
||||
} | null
|
||||
departmentName: string | null
|
||||
teamName: string | null
|
||||
teamNameHierarchy: string[]
|
||||
jobId: string | null
|
||||
locationName: string | null
|
||||
locationIds: {
|
||||
primaryLocationId: string | null
|
||||
secondaryLocationIds: string[]
|
||||
} | null
|
||||
address: {
|
||||
postalAddress: {
|
||||
addressCountry: string | null
|
||||
addressRegion: string | null
|
||||
addressLocality: string | null
|
||||
postalCode: string | null
|
||||
streetAddress: string | null
|
||||
} | null
|
||||
} | null
|
||||
isRemote: boolean
|
||||
workplaceType: string | null
|
||||
employmentType: string | null
|
||||
isListed: boolean
|
||||
suppressDescriptionOpening: boolean
|
||||
suppressDescriptionClosing: boolean
|
||||
publishedDate: string | null
|
||||
applicationDeadline: string | null
|
||||
externalLink: string | null
|
||||
applyLink: string | null
|
||||
compensation: {
|
||||
compensationTierSummary: string | null
|
||||
summaryComponents: Array<{
|
||||
summary: string | null
|
||||
compensationTypeLabel: string | null
|
||||
interval: string | null
|
||||
currencyCode: string | null
|
||||
minValue: number | null
|
||||
maxValue: number | null
|
||||
}>
|
||||
shouldDisplayCompensationOnJobBoard: boolean
|
||||
} | null
|
||||
applicationLimitCalloutHtml: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
interface AshbyGetJobPostingResponse extends ToolResponse {
|
||||
output: {
|
||||
id: string
|
||||
title: string
|
||||
jobId: string | null
|
||||
locationName: string | null
|
||||
departmentName: string | null
|
||||
employmentType: string | null
|
||||
descriptionPlain: string | null
|
||||
isListed: boolean
|
||||
publishedDate: string | null
|
||||
externalLink: string | null
|
||||
output: AshbyJobPosting
|
||||
}
|
||||
|
||||
function mapDescriptionPart(raw: unknown): AshbyDescriptionPart | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const p = raw as Record<string, unknown>
|
||||
return {
|
||||
html: (p.html as string) ?? null,
|
||||
plain: (p.plain as string) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +99,18 @@ export const getJobPostingTool: ToolConfig<AshbyGetJobPostingParams, AshbyGetJob
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The UUID of the job posting to fetch',
|
||||
},
|
||||
expandApplicationFormDefinition: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Include application form definition in the response',
|
||||
},
|
||||
expandSurveyFormDefinitions: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Include survey form definitions in the response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
@@ -48,9 +120,18 @@ export const getJobPostingTool: ToolConfig<AshbyGetJobPostingParams, AshbyGetJob
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
|
||||
}),
|
||||
body: (params) => ({
|
||||
jobPostingId: params.jobPostingId,
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {
|
||||
jobPostingId: params.jobPostingId.trim(),
|
||||
}
|
||||
if (params.expandApplicationFormDefinition !== undefined) {
|
||||
body.expandApplicationFormDefinition = params.expandApplicationFormDefinition
|
||||
}
|
||||
if (params.expandSurveyFormDefinitions !== undefined) {
|
||||
body.expandSurveyFormDefinitions = params.expandSurveyFormDefinitions
|
||||
}
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
@@ -60,21 +141,90 @@ export const getJobPostingTool: ToolConfig<AshbyGetJobPostingParams, AshbyGetJob
|
||||
throw new Error(data.errorInfo?.message || 'Failed to get job posting')
|
||||
}
|
||||
|
||||
const r = data.results
|
||||
const r = (data.results ?? {}) as Record<string, unknown> & {
|
||||
descriptionParts?: Record<string, unknown>
|
||||
locationIds?: { primaryLocationId?: string; secondaryLocationIds?: string[] }
|
||||
address?: { postalAddress?: Record<string, unknown> }
|
||||
compensation?: Record<string, unknown> & {
|
||||
summaryComponents?: Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
const pa = r.address?.postalAddress
|
||||
const comp = r.compensation
|
||||
const summaryComponents = Array.isArray(comp?.summaryComponents) ? comp.summaryComponents : []
|
||||
const descParts = r.descriptionParts
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
id: r.id ?? null,
|
||||
title: r.jobTitle ?? r.title ?? null,
|
||||
jobId: r.jobId ?? null,
|
||||
locationName: r.locationName ?? null,
|
||||
departmentName: r.departmentName ?? null,
|
||||
employmentType: r.employmentType ?? null,
|
||||
descriptionPlain: r.descriptionPlain ?? r.description ?? null,
|
||||
isListed: r.isListed ?? false,
|
||||
publishedDate: r.publishedDate ?? null,
|
||||
externalLink: r.externalLink ?? null,
|
||||
id: (r.id as string) ?? '',
|
||||
title: (r.title as string) ?? '',
|
||||
descriptionPlain: (r.descriptionPlain as string) ?? null,
|
||||
descriptionHtml: (r.descriptionHtml as string) ?? null,
|
||||
descriptionSocial: (r.descriptionSocial as string) ?? null,
|
||||
descriptionParts: descParts
|
||||
? {
|
||||
descriptionOpening: mapDescriptionPart(descParts.descriptionOpening),
|
||||
descriptionBody: mapDescriptionPart(descParts.descriptionBody),
|
||||
descriptionClosing: mapDescriptionPart(descParts.descriptionClosing),
|
||||
}
|
||||
: null,
|
||||
departmentName: (r.departmentName as string) ?? null,
|
||||
teamName: (r.teamName as string) ?? null,
|
||||
teamNameHierarchy: Array.isArray(r.teamNameHierarchy)
|
||||
? (r.teamNameHierarchy as string[])
|
||||
: [],
|
||||
jobId: (r.jobId as string) ?? null,
|
||||
locationName: (r.locationName as string) ?? null,
|
||||
locationIds: r.locationIds
|
||||
? {
|
||||
primaryLocationId: r.locationIds.primaryLocationId ?? null,
|
||||
secondaryLocationIds: Array.isArray(r.locationIds.secondaryLocationIds)
|
||||
? r.locationIds.secondaryLocationIds
|
||||
: [],
|
||||
}
|
||||
: null,
|
||||
address: r.address
|
||||
? {
|
||||
postalAddress: pa
|
||||
? {
|
||||
addressCountry: (pa.addressCountry as string) ?? null,
|
||||
addressRegion: (pa.addressRegion as string) ?? null,
|
||||
addressLocality: (pa.addressLocality as string) ?? null,
|
||||
postalCode: (pa.postalCode as string) ?? null,
|
||||
streetAddress: (pa.streetAddress as string) ?? null,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
isRemote: (r.isRemote as boolean) ?? false,
|
||||
workplaceType: (r.workplaceType as string) ?? null,
|
||||
employmentType: (r.employmentType as string) ?? null,
|
||||
isListed: (r.isListed as boolean) ?? false,
|
||||
suppressDescriptionOpening: (r.suppressDescriptionOpening as boolean) ?? false,
|
||||
suppressDescriptionClosing: (r.suppressDescriptionClosing as boolean) ?? false,
|
||||
publishedDate: (r.publishedDate as string) ?? null,
|
||||
applicationDeadline: (r.applicationDeadline as string) ?? null,
|
||||
externalLink: (r.externalLink as string) ?? null,
|
||||
applyLink: (r.applyLink as string) ?? null,
|
||||
compensation: comp
|
||||
? {
|
||||
compensationTierSummary: (comp.compensationTierSummary as string) ?? null,
|
||||
summaryComponents: summaryComponents.map((c) => ({
|
||||
summary: (c.summary as string) ?? null,
|
||||
compensationTypeLabel: (c.compensationTypeLabel as string) ?? null,
|
||||
interval: (c.interval as string) ?? null,
|
||||
currencyCode: (c.currencyCode as string) ?? null,
|
||||
minValue: (c.minValue as number) ?? null,
|
||||
maxValue: (c.maxValue as number) ?? null,
|
||||
})),
|
||||
shouldDisplayCompensationOnJobBoard:
|
||||
(comp.shouldDisplayCompensationOnJobBoard as boolean) ?? false,
|
||||
}
|
||||
: null,
|
||||
applicationLimitCalloutHtml: (r.applicationLimitCalloutHtml as string) ?? null,
|
||||
updatedAt: (r.updatedAt as string) ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -82,25 +232,188 @@ export const getJobPostingTool: ToolConfig<AshbyGetJobPostingParams, AshbyGetJob
|
||||
outputs: {
|
||||
id: { type: 'string', description: 'Job posting UUID' },
|
||||
title: { type: 'string', description: 'Job posting title' },
|
||||
jobId: { type: 'string', description: 'Associated job UUID', optional: true },
|
||||
locationName: { type: 'string', description: 'Location name', optional: true },
|
||||
departmentName: { type: 'string', description: 'Department name', optional: true },
|
||||
employmentType: {
|
||||
type: 'string',
|
||||
description: 'Employment type (e.g. FullTime, PartTime, Contract)',
|
||||
optional: true,
|
||||
},
|
||||
descriptionPlain: {
|
||||
type: 'string',
|
||||
description: 'Job posting description in plain text',
|
||||
description: 'Full description in plain text',
|
||||
optional: true,
|
||||
},
|
||||
isListed: { type: 'boolean', description: 'Whether the posting is publicly listed' },
|
||||
descriptionHtml: {
|
||||
type: 'string',
|
||||
description: 'Full description in HTML',
|
||||
optional: true,
|
||||
},
|
||||
descriptionSocial: {
|
||||
type: 'string',
|
||||
description: 'Shortened description for social sharing (max 200 chars)',
|
||||
optional: true,
|
||||
},
|
||||
descriptionParts: {
|
||||
type: 'object',
|
||||
description: 'Description broken into opening, body, and closing sections',
|
||||
optional: true,
|
||||
properties: {
|
||||
descriptionOpening: {
|
||||
type: 'object',
|
||||
description: 'Opening (from Job Boards theme settings)',
|
||||
optional: true,
|
||||
properties: {
|
||||
html: { type: 'string', description: 'HTML content', optional: true },
|
||||
plain: { type: 'string', description: 'Plain text content', optional: true },
|
||||
},
|
||||
},
|
||||
descriptionBody: {
|
||||
type: 'object',
|
||||
description: 'Main description body',
|
||||
optional: true,
|
||||
properties: {
|
||||
html: { type: 'string', description: 'HTML content', optional: true },
|
||||
plain: { type: 'string', description: 'Plain text content', optional: true },
|
||||
},
|
||||
},
|
||||
descriptionClosing: {
|
||||
type: 'object',
|
||||
description: 'Closing (from Job Boards theme settings)',
|
||||
optional: true,
|
||||
properties: {
|
||||
html: { type: 'string', description: 'HTML content', optional: true },
|
||||
plain: { type: 'string', description: 'Plain text content', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
departmentName: { type: 'string', description: 'Department name', optional: true },
|
||||
teamName: { type: 'string', description: 'Team name', optional: true },
|
||||
teamNameHierarchy: {
|
||||
type: 'array',
|
||||
description: 'Hierarchy of team names from root to team',
|
||||
items: { type: 'string', description: 'Team name' },
|
||||
},
|
||||
jobId: { type: 'string', description: 'Associated job UUID', optional: true },
|
||||
locationName: { type: 'string', description: 'Primary location name', optional: true },
|
||||
locationIds: {
|
||||
type: 'object',
|
||||
description: 'Primary and secondary location UUIDs',
|
||||
optional: true,
|
||||
properties: {
|
||||
primaryLocationId: {
|
||||
type: 'string',
|
||||
description: 'Primary location UUID',
|
||||
optional: true,
|
||||
},
|
||||
secondaryLocationIds: {
|
||||
type: 'array',
|
||||
description: 'Secondary location UUIDs',
|
||||
items: { type: 'string', description: 'Location UUID' },
|
||||
},
|
||||
},
|
||||
},
|
||||
address: {
|
||||
type: 'object',
|
||||
description: 'Postal address of the posting location',
|
||||
optional: true,
|
||||
properties: {
|
||||
postalAddress: {
|
||||
type: 'object',
|
||||
description: 'Structured postal address',
|
||||
optional: true,
|
||||
properties: {
|
||||
addressCountry: { type: 'string', description: 'Country', optional: true },
|
||||
addressRegion: { type: 'string', description: 'State or region', optional: true },
|
||||
addressLocality: { type: 'string', description: 'City or locality', optional: true },
|
||||
postalCode: { type: 'string', description: 'Postal code', optional: true },
|
||||
streetAddress: { type: 'string', description: 'Street address', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isRemote: { type: 'boolean', description: 'Whether the posting is remote' },
|
||||
workplaceType: {
|
||||
type: 'string',
|
||||
description: 'Workplace type (OnSite, Remote, Hybrid)',
|
||||
optional: true,
|
||||
},
|
||||
employmentType: {
|
||||
type: 'string',
|
||||
description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)',
|
||||
optional: true,
|
||||
},
|
||||
isListed: { type: 'boolean', description: 'Whether publicly listed on the job board' },
|
||||
suppressDescriptionOpening: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the theme opening is hidden on this posting',
|
||||
},
|
||||
suppressDescriptionClosing: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the theme closing is hidden on this posting',
|
||||
},
|
||||
publishedDate: { type: 'string', description: 'ISO 8601 published date', optional: true },
|
||||
applicationDeadline: {
|
||||
type: 'string',
|
||||
description: 'ISO 8601 application deadline',
|
||||
optional: true,
|
||||
},
|
||||
externalLink: {
|
||||
type: 'string',
|
||||
description: 'External link to the job posting',
|
||||
optional: true,
|
||||
},
|
||||
applyLink: {
|
||||
type: 'string',
|
||||
description: 'Direct apply link',
|
||||
optional: true,
|
||||
},
|
||||
compensation: {
|
||||
type: 'object',
|
||||
description: 'Compensation details for the posting',
|
||||
optional: true,
|
||||
properties: {
|
||||
compensationTierSummary: {
|
||||
type: 'string',
|
||||
description: 'Human-readable tier summary',
|
||||
optional: true,
|
||||
},
|
||||
summaryComponents: {
|
||||
type: 'array',
|
||||
description: 'Structured compensation components',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string', description: 'Component summary', optional: true },
|
||||
compensationTypeLabel: {
|
||||
type: 'string',
|
||||
description: 'Component type label (Salary, Commission, Bonus, Equity, etc.)',
|
||||
optional: true,
|
||||
},
|
||||
interval: {
|
||||
type: 'string',
|
||||
description: 'Payment interval (e.g. annual, hourly)',
|
||||
optional: true,
|
||||
},
|
||||
currencyCode: {
|
||||
type: 'string',
|
||||
description: 'ISO 4217 currency code',
|
||||
optional: true,
|
||||
},
|
||||
minValue: { type: 'number', description: 'Minimum value', optional: true },
|
||||
maxValue: { type: 'number', description: 'Maximum value', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldDisplayCompensationOnJobBoard: {
|
||||
type: 'boolean',
|
||||
description: 'Whether compensation is shown on the job board',
|
||||
},
|
||||
},
|
||||
},
|
||||
applicationLimitCalloutHtml: {
|
||||
type: 'string',
|
||||
description: 'HTML callout shown when application limit is reached',
|
||||
optional: true,
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
description: 'ISO 8601 last update timestamp',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AshbyOffer } from '@/tools/ashby/types'
|
||||
import { mapOffer, OFFER_OUTPUTS } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyGetOfferParams {
|
||||
@@ -6,19 +8,7 @@ interface AshbyGetOfferParams {
|
||||
}
|
||||
|
||||
interface AshbyGetOfferResponse extends ToolResponse {
|
||||
output: {
|
||||
id: string
|
||||
offerStatus: string
|
||||
acceptanceStatus: string | null
|
||||
applicationId: string | null
|
||||
startDate: string | null
|
||||
salary: {
|
||||
currencyCode: string
|
||||
value: number
|
||||
} | null
|
||||
openingId: string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
output: AshbyOffer
|
||||
}
|
||||
|
||||
export const getOfferTool: ToolConfig<AshbyGetOfferParams, AshbyGetOfferResponse> = {
|
||||
@@ -50,7 +40,7 @@ export const getOfferTool: ToolConfig<AshbyGetOfferParams, AshbyGetOfferResponse
|
||||
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
|
||||
}),
|
||||
body: (params) => ({
|
||||
offerId: params.offerId,
|
||||
offerId: params.offerId.trim(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -61,56 +51,11 @@ export const getOfferTool: ToolConfig<AshbyGetOfferParams, AshbyGetOfferResponse
|
||||
throw new Error(data.errorInfo?.message || 'Failed to get offer')
|
||||
}
|
||||
|
||||
const r = data.results
|
||||
const v = r.latestVersion
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
id: r.id ?? null,
|
||||
offerStatus: r.offerStatus ?? null,
|
||||
acceptanceStatus: r.acceptanceStatus ?? null,
|
||||
applicationId: r.applicationId ?? null,
|
||||
startDate: v?.startDate ?? null,
|
||||
salary: v?.salary
|
||||
? {
|
||||
currencyCode: v.salary.currencyCode ?? null,
|
||||
value: v.salary.value ?? null,
|
||||
}
|
||||
: null,
|
||||
openingId: v?.openingId ?? null,
|
||||
createdAt: v?.createdAt ?? null,
|
||||
},
|
||||
output: mapOffer(data.results),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
id: { type: 'string', description: 'Offer UUID' },
|
||||
offerStatus: {
|
||||
type: 'string',
|
||||
description: 'Offer status (e.g. WaitingOnCandidateResponse, CandidateAccepted)',
|
||||
},
|
||||
acceptanceStatus: {
|
||||
type: 'string',
|
||||
description: 'Acceptance status (e.g. Accepted, Declined, Pending)',
|
||||
optional: true,
|
||||
},
|
||||
applicationId: { type: 'string', description: 'Associated application UUID', optional: true },
|
||||
startDate: { type: 'string', description: 'Offer start date', optional: true },
|
||||
salary: {
|
||||
type: 'object',
|
||||
description: 'Salary details',
|
||||
optional: true,
|
||||
properties: {
|
||||
currencyCode: { type: 'string', description: 'ISO 4217 currency code' },
|
||||
value: { type: 'number', description: 'Salary amount' },
|
||||
},
|
||||
},
|
||||
openingId: { type: 'string', description: 'Associated opening UUID', optional: true },
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
description: 'ISO 8601 creation timestamp (from latest version)',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
outputs: OFFER_OUTPUTS,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type {
|
||||
AshbyListApplicationsParams,
|
||||
AshbyListApplicationsResponse,
|
||||
} from '@/tools/ashby/types'
|
||||
import { APPLICATION_OUTPUTS, mapApplication } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { AshbyListApplicationsParams, AshbyListApplicationsResponse } from './types'
|
||||
|
||||
export const listApplicationsTool: ToolConfig<
|
||||
AshbyListApplicationsParams,
|
||||
@@ -8,7 +12,7 @@ export const listApplicationsTool: ToolConfig<
|
||||
id: 'ashby_list_applications',
|
||||
name: 'Ashby List Applications',
|
||||
description:
|
||||
'Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date.',
|
||||
'Lists all applications in an Ashby organization with pagination and optional filters for status, job, and creation date.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
@@ -42,12 +46,6 @@ export const listApplicationsTool: ToolConfig<
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter applications by a specific job UUID',
|
||||
},
|
||||
candidateId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter applications by a specific candidate UUID',
|
||||
},
|
||||
createdAfter: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
@@ -68,10 +66,12 @@ export const listApplicationsTool: ToolConfig<
|
||||
const body: Record<string, unknown> = {}
|
||||
if (params.cursor) body.cursor = params.cursor
|
||||
if (params.perPage) body.limit = params.perPage
|
||||
if (params.status) body.status = [params.status]
|
||||
if (params.jobId) body.jobId = params.jobId
|
||||
if (params.candidateId) body.candidateId = params.candidateId
|
||||
if (params.createdAfter) body.createdAfter = new Date(params.createdAfter).getTime()
|
||||
if (params.status) body.status = params.status
|
||||
if (params.jobId) body.jobId = params.jobId.trim()
|
||||
if (params.createdAfter) {
|
||||
const ms = new Date(params.createdAfter).getTime()
|
||||
if (!Number.isNaN(ms)) body.createdAfter = ms
|
||||
}
|
||||
return body
|
||||
},
|
||||
},
|
||||
@@ -86,42 +86,7 @@ export const listApplicationsTool: ToolConfig<
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
applications: (data.results ?? []).map(
|
||||
(
|
||||
a: Record<string, unknown> & {
|
||||
candidate?: { id?: string; name?: string }
|
||||
job?: { id?: string; title?: string }
|
||||
currentInterviewStage?: { id?: string; title?: string; type?: string } | null
|
||||
source?: { id?: string; title?: string } | null
|
||||
}
|
||||
) => ({
|
||||
id: a.id ?? null,
|
||||
status: a.status ?? null,
|
||||
candidate: {
|
||||
id: a.candidate?.id ?? null,
|
||||
name: a.candidate?.name ?? null,
|
||||
},
|
||||
job: {
|
||||
id: a.job?.id ?? null,
|
||||
title: a.job?.title ?? null,
|
||||
},
|
||||
currentInterviewStage: a.currentInterviewStage
|
||||
? {
|
||||
id: a.currentInterviewStage.id ?? null,
|
||||
title: a.currentInterviewStage.title ?? null,
|
||||
type: a.currentInterviewStage.type ?? null,
|
||||
}
|
||||
: null,
|
||||
source: a.source
|
||||
? {
|
||||
id: a.source.id ?? null,
|
||||
title: a.source.title ?? null,
|
||||
}
|
||||
: null,
|
||||
createdAt: a.createdAt ?? null,
|
||||
updatedAt: a.updatedAt ?? null,
|
||||
})
|
||||
),
|
||||
applications: (data.results ?? []).map(mapApplication),
|
||||
moreDataAvailable: data.moreDataAvailable ?? false,
|
||||
nextCursor: data.nextCursor ?? null,
|
||||
},
|
||||
@@ -134,50 +99,7 @@ export const listApplicationsTool: ToolConfig<
|
||||
description: 'List of applications',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Application UUID' },
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Application status (Active, Hired, Archived, Lead)',
|
||||
},
|
||||
candidate: {
|
||||
type: 'object',
|
||||
description: 'Associated candidate',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Candidate UUID' },
|
||||
name: { type: 'string', description: 'Candidate name' },
|
||||
},
|
||||
},
|
||||
job: {
|
||||
type: 'object',
|
||||
description: 'Associated job',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
},
|
||||
},
|
||||
currentInterviewStage: {
|
||||
type: 'object',
|
||||
description: 'Current interview stage',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Stage UUID' },
|
||||
title: { type: 'string', description: 'Stage title' },
|
||||
type: { type: 'string', description: 'Stage type' },
|
||||
},
|
||||
},
|
||||
source: {
|
||||
type: 'object',
|
||||
description: 'Application source',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Source UUID' },
|
||||
title: { type: 'string', description: 'Source title' },
|
||||
},
|
||||
},
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
|
||||
},
|
||||
properties: APPLICATION_OUTPUTS,
|
||||
},
|
||||
},
|
||||
moreDataAvailable: {
|
||||
|
||||
@@ -2,16 +2,19 @@ import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyListArchiveReasonsParams {
|
||||
apiKey: string
|
||||
includeArchived?: boolean
|
||||
}
|
||||
|
||||
interface AshbyArchiveReason {
|
||||
id: string
|
||||
text: string
|
||||
reasonType: string
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
interface AshbyListArchiveReasonsResponse extends ToolResponse {
|
||||
output: {
|
||||
archiveReasons: Array<{
|
||||
id: string
|
||||
text: string
|
||||
reasonType: string
|
||||
isArchived: boolean
|
||||
}>
|
||||
archiveReasons: AshbyArchiveReason[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +34,12 @@ export const listArchiveReasonsTool: ToolConfig<
|
||||
visibility: 'user-only',
|
||||
description: 'Ashby API Key',
|
||||
},
|
||||
includeArchived: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to include archived archive reasons in the response (default false)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
@@ -40,7 +49,11 @@ export const listArchiveReasonsTool: ToolConfig<
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
|
||||
}),
|
||||
body: () => ({}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {}
|
||||
if (params.includeArchived !== undefined) body.includeArchived = params.includeArchived
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
@@ -54,10 +67,10 @@ export const listArchiveReasonsTool: ToolConfig<
|
||||
success: true,
|
||||
output: {
|
||||
archiveReasons: (data.results ?? []).map((r: Record<string, unknown>) => ({
|
||||
id: r.id ?? null,
|
||||
text: r.text ?? null,
|
||||
reasonType: r.reasonType ?? null,
|
||||
isArchived: r.isArchived ?? false,
|
||||
id: (r.id as string) ?? '',
|
||||
text: (r.text as string) ?? '',
|
||||
reasonType: (r.reasonType as string) ?? '',
|
||||
isArchived: (r.isArchived as boolean) ?? false,
|
||||
})),
|
||||
},
|
||||
}
|
||||
@@ -72,7 +85,10 @@ export const listArchiveReasonsTool: ToolConfig<
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Archive reason UUID' },
|
||||
text: { type: 'string', description: 'Archive reason text' },
|
||||
reasonType: { type: 'string', description: 'Reason type' },
|
||||
reasonType: {
|
||||
type: 'string',
|
||||
description: 'Reason type (RejectedByCandidate, RejectedByOrg, Other)',
|
||||
},
|
||||
isArchived: { type: 'boolean', description: 'Whether the reason is archived' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,15 +2,24 @@ import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyListCandidateTagsParams {
|
||||
apiKey: string
|
||||
includeArchived?: boolean
|
||||
cursor?: string
|
||||
syncToken?: string
|
||||
perPage?: number
|
||||
}
|
||||
|
||||
interface AshbyCandidateTag {
|
||||
id: string
|
||||
title: string
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
interface AshbyListCandidateTagsResponse extends ToolResponse {
|
||||
output: {
|
||||
tags: Array<{
|
||||
id: string
|
||||
title: string
|
||||
isArchived: boolean
|
||||
}>
|
||||
tags: AshbyCandidateTag[]
|
||||
moreDataAvailable: boolean
|
||||
nextCursor: string | null
|
||||
syncToken: string | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +39,30 @@ export const listCandidateTagsTool: ToolConfig<
|
||||
visibility: 'user-only',
|
||||
description: 'Ashby API Key',
|
||||
},
|
||||
includeArchived: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to include archived candidate tags (default false)',
|
||||
},
|
||||
cursor: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Opaque pagination cursor from a previous response nextCursor value',
|
||||
},
|
||||
syncToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Sync token from a previous response to fetch only changed results',
|
||||
},
|
||||
perPage: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Number of results per page (default 100)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
@@ -39,7 +72,14 @@ export const listCandidateTagsTool: ToolConfig<
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
|
||||
}),
|
||||
body: () => ({}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {}
|
||||
if (params.includeArchived !== undefined) body.includeArchived = params.includeArchived
|
||||
if (params.cursor) body.cursor = params.cursor
|
||||
if (params.syncToken) body.syncToken = params.syncToken
|
||||
if (params.perPage) body.limit = params.perPage
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
@@ -53,10 +93,13 @@ export const listCandidateTagsTool: ToolConfig<
|
||||
success: true,
|
||||
output: {
|
||||
tags: (data.results ?? []).map((t: Record<string, unknown>) => ({
|
||||
id: t.id ?? null,
|
||||
title: t.title ?? null,
|
||||
isArchived: t.isArchived ?? false,
|
||||
id: (t.id as string) ?? '',
|
||||
title: (t.title as string) ?? '',
|
||||
isArchived: (t.isArchived as boolean) ?? false,
|
||||
})),
|
||||
moreDataAvailable: data.moreDataAvailable ?? false,
|
||||
nextCursor: data.nextCursor ?? null,
|
||||
syncToken: data.syncToken ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -74,5 +117,19 @@ export const listCandidateTagsTool: ToolConfig<
|
||||
},
|
||||
},
|
||||
},
|
||||
moreDataAvailable: {
|
||||
type: 'boolean',
|
||||
description: 'Whether more pages of results exist',
|
||||
},
|
||||
nextCursor: {
|
||||
type: 'string',
|
||||
description: 'Opaque cursor for fetching the next page',
|
||||
optional: true,
|
||||
},
|
||||
syncToken: {
|
||||
type: 'string',
|
||||
description: 'Sync token to use for incremental updates in future requests',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AshbyListCandidatesParams, AshbyListCandidatesResponse } from '@/tools/ashby/types'
|
||||
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { AshbyListCandidatesParams, AshbyListCandidatesResponse } from './types'
|
||||
|
||||
export const listCandidatesTool: ToolConfig<
|
||||
AshbyListCandidatesParams,
|
||||
@@ -56,33 +57,7 @@ export const listCandidatesTool: ToolConfig<
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
candidates: (data.results ?? []).map(
|
||||
(
|
||||
c: Record<string, unknown> & {
|
||||
primaryEmailAddress?: { value?: string; type?: string; isPrimary?: boolean }
|
||||
primaryPhoneNumber?: { value?: string; type?: string; isPrimary?: boolean }
|
||||
}
|
||||
) => ({
|
||||
id: c.id ?? null,
|
||||
name: c.name ?? null,
|
||||
primaryEmailAddress: c.primaryEmailAddress
|
||||
? {
|
||||
value: c.primaryEmailAddress.value ?? '',
|
||||
type: c.primaryEmailAddress.type ?? 'Other',
|
||||
isPrimary: c.primaryEmailAddress.isPrimary ?? true,
|
||||
}
|
||||
: null,
|
||||
primaryPhoneNumber: c.primaryPhoneNumber
|
||||
? {
|
||||
value: c.primaryPhoneNumber.value ?? '',
|
||||
type: c.primaryPhoneNumber.type ?? 'Other',
|
||||
isPrimary: c.primaryPhoneNumber.isPrimary ?? true,
|
||||
}
|
||||
: null,
|
||||
createdAt: c.createdAt ?? null,
|
||||
updatedAt: c.updatedAt ?? null,
|
||||
})
|
||||
),
|
||||
candidates: (data.results ?? []).map(mapCandidate),
|
||||
moreDataAvailable: data.moreDataAvailable ?? false,
|
||||
nextCursor: data.nextCursor ?? null,
|
||||
},
|
||||
@@ -95,32 +70,7 @@ export const listCandidatesTool: ToolConfig<
|
||||
description: 'List of candidates',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Candidate UUID' },
|
||||
name: { type: 'string', description: 'Full name' },
|
||||
primaryEmailAddress: {
|
||||
type: 'object',
|
||||
description: 'Primary email contact info',
|
||||
optional: true,
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Email address' },
|
||||
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether this is the primary email' },
|
||||
},
|
||||
},
|
||||
primaryPhoneNumber: {
|
||||
type: 'object',
|
||||
description: 'Primary phone contact info',
|
||||
optional: true,
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Phone number' },
|
||||
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' },
|
||||
},
|
||||
},
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
|
||||
},
|
||||
properties: CANDIDATE_OUTPUTS,
|
||||
},
|
||||
},
|
||||
moreDataAvailable: {
|
||||
|
||||
@@ -4,15 +4,24 @@ interface AshbyListCustomFieldsParams {
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
interface AshbyCustomFieldDefinition {
|
||||
id: string
|
||||
title: string
|
||||
isPrivate: boolean
|
||||
fieldType: string
|
||||
objectType: string
|
||||
isArchived: boolean
|
||||
isRequired: boolean
|
||||
selectableValues: Array<{
|
||||
label: string
|
||||
value: string
|
||||
isArchived: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
interface AshbyListCustomFieldsResponse extends ToolResponse {
|
||||
output: {
|
||||
customFields: Array<{
|
||||
id: string
|
||||
title: string
|
||||
fieldType: string
|
||||
objectType: string
|
||||
isArchived: boolean
|
||||
}>
|
||||
customFields: AshbyCustomFieldDefinition[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,13 +63,24 @@ export const listCustomFieldsTool: ToolConfig<
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
customFields: (data.results ?? []).map((f: Record<string, unknown>) => ({
|
||||
id: f.id ?? null,
|
||||
title: f.title ?? null,
|
||||
fieldType: f.fieldType ?? null,
|
||||
objectType: f.objectType ?? null,
|
||||
isArchived: f.isArchived ?? false,
|
||||
})),
|
||||
customFields: (data.results ?? []).map(
|
||||
(f: Record<string, unknown> & { selectableValues?: Array<Record<string, unknown>> }) => ({
|
||||
id: (f.id as string) ?? '',
|
||||
title: (f.title as string) ?? '',
|
||||
isPrivate: (f.isPrivate as boolean) ?? false,
|
||||
fieldType: (f.fieldType as string) ?? '',
|
||||
objectType: (f.objectType as string) ?? '',
|
||||
isArchived: (f.isArchived as boolean) ?? false,
|
||||
isRequired: (f.isRequired as boolean) ?? false,
|
||||
selectableValues: Array.isArray(f.selectableValues)
|
||||
? f.selectableValues.map((v) => ({
|
||||
label: (v.label as string) ?? '',
|
||||
value: (v.value as string) ?? '',
|
||||
isArchived: (v.isArchived as boolean) ?? false,
|
||||
}))
|
||||
: [],
|
||||
})
|
||||
),
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -74,12 +94,35 @@ export const listCustomFieldsTool: ToolConfig<
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Custom field UUID' },
|
||||
title: { type: 'string', description: 'Custom field title' },
|
||||
fieldType: { type: 'string', description: 'Field type (e.g. String, Number, Boolean)' },
|
||||
isPrivate: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the custom field is private',
|
||||
},
|
||||
fieldType: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Field data type (MultiValueSelect, NumberRange, String, Date, ValueSelect, Number, Currency, Boolean, LongText, CompensationRange)',
|
||||
},
|
||||
objectType: {
|
||||
type: 'string',
|
||||
description: 'Object type the field applies to (e.g. Candidate, Application, Job)',
|
||||
description:
|
||||
'Object type the field applies to (Application, Candidate, Employee, Job, Offer, Opening, Talent_Project)',
|
||||
},
|
||||
isArchived: { type: 'boolean', description: 'Whether the custom field is archived' },
|
||||
isRequired: { type: 'boolean', description: 'Whether a value is required' },
|
||||
selectableValues: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Selectable values for MultiValueSelect fields (empty for other field types)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', description: 'Display label' },
|
||||
value: { type: 'string', description: 'Stored value' },
|
||||
isArchived: { type: 'boolean', description: 'Whether archived' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,14 +4,19 @@ interface AshbyListDepartmentsParams {
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
interface AshbyDepartment {
|
||||
id: string
|
||||
name: string
|
||||
externalName: string | null
|
||||
isArchived: boolean
|
||||
parentId: string | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
interface AshbyListDepartmentsResponse extends ToolResponse {
|
||||
output: {
|
||||
departments: Array<{
|
||||
id: string
|
||||
name: string
|
||||
isArchived: boolean
|
||||
parentId: string | null
|
||||
}>
|
||||
departments: AshbyDepartment[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,10 +59,13 @@ export const listDepartmentsTool: ToolConfig<
|
||||
success: true,
|
||||
output: {
|
||||
departments: (data.results ?? []).map((d: Record<string, unknown>) => ({
|
||||
id: d.id ?? null,
|
||||
name: d.name ?? null,
|
||||
isArchived: d.isArchived ?? false,
|
||||
id: (d.id as string) ?? '',
|
||||
name: (d.name as string) ?? '',
|
||||
externalName: (d.externalName as string) ?? null,
|
||||
isArchived: (d.isArchived as boolean) ?? false,
|
||||
parentId: (d.parentId as string) ?? null,
|
||||
createdAt: (d.createdAt as string) ?? null,
|
||||
updatedAt: (d.updatedAt as string) ?? null,
|
||||
})),
|
||||
},
|
||||
}
|
||||
@@ -72,12 +80,27 @@ export const listDepartmentsTool: ToolConfig<
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Department UUID' },
|
||||
name: { type: 'string', description: 'Department name' },
|
||||
externalName: {
|
||||
type: 'string',
|
||||
description: 'Candidate-facing name used on job boards',
|
||||
optional: true,
|
||||
},
|
||||
isArchived: { type: 'boolean', description: 'Whether the department is archived' },
|
||||
parentId: {
|
||||
type: 'string',
|
||||
description: 'Parent department UUID',
|
||||
optional: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
description: 'ISO 8601 creation timestamp',
|
||||
optional: true,
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
description: 'ISO 8601 last update timestamp',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AshbyUserSummary } from '@/tools/ashby/types'
|
||||
import { mapUserSummary, USER_SUMMARY_OUTPUT } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyListInterviewSchedulesParams {
|
||||
@@ -8,20 +10,81 @@ interface AshbyListInterviewSchedulesParams {
|
||||
perPage?: number
|
||||
}
|
||||
|
||||
interface AshbyInterviewEvent {
|
||||
id: string
|
||||
interviewId: string | null
|
||||
interviewScheduleId: string | null
|
||||
interviewerUserIds: string[]
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
feedbackLink: string | null
|
||||
location: string | null
|
||||
meetingLink: string | null
|
||||
hasSubmittedFeedback: boolean
|
||||
}
|
||||
|
||||
interface AshbyInterviewSchedule {
|
||||
id: string
|
||||
status: string | null
|
||||
applicationId: string
|
||||
interviewStageId: string | null
|
||||
scheduledBy: AshbyUserSummary | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
interviewEvents: AshbyInterviewEvent[]
|
||||
}
|
||||
|
||||
interface AshbyListInterviewSchedulesResponse extends ToolResponse {
|
||||
output: {
|
||||
interviewSchedules: Array<{
|
||||
id: string
|
||||
applicationId: string
|
||||
interviewStageId: string | null
|
||||
status: string | null
|
||||
createdAt: string
|
||||
}>
|
||||
interviewSchedules: AshbyInterviewSchedule[]
|
||||
moreDataAvailable: boolean
|
||||
nextCursor: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
|
||||
function mapInterviewEvent(raw: unknown): AshbyInterviewEvent | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const e = raw as UnknownRecord
|
||||
return {
|
||||
id: (e.id as string) ?? '',
|
||||
interviewId: (e.interviewId as string) ?? null,
|
||||
interviewScheduleId: (e.interviewScheduleId as string) ?? null,
|
||||
interviewerUserIds: Array.isArray(e.interviewerUserIds)
|
||||
? (e.interviewerUserIds as string[])
|
||||
: [],
|
||||
createdAt: (e.createdAt as string) ?? null,
|
||||
updatedAt: (e.updatedAt as string) ?? null,
|
||||
startTime: (e.startTime as string) ?? null,
|
||||
endTime: (e.endTime as string) ?? null,
|
||||
feedbackLink: (e.feedbackLink as string) ?? null,
|
||||
location: (e.location as string) ?? null,
|
||||
meetingLink: (e.meetingLink as string) ?? null,
|
||||
hasSubmittedFeedback: (e.hasSubmittedFeedback as boolean) ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
function mapInterviewSchedule(raw: unknown): AshbyInterviewSchedule {
|
||||
const s = (raw ?? {}) as UnknownRecord
|
||||
return {
|
||||
id: (s.id as string) ?? '',
|
||||
status: (s.status as string) ?? null,
|
||||
applicationId: (s.applicationId as string) ?? '',
|
||||
interviewStageId: (s.interviewStageId as string) ?? null,
|
||||
scheduledBy: mapUserSummary(s.scheduledBy),
|
||||
createdAt: (s.createdAt as string) ?? null,
|
||||
updatedAt: (s.updatedAt as string) ?? null,
|
||||
interviewEvents: Array.isArray(s.interviewEvents)
|
||||
? (s.interviewEvents as unknown[])
|
||||
.map(mapInterviewEvent)
|
||||
.filter((e): e is AshbyInterviewEvent => e !== null)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
export const listInterviewsTool: ToolConfig<
|
||||
AshbyListInterviewSchedulesParams,
|
||||
AshbyListInterviewSchedulesResponse
|
||||
@@ -74,8 +137,8 @@ export const listInterviewsTool: ToolConfig<
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {}
|
||||
if (params.applicationId) body.applicationId = params.applicationId
|
||||
if (params.interviewStageId) body.interviewStageId = params.interviewStageId
|
||||
if (params.applicationId) body.applicationId = params.applicationId.trim()
|
||||
if (params.interviewStageId) body.interviewStageId = params.interviewStageId.trim()
|
||||
if (params.cursor) body.cursor = params.cursor
|
||||
if (params.perPage) body.limit = params.perPage
|
||||
return body
|
||||
@@ -92,13 +155,7 @@ export const listInterviewsTool: ToolConfig<
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
interviewSchedules: (data.results ?? []).map((s: Record<string, unknown>) => ({
|
||||
id: s.id ?? null,
|
||||
applicationId: s.applicationId ?? null,
|
||||
interviewStageId: s.interviewStageId ?? null,
|
||||
status: s.status ?? null,
|
||||
createdAt: s.createdAt ?? null,
|
||||
})),
|
||||
interviewSchedules: (data.results ?? []).map(mapInterviewSchedule),
|
||||
moreDataAvailable: data.moreDataAvailable ?? false,
|
||||
nextCursor: data.nextCursor ?? null,
|
||||
},
|
||||
@@ -113,14 +170,92 @@ export const listInterviewsTool: ToolConfig<
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Interview schedule UUID' },
|
||||
status: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Schedule status (NeedsScheduling, WaitingOnCandidateBooking, Scheduled, Complete, Cancelled, OnHold, etc.)',
|
||||
optional: true,
|
||||
},
|
||||
applicationId: { type: 'string', description: 'Associated application UUID' },
|
||||
interviewStageId: {
|
||||
type: 'string',
|
||||
description: 'Interview stage UUID',
|
||||
optional: true,
|
||||
},
|
||||
status: { type: 'string', description: 'Schedule status', optional: true },
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
scheduledBy: {
|
||||
...USER_SUMMARY_OUTPUT,
|
||||
description: 'User who scheduled the interview (null if not yet scheduled)',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
description: 'ISO 8601 creation timestamp',
|
||||
optional: true,
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
description: 'ISO 8601 last update timestamp',
|
||||
optional: true,
|
||||
},
|
||||
interviewEvents: {
|
||||
type: 'array',
|
||||
description: 'Scheduled interview events on this schedule',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Event UUID' },
|
||||
interviewId: {
|
||||
type: 'string',
|
||||
description: 'Interview template UUID',
|
||||
optional: true,
|
||||
},
|
||||
interviewScheduleId: {
|
||||
type: 'string',
|
||||
description: 'Parent schedule UUID',
|
||||
optional: true,
|
||||
},
|
||||
interviewerUserIds: {
|
||||
type: 'array',
|
||||
description: 'User UUIDs of interviewers assigned to the event',
|
||||
items: { type: 'string', description: 'User UUID' },
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
description: 'Event creation timestamp',
|
||||
optional: true,
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
description: 'Event last updated timestamp',
|
||||
optional: true,
|
||||
},
|
||||
startTime: {
|
||||
type: 'string',
|
||||
description: 'Event start time',
|
||||
optional: true,
|
||||
},
|
||||
endTime: { type: 'string', description: 'Event end time', optional: true },
|
||||
feedbackLink: {
|
||||
type: 'string',
|
||||
description: 'URL to submit feedback for the event',
|
||||
optional: true,
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'Physical location',
|
||||
optional: true,
|
||||
},
|
||||
meetingLink: {
|
||||
type: 'string',
|
||||
description: 'Virtual meeting URL',
|
||||
optional: true,
|
||||
},
|
||||
hasSubmittedFeedback: {
|
||||
type: 'boolean',
|
||||
description: 'Whether any feedback has been submitted',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,18 +4,32 @@ interface AshbyListJobPostingsParams {
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
interface AshbyJobPostingSummary {
|
||||
id: string
|
||||
title: string
|
||||
jobId: string | null
|
||||
departmentName: string | null
|
||||
teamName: string | null
|
||||
locationName: string | null
|
||||
locationIds: {
|
||||
primaryLocationId: string | null
|
||||
secondaryLocationIds: string[]
|
||||
} | null
|
||||
workplaceType: string | null
|
||||
employmentType: string | null
|
||||
isListed: boolean
|
||||
publishedDate: string | null
|
||||
applicationDeadline: string | null
|
||||
externalLink: string | null
|
||||
applyLink: string | null
|
||||
compensationTierSummary: string | null
|
||||
shouldDisplayCompensationOnJobBoard: boolean
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
interface AshbyListJobPostingsResponse extends ToolResponse {
|
||||
output: {
|
||||
jobPostings: Array<{
|
||||
id: string
|
||||
title: string
|
||||
jobId: string | null
|
||||
locationName: string | null
|
||||
departmentName: string | null
|
||||
employmentType: string | null
|
||||
isListed: boolean
|
||||
publishedDate: string | null
|
||||
}>
|
||||
jobPostings: AshbyJobPostingSummary[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,16 +71,39 @@ export const listJobPostingsTool: ToolConfig<
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
jobPostings: (data.results ?? []).map((jp: Record<string, unknown>) => ({
|
||||
id: jp.id ?? null,
|
||||
title: (jp.jobTitle as string) ?? (jp.title as string) ?? null,
|
||||
jobId: jp.jobId ?? null,
|
||||
locationName: jp.locationName ?? null,
|
||||
departmentName: jp.departmentName ?? null,
|
||||
employmentType: jp.employmentType ?? null,
|
||||
isListed: jp.isListed ?? false,
|
||||
publishedDate: jp.publishedDate ?? null,
|
||||
})),
|
||||
jobPostings: (data.results ?? []).map(
|
||||
(
|
||||
jp: Record<string, unknown> & {
|
||||
locationIds?: { primaryLocationId?: string; secondaryLocationIds?: string[] }
|
||||
}
|
||||
) => ({
|
||||
id: (jp.id as string) ?? '',
|
||||
title: (jp.title as string) ?? '',
|
||||
jobId: (jp.jobId as string) ?? null,
|
||||
departmentName: (jp.departmentName as string) ?? null,
|
||||
teamName: (jp.teamName as string) ?? null,
|
||||
locationName: (jp.locationName as string) ?? null,
|
||||
locationIds: jp.locationIds
|
||||
? {
|
||||
primaryLocationId: jp.locationIds.primaryLocationId ?? null,
|
||||
secondaryLocationIds: Array.isArray(jp.locationIds.secondaryLocationIds)
|
||||
? jp.locationIds.secondaryLocationIds
|
||||
: [],
|
||||
}
|
||||
: null,
|
||||
workplaceType: (jp.workplaceType as string) ?? null,
|
||||
employmentType: (jp.employmentType as string) ?? null,
|
||||
isListed: (jp.isListed as boolean) ?? false,
|
||||
publishedDate: (jp.publishedDate as string) ?? null,
|
||||
applicationDeadline: (jp.applicationDeadline as string) ?? null,
|
||||
externalLink: (jp.externalLink as string) ?? null,
|
||||
applyLink: (jp.applyLink as string) ?? null,
|
||||
compensationTierSummary: (jp.compensationTierSummary as string) ?? null,
|
||||
shouldDisplayCompensationOnJobBoard:
|
||||
(jp.shouldDisplayCompensationOnJobBoard as boolean) ?? false,
|
||||
updatedAt: (jp.updatedAt as string) ?? null,
|
||||
})
|
||||
),
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -81,15 +118,75 @@ export const listJobPostingsTool: ToolConfig<
|
||||
id: { type: 'string', description: 'Job posting UUID' },
|
||||
title: { type: 'string', description: 'Job posting title' },
|
||||
jobId: { type: 'string', description: 'Associated job UUID', optional: true },
|
||||
locationName: { type: 'string', description: 'Location name', optional: true },
|
||||
departmentName: { type: 'string', description: 'Department name', optional: true },
|
||||
teamName: { type: 'string', description: 'Team name', optional: true },
|
||||
locationName: {
|
||||
type: 'string',
|
||||
description: 'Primary location display name',
|
||||
optional: true,
|
||||
},
|
||||
locationIds: {
|
||||
type: 'object',
|
||||
description: 'Primary and secondary location UUIDs',
|
||||
optional: true,
|
||||
properties: {
|
||||
primaryLocationId: {
|
||||
type: 'string',
|
||||
description: 'Primary location UUID',
|
||||
optional: true,
|
||||
},
|
||||
secondaryLocationIds: {
|
||||
type: 'array',
|
||||
description: 'Secondary location UUIDs',
|
||||
items: { type: 'string', description: 'Location UUID' },
|
||||
},
|
||||
},
|
||||
},
|
||||
workplaceType: {
|
||||
type: 'string',
|
||||
description: 'Workplace type (OnSite, Remote, Hybrid)',
|
||||
optional: true,
|
||||
},
|
||||
employmentType: {
|
||||
type: 'string',
|
||||
description: 'Employment type (e.g. FullTime, PartTime, Contract)',
|
||||
description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)',
|
||||
optional: true,
|
||||
},
|
||||
isListed: { type: 'boolean', description: 'Whether the posting is publicly listed' },
|
||||
publishedDate: { type: 'string', description: 'ISO 8601 published date', optional: true },
|
||||
publishedDate: {
|
||||
type: 'string',
|
||||
description: 'ISO 8601 published date',
|
||||
optional: true,
|
||||
},
|
||||
applicationDeadline: {
|
||||
type: 'string',
|
||||
description: 'ISO 8601 application deadline',
|
||||
optional: true,
|
||||
},
|
||||
externalLink: {
|
||||
type: 'string',
|
||||
description: 'External link to the job posting',
|
||||
optional: true,
|
||||
},
|
||||
applyLink: {
|
||||
type: 'string',
|
||||
description: 'Direct apply link for the job posting',
|
||||
optional: true,
|
||||
},
|
||||
compensationTierSummary: {
|
||||
type: 'string',
|
||||
description: 'Compensation tier summary for job boards',
|
||||
optional: true,
|
||||
},
|
||||
shouldDisplayCompensationOnJobBoard: {
|
||||
type: 'boolean',
|
||||
description: 'Whether compensation is shown on the job board',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
description: 'ISO 8601 last update timestamp',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AshbyListJobsParams, AshbyListJobsResponse } from '@/tools/ashby/types'
|
||||
import { JOB_OUTPUTS, mapJob } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { AshbyListJobsParams, AshbyListJobsResponse } from './types'
|
||||
|
||||
export const listJobsTool: ToolConfig<AshbyListJobsParams, AshbyListJobsResponse> = {
|
||||
id: 'ashby_list_jobs',
|
||||
@@ -61,16 +62,7 @@ export const listJobsTool: ToolConfig<AshbyListJobsParams, AshbyListJobsResponse
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
jobs: (data.results ?? []).map((j: Record<string, unknown>) => ({
|
||||
id: j.id ?? null,
|
||||
title: j.title ?? null,
|
||||
status: j.status ?? null,
|
||||
employmentType: j.employmentType ?? null,
|
||||
departmentId: j.departmentId ?? null,
|
||||
locationId: j.locationId ?? null,
|
||||
createdAt: j.createdAt ?? null,
|
||||
updatedAt: j.updatedAt ?? null,
|
||||
})),
|
||||
jobs: (data.results ?? []).map(mapJob),
|
||||
moreDataAvailable: data.moreDataAvailable ?? false,
|
||||
nextCursor: data.nextCursor ?? null,
|
||||
},
|
||||
@@ -83,20 +75,7 @@ export const listJobsTool: ToolConfig<AshbyListJobsParams, AshbyListJobsResponse
|
||||
description: 'List of jobs',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
status: { type: 'string', description: 'Job status (Open, Closed, Archived, Draft)' },
|
||||
employmentType: {
|
||||
type: 'string',
|
||||
description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)',
|
||||
optional: true,
|
||||
},
|
||||
departmentId: { type: 'string', description: 'Department UUID', optional: true },
|
||||
locationId: { type: 'string', description: 'Location UUID', optional: true },
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
|
||||
},
|
||||
properties: JOB_OUTPUTS,
|
||||
},
|
||||
},
|
||||
moreDataAvailable: {
|
||||
|
||||
@@ -4,19 +4,27 @@ interface AshbyListLocationsParams {
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
interface AshbyLocation {
|
||||
id: string
|
||||
name: string
|
||||
externalName: string | null
|
||||
isArchived: boolean
|
||||
isRemote: boolean
|
||||
workplaceType: string | null
|
||||
parentLocationId: string | null
|
||||
type: string | null
|
||||
address: {
|
||||
addressCountry: string | null
|
||||
addressRegion: string | null
|
||||
addressLocality: string | null
|
||||
postalCode: string | null
|
||||
streetAddress: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface AshbyListLocationsResponse extends ToolResponse {
|
||||
output: {
|
||||
locations: Array<{
|
||||
id: string
|
||||
name: string
|
||||
isArchived: boolean
|
||||
isRemote: boolean
|
||||
address: {
|
||||
city: string | null
|
||||
region: string | null
|
||||
country: string | null
|
||||
} | null
|
||||
}>
|
||||
locations: AshbyLocation[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,27 +66,30 @@ export const listLocationsTool: ToolConfig<AshbyListLocationsParams, AshbyListLo
|
||||
locations: (data.results ?? []).map(
|
||||
(
|
||||
l: Record<string, unknown> & {
|
||||
address?: {
|
||||
postalAddress?: {
|
||||
addressLocality?: string
|
||||
addressRegion?: string
|
||||
addressCountry?: string
|
||||
}
|
||||
}
|
||||
address?: { postalAddress?: Record<string, unknown> }
|
||||
}
|
||||
) => ({
|
||||
id: l.id ?? null,
|
||||
name: l.name ?? null,
|
||||
isArchived: l.isArchived ?? false,
|
||||
isRemote: l.isRemote ?? false,
|
||||
address: l.address?.postalAddress
|
||||
? {
|
||||
city: l.address.postalAddress.addressLocality ?? null,
|
||||
region: l.address.postalAddress.addressRegion ?? null,
|
||||
country: l.address.postalAddress.addressCountry ?? null,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
) => {
|
||||
const pa = l.address?.postalAddress
|
||||
return {
|
||||
id: (l.id as string) ?? '',
|
||||
name: (l.name as string) ?? '',
|
||||
externalName: (l.externalName as string) ?? null,
|
||||
isArchived: (l.isArchived as boolean) ?? false,
|
||||
isRemote: (l.isRemote as boolean) ?? false,
|
||||
workplaceType: (l.workplaceType as string) ?? null,
|
||||
parentLocationId: (l.parentLocationId as string) ?? null,
|
||||
type: (l.type as string) ?? null,
|
||||
address: pa
|
||||
? {
|
||||
addressCountry: (pa.addressCountry as string) ?? null,
|
||||
addressRegion: (pa.addressRegion as string) ?? null,
|
||||
addressLocality: (pa.addressLocality as string) ?? null,
|
||||
postalCode: (pa.postalCode as string) ?? null,
|
||||
streetAddress: (pa.streetAddress as string) ?? null,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -93,16 +104,49 @@ export const listLocationsTool: ToolConfig<AshbyListLocationsParams, AshbyListLo
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Location UUID' },
|
||||
name: { type: 'string', description: 'Location name' },
|
||||
externalName: {
|
||||
type: 'string',
|
||||
description: 'Candidate-facing name used on job boards',
|
||||
optional: true,
|
||||
},
|
||||
isArchived: { type: 'boolean', description: 'Whether the location is archived' },
|
||||
isRemote: { type: 'boolean', description: 'Whether this is a remote location' },
|
||||
isRemote: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the location is remote (use workplaceType instead)',
|
||||
},
|
||||
workplaceType: {
|
||||
type: 'string',
|
||||
description: 'Workplace type (OnSite, Hybrid, Remote)',
|
||||
optional: true,
|
||||
},
|
||||
parentLocationId: {
|
||||
type: 'string',
|
||||
description: 'Parent location UUID',
|
||||
optional: true,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'Location component type (Location, LocationHierarchy)',
|
||||
optional: true,
|
||||
},
|
||||
address: {
|
||||
type: 'object',
|
||||
description: 'Location address',
|
||||
description: 'Location postal address',
|
||||
optional: true,
|
||||
properties: {
|
||||
city: { type: 'string', description: 'City', optional: true },
|
||||
region: { type: 'string', description: 'State or region', optional: true },
|
||||
country: { type: 'string', description: 'Country', optional: true },
|
||||
addressCountry: { type: 'string', description: 'Country', optional: true },
|
||||
addressRegion: {
|
||||
type: 'string',
|
||||
description: 'State or region',
|
||||
optional: true,
|
||||
},
|
||||
addressLocality: {
|
||||
type: 'string',
|
||||
description: 'City or locality',
|
||||
optional: true,
|
||||
},
|
||||
postalCode: { type: 'string', description: 'Postal code', optional: true },
|
||||
streetAddress: { type: 'string', description: 'Street address', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,14 +11,15 @@ interface AshbyListNotesResponse extends ToolResponse {
|
||||
output: {
|
||||
notes: Array<{
|
||||
id: string
|
||||
content: string
|
||||
content: string | null
|
||||
isPrivate: boolean
|
||||
author: {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
email: string | null
|
||||
} | null
|
||||
createdAt: string
|
||||
createdAt: string | null
|
||||
}>
|
||||
moreDataAvailable: boolean
|
||||
nextCursor: string | null
|
||||
@@ -67,7 +68,7 @@ export const listNotesTool: ToolConfig<AshbyListNotesParams, AshbyListNotesRespo
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {
|
||||
candidateId: params.candidateId,
|
||||
candidateId: params.candidateId.trim(),
|
||||
}
|
||||
if (params.cursor) body.cursor = params.cursor
|
||||
if (params.perPage) body.limit = params.perPage
|
||||
@@ -91,17 +92,18 @@ export const listNotesTool: ToolConfig<AshbyListNotesParams, AshbyListNotesRespo
|
||||
author?: { id?: string; firstName?: string; lastName?: string; email?: string }
|
||||
}
|
||||
) => ({
|
||||
id: n.id ?? null,
|
||||
content: n.content ?? null,
|
||||
id: (n.id as string) ?? '',
|
||||
content: (n.content as string) ?? null,
|
||||
isPrivate: (n.isPrivate as boolean) ?? false,
|
||||
author: n.author
|
||||
? {
|
||||
id: n.author.id ?? null,
|
||||
id: n.author.id ?? '',
|
||||
firstName: n.author.firstName ?? null,
|
||||
lastName: n.author.lastName ?? null,
|
||||
email: n.author.email ?? null,
|
||||
}
|
||||
: null,
|
||||
createdAt: n.createdAt ?? null,
|
||||
createdAt: (n.createdAt as string) ?? null,
|
||||
})
|
||||
),
|
||||
moreDataAvailable: data.moreDataAvailable ?? false,
|
||||
@@ -118,19 +120,20 @@ export const listNotesTool: ToolConfig<AshbyListNotesParams, AshbyListNotesRespo
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Note UUID' },
|
||||
content: { type: 'string', description: 'Note content' },
|
||||
content: { type: 'string', description: 'Note content', optional: true },
|
||||
isPrivate: { type: 'boolean', description: 'Whether the note is private' },
|
||||
author: {
|
||||
type: 'object',
|
||||
description: 'Note author',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Author user UUID' },
|
||||
firstName: { type: 'string', description: 'First name' },
|
||||
lastName: { type: 'string', description: 'Last name' },
|
||||
email: { type: 'string', description: 'Email address' },
|
||||
firstName: { type: 'string', description: 'First name', optional: true },
|
||||
lastName: { type: 'string', description: 'Last name', optional: true },
|
||||
email: { type: 'string', description: 'Email address', optional: true },
|
||||
},
|
||||
},
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AshbyOffer } from '@/tools/ashby/types'
|
||||
import { mapOffer, OFFER_OUTPUTS } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyListOffersParams {
|
||||
@@ -8,19 +10,7 @@ interface AshbyListOffersParams {
|
||||
|
||||
interface AshbyListOffersResponse extends ToolResponse {
|
||||
output: {
|
||||
offers: Array<{
|
||||
id: string
|
||||
offerStatus: string
|
||||
acceptanceStatus: string | null
|
||||
applicationId: string | null
|
||||
startDate: string | null
|
||||
salary: {
|
||||
currencyCode: string
|
||||
value: number
|
||||
} | null
|
||||
openingId: string | null
|
||||
createdAt: string | null
|
||||
}>
|
||||
offers: AshbyOffer[]
|
||||
moreDataAvailable: boolean
|
||||
nextCursor: string | null
|
||||
}
|
||||
@@ -78,35 +68,7 @@ export const listOffersTool: ToolConfig<AshbyListOffersParams, AshbyListOffersRe
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
offers: (data.results ?? []).map(
|
||||
(
|
||||
o: Record<string, unknown> & {
|
||||
latestVersion?: {
|
||||
startDate?: string
|
||||
salary?: { currencyCode?: string; value?: number }
|
||||
openingId?: string
|
||||
createdAt?: string
|
||||
}
|
||||
}
|
||||
) => {
|
||||
const v = o.latestVersion
|
||||
return {
|
||||
id: o.id ?? null,
|
||||
offerStatus: o.offerStatus ?? null,
|
||||
acceptanceStatus: o.acceptanceStatus ?? null,
|
||||
applicationId: o.applicationId ?? null,
|
||||
startDate: v?.startDate ?? null,
|
||||
salary: v?.salary
|
||||
? {
|
||||
currencyCode: v.salary.currencyCode ?? null,
|
||||
value: v.salary.value ?? null,
|
||||
}
|
||||
: null,
|
||||
openingId: v?.openingId ?? null,
|
||||
createdAt: v?.createdAt ?? null,
|
||||
}
|
||||
}
|
||||
),
|
||||
offers: (data.results ?? []).map(mapOffer),
|
||||
moreDataAvailable: data.moreDataAvailable ?? false,
|
||||
nextCursor: data.nextCursor ?? null,
|
||||
},
|
||||
@@ -119,28 +81,7 @@ export const listOffersTool: ToolConfig<AshbyListOffersParams, AshbyListOffersRe
|
||||
description: 'List of offers',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Offer UUID' },
|
||||
offerStatus: { type: 'string', description: 'Offer status' },
|
||||
acceptanceStatus: { type: 'string', description: 'Acceptance status', optional: true },
|
||||
applicationId: {
|
||||
type: 'string',
|
||||
description: 'Associated application UUID',
|
||||
optional: true,
|
||||
},
|
||||
startDate: { type: 'string', description: 'Offer start date', optional: true },
|
||||
salary: {
|
||||
type: 'object',
|
||||
description: 'Salary details',
|
||||
optional: true,
|
||||
properties: {
|
||||
currencyCode: { type: 'string', description: 'ISO 4217 currency code' },
|
||||
value: { type: 'number', description: 'Salary amount' },
|
||||
},
|
||||
},
|
||||
openingId: { type: 'string', description: 'Associated opening UUID', optional: true },
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp', optional: true },
|
||||
},
|
||||
properties: OFFER_OUTPUTS,
|
||||
},
|
||||
},
|
||||
moreDataAvailable: {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AshbyOpening } from '@/tools/ashby/types'
|
||||
import { mapOpenings, OPENINGS_OUTPUT } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyListOpeningsParams {
|
||||
@@ -8,13 +10,7 @@ interface AshbyListOpeningsParams {
|
||||
|
||||
interface AshbyListOpeningsResponse extends ToolResponse {
|
||||
output: {
|
||||
openings: Array<{
|
||||
id: string
|
||||
openingState: string | null
|
||||
isArchived: boolean
|
||||
openedAt: string | null
|
||||
closedAt: string | null
|
||||
}>
|
||||
openings: AshbyOpening[]
|
||||
moreDataAvailable: boolean
|
||||
nextCursor: string | null
|
||||
}
|
||||
@@ -72,13 +68,7 @@ export const listOpeningsTool: ToolConfig<AshbyListOpeningsParams, AshbyListOpen
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
openings: (data.results ?? []).map((o: Record<string, unknown>) => ({
|
||||
id: o.id ?? null,
|
||||
openingState: o.openingState ?? null,
|
||||
isArchived: o.isArchived ?? false,
|
||||
openedAt: o.openedAt ?? null,
|
||||
closedAt: o.closedAt ?? null,
|
||||
})),
|
||||
openings: mapOpenings(data.results),
|
||||
moreDataAvailable: data.moreDataAvailable ?? false,
|
||||
nextCursor: data.nextCursor ?? null,
|
||||
},
|
||||
@@ -86,24 +76,7 @@ export const listOpeningsTool: ToolConfig<AshbyListOpeningsParams, AshbyListOpen
|
||||
},
|
||||
|
||||
outputs: {
|
||||
openings: {
|
||||
type: 'array',
|
||||
description: 'List of openings',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Opening UUID' },
|
||||
openingState: {
|
||||
type: 'string',
|
||||
description: 'Opening state (Approved, Closed, Draft, Filled, Open)',
|
||||
optional: true,
|
||||
},
|
||||
isArchived: { type: 'boolean', description: 'Whether the opening is archived' },
|
||||
openedAt: { type: 'string', description: 'ISO 8601 opened timestamp', optional: true },
|
||||
closedAt: { type: 'string', description: 'ISO 8601 closed timestamp', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
openings: OPENINGS_OUTPUT,
|
||||
moreDataAvailable: {
|
||||
type: 'boolean',
|
||||
description: 'Whether more pages of results exist',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AshbySourceSummary } from '@/tools/ashby/types'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyListSourcesParams {
|
||||
@@ -6,11 +7,7 @@ interface AshbyListSourcesParams {
|
||||
|
||||
interface AshbyListSourcesResponse extends ToolResponse {
|
||||
output: {
|
||||
sources: Array<{
|
||||
id: string
|
||||
title: string
|
||||
isArchived: boolean
|
||||
}>
|
||||
sources: AshbySourceSummary[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,11 +46,23 @@ export const listSourcesTool: ToolConfig<AshbyListSourcesParams, AshbyListSource
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
sources: (data.results ?? []).map((s: Record<string, unknown>) => ({
|
||||
id: s.id ?? null,
|
||||
title: s.title ?? null,
|
||||
isArchived: s.isArchived ?? false,
|
||||
})),
|
||||
sources: (data.results ?? []).map(
|
||||
(s: Record<string, unknown> & { sourceType?: Record<string, unknown> }) => {
|
||||
const sourceType = s.sourceType
|
||||
return {
|
||||
id: (s.id as string) ?? '',
|
||||
title: (s.title as string) ?? '',
|
||||
isArchived: (s.isArchived as boolean) ?? false,
|
||||
sourceType: sourceType
|
||||
? {
|
||||
id: (sourceType.id as string) ?? '',
|
||||
title: (sourceType.title as string) ?? '',
|
||||
isArchived: (sourceType.isArchived as boolean) ?? false,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
),
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -68,6 +77,16 @@ export const listSourcesTool: ToolConfig<AshbyListSourcesParams, AshbyListSource
|
||||
id: { type: 'string', description: 'Source UUID' },
|
||||
title: { type: 'string', description: 'Source title' },
|
||||
isArchived: { type: 'boolean', description: 'Whether the source is archived' },
|
||||
sourceType: {
|
||||
type: 'object',
|
||||
description: 'Source type grouping',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Source type UUID' },
|
||||
title: { type: 'string', description: 'Source type title' },
|
||||
isArchived: { type: 'boolean', description: 'Whether archived' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AshbyUserSummary } from '@/tools/ashby/types'
|
||||
import { mapUserSummary, USER_SUMMARY_OUTPUT } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyListUsersParams {
|
||||
@@ -8,14 +10,7 @@ interface AshbyListUsersParams {
|
||||
|
||||
interface AshbyListUsersResponse extends ToolResponse {
|
||||
output: {
|
||||
users: Array<{
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
isEnabled: boolean
|
||||
globalRole: string | null
|
||||
}>
|
||||
users: AshbyUserSummary[]
|
||||
moreDataAvailable: boolean
|
||||
nextCursor: string | null
|
||||
}
|
||||
@@ -73,14 +68,9 @@ export const listUsersTool: ToolConfig<AshbyListUsersParams, AshbyListUsersRespo
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
users: (data.results ?? []).map((u: Record<string, unknown>) => ({
|
||||
id: u.id ?? null,
|
||||
firstName: u.firstName ?? null,
|
||||
lastName: u.lastName ?? null,
|
||||
email: u.email ?? null,
|
||||
isEnabled: u.isEnabled ?? false,
|
||||
globalRole: u.globalRole ?? null,
|
||||
})),
|
||||
users: (data.results ?? [])
|
||||
.map(mapUserSummary)
|
||||
.filter((u: AshbyUserSummary | null): u is AshbyUserSummary => u !== null),
|
||||
moreDataAvailable: data.moreDataAvailable ?? false,
|
||||
nextCursor: data.nextCursor ?? null,
|
||||
},
|
||||
@@ -93,19 +83,7 @@ export const listUsersTool: ToolConfig<AshbyListUsersParams, AshbyListUsersRespo
|
||||
description: 'List of users',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'User UUID' },
|
||||
firstName: { type: 'string', description: 'First name' },
|
||||
lastName: { type: 'string', description: 'Last name' },
|
||||
email: { type: 'string', description: 'Email address' },
|
||||
isEnabled: { type: 'boolean', description: 'Whether the user account is enabled' },
|
||||
globalRole: {
|
||||
type: 'string',
|
||||
description:
|
||||
'User role (Organization Admin, Elevated Access, Limited Access, External Recruiter)',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
properties: USER_SUMMARY_OUTPUT.properties,
|
||||
},
|
||||
},
|
||||
moreDataAvailable: {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AshbyCandidate } from '@/tools/ashby/types'
|
||||
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface AshbyRemoveCandidateTagParams {
|
||||
@@ -7,9 +9,7 @@ interface AshbyRemoveCandidateTagParams {
|
||||
}
|
||||
|
||||
interface AshbyRemoveCandidateTagResponse extends ToolResponse {
|
||||
output: {
|
||||
success: boolean
|
||||
}
|
||||
output: AshbyCandidate
|
||||
}
|
||||
|
||||
export const removeCandidateTagTool: ToolConfig<
|
||||
@@ -18,7 +18,7 @@ export const removeCandidateTagTool: ToolConfig<
|
||||
> = {
|
||||
id: 'ashby_remove_candidate_tag',
|
||||
name: 'Ashby Remove Candidate Tag',
|
||||
description: 'Removes a tag from a candidate in Ashby.',
|
||||
description: 'Removes a tag from a candidate in Ashby and returns the updated candidate.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
@@ -50,8 +50,8 @@ export const removeCandidateTagTool: ToolConfig<
|
||||
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
|
||||
}),
|
||||
body: (params) => ({
|
||||
candidateId: params.candidateId,
|
||||
tagId: params.tagId,
|
||||
candidateId: params.candidateId.trim(),
|
||||
tagId: params.tagId.trim(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -64,13 +64,9 @@ export const removeCandidateTagTool: ToolConfig<
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
},
|
||||
output: mapCandidate(data.results),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the tag was successfully removed' },
|
||||
},
|
||||
outputs: CANDIDATE_OUTPUTS,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type {
|
||||
AshbySearchCandidatesParams,
|
||||
AshbySearchCandidatesResponse,
|
||||
} from '@/tools/ashby/types'
|
||||
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { AshbySearchCandidatesParams, AshbySearchCandidatesResponse } from './types'
|
||||
|
||||
export const searchCandidatesTool: ToolConfig<
|
||||
AshbySearchCandidatesParams,
|
||||
@@ -57,33 +61,7 @@ export const searchCandidatesTool: ToolConfig<
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
candidates: (data.results ?? []).map(
|
||||
(
|
||||
c: Record<string, unknown> & {
|
||||
primaryEmailAddress?: { value?: string; type?: string; isPrimary?: boolean }
|
||||
primaryPhoneNumber?: { value?: string; type?: string; isPrimary?: boolean }
|
||||
}
|
||||
) => ({
|
||||
id: c.id ?? null,
|
||||
name: c.name ?? null,
|
||||
primaryEmailAddress: c.primaryEmailAddress
|
||||
? {
|
||||
value: c.primaryEmailAddress.value ?? '',
|
||||
type: c.primaryEmailAddress.type ?? 'Other',
|
||||
isPrimary: c.primaryEmailAddress.isPrimary ?? true,
|
||||
}
|
||||
: null,
|
||||
primaryPhoneNumber: c.primaryPhoneNumber
|
||||
? {
|
||||
value: c.primaryPhoneNumber.value ?? '',
|
||||
type: c.primaryPhoneNumber.type ?? 'Other',
|
||||
isPrimary: c.primaryPhoneNumber.isPrimary ?? true,
|
||||
}
|
||||
: null,
|
||||
createdAt: c.createdAt ?? null,
|
||||
updatedAt: c.updatedAt ?? null,
|
||||
})
|
||||
),
|
||||
candidates: (data.results ?? []).map(mapCandidate),
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -94,32 +72,7 @@ export const searchCandidatesTool: ToolConfig<
|
||||
description: 'Matching candidates (max 100 results)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Candidate UUID' },
|
||||
name: { type: 'string', description: 'Full name' },
|
||||
primaryEmailAddress: {
|
||||
type: 'object',
|
||||
description: 'Primary email contact info',
|
||||
optional: true,
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Email address' },
|
||||
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether this is the primary email' },
|
||||
},
|
||||
},
|
||||
primaryPhoneNumber: {
|
||||
type: 'object',
|
||||
description: 'Primary phone contact info',
|
||||
optional: true,
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Phone number' },
|
||||
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' },
|
||||
},
|
||||
},
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
|
||||
},
|
||||
properties: CANDIDATE_OUTPUTS,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,6 +10,87 @@ export interface AshbyContactInfo {
|
||||
isPrimary: boolean
|
||||
}
|
||||
|
||||
export interface AshbySocialLink {
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface AshbyTag {
|
||||
id: string
|
||||
title: string
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
export interface AshbyFileHandle {
|
||||
id: string
|
||||
name: string
|
||||
handle: string
|
||||
}
|
||||
|
||||
export interface AshbyCustomField {
|
||||
id: string | null
|
||||
title: string
|
||||
isPrivate: boolean
|
||||
valueLabel: string | null
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export interface AshbyUserSummary {
|
||||
id: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
email: string | null
|
||||
globalRole: string | null
|
||||
isEnabled: boolean
|
||||
updatedAt: string | null
|
||||
managerId: string | null
|
||||
}
|
||||
|
||||
export interface AshbySourceSummary {
|
||||
id: string
|
||||
title: string
|
||||
isArchived: boolean
|
||||
sourceType: {
|
||||
id: string
|
||||
title: string
|
||||
isArchived: boolean
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface AshbyCandidateLocation {
|
||||
id: string | null
|
||||
locationSummary: string | null
|
||||
locationComponents: Array<{ type: string; name: string }>
|
||||
}
|
||||
|
||||
export interface AshbyCandidate {
|
||||
id: string
|
||||
name: string
|
||||
primaryEmailAddress: AshbyContactInfo | null
|
||||
primaryPhoneNumber: AshbyContactInfo | null
|
||||
emailAddresses: AshbyContactInfo[]
|
||||
phoneNumbers: AshbyContactInfo[]
|
||||
socialLinks: AshbySocialLink[]
|
||||
linkedInUrl: string | null
|
||||
githubUrl: string | null
|
||||
profileUrl: string | null
|
||||
position: string | null
|
||||
company: string | null
|
||||
school: string | null
|
||||
timezone: string | null
|
||||
location: AshbyCandidateLocation | null
|
||||
tags: AshbyTag[]
|
||||
applicationIds: string[]
|
||||
customFields: AshbyCustomField[]
|
||||
resumeFileHandle: AshbyFileHandle | null
|
||||
fileHandles: AshbyFileHandle[]
|
||||
source: AshbySourceSummary | null
|
||||
creditedToUser: AshbyUserSummary | null
|
||||
fraudStatus: string | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface AshbyListCandidatesParams extends AshbyBaseParams {
|
||||
cursor?: string
|
||||
perPage?: number
|
||||
@@ -21,7 +102,7 @@ export interface AshbyGetCandidateParams extends AshbyBaseParams {
|
||||
|
||||
export interface AshbyCreateCandidateParams extends AshbyBaseParams {
|
||||
name: string
|
||||
email: string
|
||||
email?: string
|
||||
phoneNumber?: string
|
||||
linkedInUrl?: string
|
||||
githubUrl?: string
|
||||
@@ -55,130 +136,223 @@ export interface AshbyListApplicationsParams extends AshbyBaseParams {
|
||||
perPage?: number
|
||||
status?: string
|
||||
jobId?: string
|
||||
candidateId?: string
|
||||
createdAfter?: string
|
||||
}
|
||||
|
||||
export interface AshbyListCandidatesResponse extends ToolResponse {
|
||||
output: {
|
||||
candidates: Array<{
|
||||
id: string
|
||||
name: string
|
||||
primaryEmailAddress: AshbyContactInfo | null
|
||||
primaryPhoneNumber: AshbyContactInfo | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}>
|
||||
candidates: AshbyCandidate[]
|
||||
moreDataAvailable: boolean
|
||||
nextCursor: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface AshbyGetCandidateResponse extends ToolResponse {
|
||||
output: {
|
||||
id: string
|
||||
name: string
|
||||
primaryEmailAddress: AshbyContactInfo | null
|
||||
primaryPhoneNumber: AshbyContactInfo | null
|
||||
profileUrl: string | null
|
||||
position: string | null
|
||||
company: string | null
|
||||
linkedInUrl: string | null
|
||||
githubUrl: string | null
|
||||
tags: Array<{ id: string; title: string }>
|
||||
applicationIds: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
output: AshbyCandidate
|
||||
}
|
||||
|
||||
export interface AshbyCreateCandidateResponse extends ToolResponse {
|
||||
output: {
|
||||
id: string
|
||||
name: string
|
||||
primaryEmailAddress: AshbyContactInfo | null
|
||||
primaryPhoneNumber: AshbyContactInfo | null
|
||||
createdAt: string
|
||||
}
|
||||
output: AshbyCandidate
|
||||
}
|
||||
|
||||
export interface AshbySearchCandidatesResponse extends ToolResponse {
|
||||
output: {
|
||||
candidates: Array<{
|
||||
id: string
|
||||
name: string
|
||||
primaryEmailAddress: AshbyContactInfo | null
|
||||
primaryPhoneNumber: AshbyContactInfo | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}>
|
||||
candidates: AshbyCandidate[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface AshbyJobLocation {
|
||||
id: string | null
|
||||
name: string | null
|
||||
externalName: string | null
|
||||
isArchived: boolean
|
||||
isRemote: boolean
|
||||
workplaceType: string | null
|
||||
parentLocationId: string | null
|
||||
type: string | null
|
||||
address: {
|
||||
addressCountry: string | null
|
||||
addressRegion: string | null
|
||||
addressLocality: string | null
|
||||
postalCode: string | null
|
||||
streetAddress: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface AshbyHiringTeamMember {
|
||||
email: string | null
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
role: string | null
|
||||
userId: string | null
|
||||
}
|
||||
|
||||
export interface AshbyOpeningLatestVersion {
|
||||
id: string | null
|
||||
identifier: string | null
|
||||
description: string | null
|
||||
authorId: string | null
|
||||
createdAt: string | null
|
||||
teamId: string | null
|
||||
jobIds: string[]
|
||||
targetHireDate: string | null
|
||||
targetStartDate: string | null
|
||||
isBackfill: boolean
|
||||
employmentType: string | null
|
||||
locationIds: string[]
|
||||
hiringTeam: AshbyHiringTeamMember[]
|
||||
customFields: AshbyCustomField[]
|
||||
}
|
||||
|
||||
export interface AshbyOpening {
|
||||
id: string
|
||||
openedAt: string | null
|
||||
closedAt: string | null
|
||||
isArchived: boolean
|
||||
archivedAt: string | null
|
||||
closeReasonId: string | null
|
||||
openingState: string | null
|
||||
latestVersion: AshbyOpeningLatestVersion | null
|
||||
}
|
||||
|
||||
export interface AshbyJobCompensationTier {
|
||||
id: string | null
|
||||
title: string | null
|
||||
additionalInformation: string | null
|
||||
tierSummary: string | null
|
||||
}
|
||||
|
||||
export interface AshbyJob {
|
||||
id: string
|
||||
title: string
|
||||
confidential: boolean
|
||||
status: string | null
|
||||
employmentType: string | null
|
||||
locationId: string | null
|
||||
departmentId: string | null
|
||||
defaultInterviewPlanId: string | null
|
||||
interviewPlanIds: string[]
|
||||
customFields: AshbyCustomField[]
|
||||
jobPostingIds: string[]
|
||||
customRequisitionId: string | null
|
||||
brandId: string | null
|
||||
hiringTeam: AshbyHiringTeamMember[]
|
||||
author: AshbyUserSummary | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
openedAt: string | null
|
||||
closedAt: string | null
|
||||
location: AshbyJobLocation | null
|
||||
openings: AshbyOpening[]
|
||||
compensation: {
|
||||
compensationTiers: AshbyJobCompensationTier[]
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface AshbyListJobsResponse extends ToolResponse {
|
||||
output: {
|
||||
jobs: Array<{
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
employmentType: string | null
|
||||
departmentId: string | null
|
||||
locationId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}>
|
||||
jobs: AshbyJob[]
|
||||
moreDataAvailable: boolean
|
||||
nextCursor: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface AshbyGetJobResponse extends ToolResponse {
|
||||
output: {
|
||||
output: AshbyJob
|
||||
}
|
||||
|
||||
export interface AshbyNote {
|
||||
id: string
|
||||
createdAt: string | null
|
||||
isPrivate: boolean
|
||||
content: string | null
|
||||
author: {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
employmentType: string | null
|
||||
departmentId: string | null
|
||||
locationId: string | null
|
||||
descriptionPlain: string | null
|
||||
isArchived: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
email: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface AshbyCreateNoteResponse extends ToolResponse {
|
||||
output: {
|
||||
noteId: string
|
||||
}
|
||||
output: AshbyNote
|
||||
}
|
||||
|
||||
export interface AshbyApplicationCandidate {
|
||||
id: string
|
||||
name: string | null
|
||||
primaryEmailAddress: AshbyContactInfo | null
|
||||
primaryPhoneNumber: AshbyContactInfo | null
|
||||
}
|
||||
|
||||
export interface AshbyApplicationJob {
|
||||
id: string
|
||||
title: string | null
|
||||
locationId: string | null
|
||||
departmentId: string | null
|
||||
}
|
||||
|
||||
export interface AshbyApplicationStage {
|
||||
id: string
|
||||
title: string | null
|
||||
type: string | null
|
||||
orderInInterviewPlan: number | null
|
||||
interviewStageGroupId: string | null
|
||||
interviewPlanId: string | null
|
||||
}
|
||||
|
||||
export interface AshbyApplicationArchiveReason {
|
||||
id: string
|
||||
text: string | null
|
||||
reasonType: string | null
|
||||
isArchived: boolean
|
||||
customFields: AshbyCustomField[]
|
||||
}
|
||||
|
||||
export interface AshbyApplication {
|
||||
id: string
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
status: string
|
||||
customFields: AshbyCustomField[]
|
||||
candidate: AshbyApplicationCandidate
|
||||
currentInterviewStage: AshbyApplicationStage | null
|
||||
source: AshbySourceSummary | null
|
||||
archiveReason: AshbyApplicationArchiveReason | null
|
||||
archivedAt: string | null
|
||||
job: AshbyApplicationJob
|
||||
creditedToUser: AshbyUserSummary | null
|
||||
hiringTeam: AshbyHiringTeamMember[]
|
||||
appliedViaJobPostingId: string | null
|
||||
submitterClientIp: string | null
|
||||
submitterUserAgent: string | null
|
||||
}
|
||||
|
||||
export interface AshbyListApplicationsResponse extends ToolResponse {
|
||||
output: {
|
||||
applications: Array<{
|
||||
id: string
|
||||
status: string
|
||||
candidate: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
job: {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
currentInterviewStage: {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
} | null
|
||||
source: {
|
||||
id: string
|
||||
title: string
|
||||
} | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}>
|
||||
applications: AshbyApplication[]
|
||||
moreDataAvailable: boolean
|
||||
nextCursor: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface AshbyOfferVersion {
|
||||
id: string | null
|
||||
startDate: string | null
|
||||
salary: { currencyCode: string | null; value: number | null } | null
|
||||
createdAt: string | null
|
||||
openingId: string | null
|
||||
customFields: AshbyCustomField[]
|
||||
fileHandles: AshbyFileHandle[]
|
||||
author: AshbyUserSummary | null
|
||||
approvalStatus: string | null
|
||||
}
|
||||
|
||||
export interface AshbyOffer {
|
||||
id: string
|
||||
decidedAt: string | null
|
||||
applicationId: string | null
|
||||
acceptanceStatus: string | null
|
||||
offerStatus: string | null
|
||||
latestVersion: AshbyOfferVersion | null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AshbyGetCandidateResponse } from '@/tools/ashby/types'
|
||||
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { AshbyGetCandidateResponse } from './types'
|
||||
|
||||
interface AshbyUpdateCandidateParams {
|
||||
apiKey: string
|
||||
@@ -88,7 +89,7 @@ export const updateCandidateTool: ToolConfig<
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {
|
||||
candidateId: params.candidateId,
|
||||
candidateId: params.candidateId.trim(),
|
||||
}
|
||||
if (params.name) body.name = params.name
|
||||
if (params.email) body.email = params.email
|
||||
@@ -96,7 +97,7 @@ export const updateCandidateTool: ToolConfig<
|
||||
if (params.linkedInUrl) body.linkedInUrl = params.linkedInUrl
|
||||
if (params.githubUrl) body.githubUrl = params.githubUrl
|
||||
if (params.websiteUrl) body.websiteUrl = params.websiteUrl
|
||||
if (params.sourceId) body.sourceId = params.sourceId
|
||||
if (params.sourceId) body.sourceId = params.sourceId.trim()
|
||||
return body
|
||||
},
|
||||
},
|
||||
@@ -108,94 +109,11 @@ export const updateCandidateTool: ToolConfig<
|
||||
throw new Error(data.errorInfo?.message || 'Failed to update candidate')
|
||||
}
|
||||
|
||||
const r = data.results
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
id: r.id ?? null,
|
||||
name: r.name ?? null,
|
||||
primaryEmailAddress: r.primaryEmailAddress
|
||||
? {
|
||||
value: r.primaryEmailAddress.value ?? '',
|
||||
type: r.primaryEmailAddress.type ?? 'Other',
|
||||
isPrimary: r.primaryEmailAddress.isPrimary ?? true,
|
||||
}
|
||||
: null,
|
||||
primaryPhoneNumber: r.primaryPhoneNumber
|
||||
? {
|
||||
value: r.primaryPhoneNumber.value ?? '',
|
||||
type: r.primaryPhoneNumber.type ?? 'Other',
|
||||
isPrimary: r.primaryPhoneNumber.isPrimary ?? true,
|
||||
}
|
||||
: null,
|
||||
profileUrl: r.profileUrl ?? null,
|
||||
position: r.position ?? null,
|
||||
company: r.company ?? null,
|
||||
linkedInUrl:
|
||||
(r.socialLinks ?? []).find((l: { type: string }) => l.type === 'LinkedIn')?.url ?? null,
|
||||
githubUrl:
|
||||
(r.socialLinks ?? []).find((l: { type: string }) => l.type === 'GitHub')?.url ?? null,
|
||||
tags: (r.tags ?? []).map((t: { id: string; title: string }) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
})),
|
||||
applicationIds: r.applicationIds ?? [],
|
||||
createdAt: r.createdAt ?? null,
|
||||
updatedAt: r.updatedAt ?? null,
|
||||
},
|
||||
output: mapCandidate(data.results),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
id: { type: 'string', description: 'Candidate UUID' },
|
||||
name: { type: 'string', description: 'Full name' },
|
||||
primaryEmailAddress: {
|
||||
type: 'object',
|
||||
description: 'Primary email contact info',
|
||||
optional: true,
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Email address' },
|
||||
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether this is the primary email' },
|
||||
},
|
||||
},
|
||||
primaryPhoneNumber: {
|
||||
type: 'object',
|
||||
description: 'Primary phone contact info',
|
||||
optional: true,
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Phone number' },
|
||||
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' },
|
||||
},
|
||||
},
|
||||
profileUrl: {
|
||||
type: 'string',
|
||||
description: 'URL to the candidate Ashby profile',
|
||||
optional: true,
|
||||
},
|
||||
position: { type: 'string', description: 'Current position or title', optional: true },
|
||||
company: { type: 'string', description: 'Current company', optional: true },
|
||||
linkedInUrl: { type: 'string', description: 'LinkedIn profile URL', optional: true },
|
||||
githubUrl: { type: 'string', description: 'GitHub profile URL', optional: true },
|
||||
tags: {
|
||||
type: 'array',
|
||||
description: 'Tags applied to the candidate',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Tag UUID' },
|
||||
title: { type: 'string', description: 'Tag title' },
|
||||
},
|
||||
},
|
||||
},
|
||||
applicationIds: {
|
||||
type: 'array',
|
||||
description: 'IDs of associated applications',
|
||||
items: { type: 'string', description: 'Application UUID' },
|
||||
},
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
|
||||
},
|
||||
outputs: CANDIDATE_OUTPUTS,
|
||||
}
|
||||
|
||||
848
apps/sim/tools/ashby/utils.ts
Normal file
848
apps/sim/tools/ashby/utils.ts
Normal file
@@ -0,0 +1,848 @@
|
||||
import type {
|
||||
AshbyApplication,
|
||||
AshbyCandidate,
|
||||
AshbyContactInfo,
|
||||
AshbyCustomField,
|
||||
AshbyFileHandle,
|
||||
AshbyHiringTeamMember,
|
||||
AshbyJob,
|
||||
AshbyOffer,
|
||||
AshbyOfferVersion,
|
||||
AshbyOpening,
|
||||
AshbyOpeningLatestVersion,
|
||||
AshbySourceSummary,
|
||||
AshbyUserSummary,
|
||||
} from '@/tools/ashby/types'
|
||||
import type { OutputProperty } from '@/tools/types'
|
||||
|
||||
type Unknown = Record<string, unknown>
|
||||
|
||||
function mapContact(raw: unknown): AshbyContactInfo | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const c = raw as Unknown
|
||||
return {
|
||||
value: (c.value as string) ?? '',
|
||||
type: (c.type as string) ?? 'Other',
|
||||
isPrimary: (c.isPrimary as boolean) ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
function mapContactArray(raw: unknown): AshbyContactInfo[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((c) => mapContact(c)).filter((c): c is AshbyContactInfo => c !== null)
|
||||
}
|
||||
|
||||
function mapCustomFields(raw: unknown): AshbyCustomField[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((f) => {
|
||||
const cf = f as Unknown
|
||||
return {
|
||||
id: (cf.id as string) ?? null,
|
||||
title: (cf.title as string) ?? '',
|
||||
isPrivate: (cf.isPrivate as boolean) ?? false,
|
||||
valueLabel: (cf.valueLabel as string) ?? null,
|
||||
value: cf.value ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mapFileHandle(raw: unknown): AshbyFileHandle | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const f = raw as Unknown
|
||||
return {
|
||||
id: (f.id as string) ?? '',
|
||||
name: (f.name as string) ?? '',
|
||||
handle: (f.handle as string) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapFileHandles(raw: unknown): AshbyFileHandle[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((f) => mapFileHandle(f)).filter((f): f is AshbyFileHandle => f !== null)
|
||||
}
|
||||
|
||||
export function mapUserSummary(raw: unknown): AshbyUserSummary | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const u = raw as Unknown
|
||||
return {
|
||||
id: (u.id as string) ?? '',
|
||||
firstName: (u.firstName as string) ?? null,
|
||||
lastName: (u.lastName as string) ?? null,
|
||||
email: (u.email as string) ?? null,
|
||||
globalRole: (u.globalRole as string) ?? null,
|
||||
isEnabled: (u.isEnabled as boolean) ?? false,
|
||||
updatedAt: (u.updatedAt as string) ?? null,
|
||||
managerId: (u.managerId as string) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSource(raw: unknown): AshbySourceSummary | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const s = raw as Unknown
|
||||
const sourceType = s.sourceType as Unknown | undefined
|
||||
return {
|
||||
id: (s.id as string) ?? '',
|
||||
title: (s.title as string) ?? '',
|
||||
isArchived: (s.isArchived as boolean) ?? false,
|
||||
sourceType: sourceType
|
||||
? {
|
||||
id: (sourceType.id as string) ?? '',
|
||||
title: (sourceType.title as string) ?? '',
|
||||
isArchived: (sourceType.isArchived as boolean) ?? false,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapCandidate(raw: unknown): AshbyCandidate {
|
||||
const c = (raw ?? {}) as Unknown
|
||||
const socialLinks = Array.isArray(c.socialLinks)
|
||||
? (c.socialLinks as Array<{ type?: string; url?: string }>)
|
||||
: []
|
||||
const location = c.location as Unknown | undefined
|
||||
const locationComponents = Array.isArray(location?.locationComponents)
|
||||
? (location?.locationComponents as Array<{ type?: string; name?: string }>)
|
||||
: []
|
||||
return {
|
||||
id: (c.id as string) ?? '',
|
||||
name: (c.name as string) ?? '',
|
||||
primaryEmailAddress: mapContact(c.primaryEmailAddress),
|
||||
primaryPhoneNumber: mapContact(c.primaryPhoneNumber),
|
||||
emailAddresses: mapContactArray(c.emailAddresses),
|
||||
phoneNumbers: mapContactArray(c.phoneNumbers),
|
||||
socialLinks: socialLinks.map((l) => ({
|
||||
type: l.type ?? '',
|
||||
url: l.url ?? '',
|
||||
})),
|
||||
linkedInUrl: socialLinks.find((l) => l.type === 'LinkedIn')?.url ?? null,
|
||||
githubUrl: socialLinks.find((l) => l.type === 'GitHub')?.url ?? null,
|
||||
profileUrl: (c.profileUrl as string) ?? null,
|
||||
position: (c.position as string) ?? null,
|
||||
company: (c.company as string) ?? null,
|
||||
school: (c.school as string) ?? null,
|
||||
timezone: (c.timezone as string) ?? null,
|
||||
location: location
|
||||
? {
|
||||
id: (location.id as string) ?? null,
|
||||
locationSummary: (location.locationSummary as string) ?? null,
|
||||
locationComponents: locationComponents.map((lc) => ({
|
||||
type: lc.type ?? '',
|
||||
name: lc.name ?? '',
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
tags: Array.isArray(c.tags)
|
||||
? (c.tags as Array<{ id?: string; title?: string; isArchived?: boolean }>).map((t) => ({
|
||||
id: t.id ?? '',
|
||||
title: t.title ?? '',
|
||||
isArchived: t.isArchived ?? false,
|
||||
}))
|
||||
: [],
|
||||
applicationIds: Array.isArray(c.applicationIds) ? (c.applicationIds as string[]) : [],
|
||||
customFields: mapCustomFields(c.customFields),
|
||||
resumeFileHandle: mapFileHandle(c.resumeFileHandle),
|
||||
fileHandles: mapFileHandles(c.fileHandles),
|
||||
source: mapSource(c.source),
|
||||
creditedToUser: mapUserSummary(c.creditedToUser),
|
||||
fraudStatus: (c.fraudStatus as string) ?? null,
|
||||
createdAt: (c.createdAt as string) ?? null,
|
||||
updatedAt: (c.updatedAt as string) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function mapHiringTeam(raw: unknown): AshbyHiringTeamMember[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((m) => {
|
||||
const mem = m as Unknown
|
||||
return {
|
||||
email: (mem.email as string) ?? null,
|
||||
firstName: (mem.firstName as string) ?? null,
|
||||
lastName: (mem.lastName as string) ?? null,
|
||||
role: (mem.role as string) ?? null,
|
||||
userId: (mem.userId as string) ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mapOpeningLatestVersion(raw: unknown): AshbyOpeningLatestVersion | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const v = raw as Unknown
|
||||
return {
|
||||
id: (v.id as string) ?? null,
|
||||
identifier: (v.identifier as string) ?? null,
|
||||
description: (v.description as string) ?? null,
|
||||
authorId: (v.authorId as string) ?? null,
|
||||
createdAt: (v.createdAt as string) ?? null,
|
||||
teamId: (v.teamId as string) ?? null,
|
||||
jobIds: Array.isArray(v.jobIds) ? (v.jobIds as string[]) : [],
|
||||
targetHireDate: (v.targetHireDate as string) ?? null,
|
||||
targetStartDate: (v.targetStartDate as string) ?? null,
|
||||
isBackfill: (v.isBackfill as boolean) ?? false,
|
||||
employmentType: (v.employmentType as string) ?? null,
|
||||
locationIds: Array.isArray(v.locationIds) ? (v.locationIds as string[]) : [],
|
||||
hiringTeam: mapHiringTeam(v.hiringTeam),
|
||||
customFields: mapCustomFields(v.customFields),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapOpenings(raw: unknown): AshbyOpening[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((o) => {
|
||||
const op = o as Unknown
|
||||
return {
|
||||
id: (op.id as string) ?? '',
|
||||
openedAt: (op.openedAt as string) ?? null,
|
||||
closedAt: (op.closedAt as string) ?? null,
|
||||
isArchived: (op.isArchived as boolean) ?? false,
|
||||
archivedAt: (op.archivedAt as string) ?? null,
|
||||
closeReasonId: (op.closeReasonId as string) ?? null,
|
||||
openingState: (op.openingState as string) ?? null,
|
||||
latestVersion: mapOpeningLatestVersion(op.latestVersion),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function mapJob(raw: unknown): AshbyJob {
|
||||
const j = (raw ?? {}) as Unknown
|
||||
const location = j.location as Unknown | undefined
|
||||
const address = location?.address as Unknown | undefined
|
||||
const postalAddress = address?.postalAddress as Unknown | undefined
|
||||
const compensation = j.compensation as Unknown | undefined
|
||||
return {
|
||||
id: (j.id as string) ?? '',
|
||||
title: (j.title as string) ?? '',
|
||||
confidential: (j.confidential as boolean) ?? false,
|
||||
status: (j.status as string) ?? null,
|
||||
employmentType: (j.employmentType as string) ?? null,
|
||||
locationId: (j.locationId as string) ?? null,
|
||||
departmentId: (j.departmentId as string) ?? null,
|
||||
defaultInterviewPlanId: (j.defaultInterviewPlanId as string) ?? null,
|
||||
interviewPlanIds: Array.isArray(j.interviewPlanIds) ? (j.interviewPlanIds as string[]) : [],
|
||||
customFields: mapCustomFields(j.customFields),
|
||||
jobPostingIds: Array.isArray(j.jobPostingIds) ? (j.jobPostingIds as string[]) : [],
|
||||
customRequisitionId: (j.customRequisitionId as string) ?? null,
|
||||
brandId: (j.brandId as string) ?? null,
|
||||
hiringTeam: mapHiringTeam(j.hiringTeam),
|
||||
author: mapUserSummary(j.author),
|
||||
createdAt: (j.createdAt as string) ?? null,
|
||||
updatedAt: (j.updatedAt as string) ?? null,
|
||||
openedAt: (j.openedAt as string) ?? null,
|
||||
closedAt: (j.closedAt as string) ?? null,
|
||||
location: location
|
||||
? {
|
||||
id: (location.id as string) ?? null,
|
||||
name: (location.name as string) ?? null,
|
||||
externalName: (location.externalName as string) ?? null,
|
||||
isArchived: (location.isArchived as boolean) ?? false,
|
||||
isRemote: (location.isRemote as boolean) ?? false,
|
||||
workplaceType: (location.workplaceType as string) ?? null,
|
||||
parentLocationId: (location.parentLocationId as string) ?? null,
|
||||
type: (location.type as string) ?? null,
|
||||
address: postalAddress
|
||||
? {
|
||||
addressCountry: (postalAddress.addressCountry as string) ?? null,
|
||||
addressRegion: (postalAddress.addressRegion as string) ?? null,
|
||||
addressLocality: (postalAddress.addressLocality as string) ?? null,
|
||||
postalCode: (postalAddress.postalCode as string) ?? null,
|
||||
streetAddress: (postalAddress.streetAddress as string) ?? null,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
openings: mapOpenings(j.openings),
|
||||
compensation: compensation
|
||||
? {
|
||||
compensationTiers: Array.isArray(compensation.compensationTiers)
|
||||
? (
|
||||
compensation.compensationTiers as Array<{
|
||||
id?: string
|
||||
title?: string
|
||||
additionalInformation?: string
|
||||
tierSummary?: string
|
||||
}>
|
||||
).map((t) => ({
|
||||
id: t.id ?? null,
|
||||
title: t.title ?? null,
|
||||
additionalInformation: t.additionalInformation ?? null,
|
||||
tierSummary: t.tierSummary ?? null,
|
||||
}))
|
||||
: [],
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapApplication(raw: unknown): AshbyApplication {
|
||||
const a = (raw ?? {}) as Unknown
|
||||
const candidate = a.candidate as Unknown | undefined
|
||||
const job = a.job as Unknown | undefined
|
||||
const stage = a.currentInterviewStage as Unknown | undefined
|
||||
const archiveReason = a.archiveReason as Unknown | undefined
|
||||
return {
|
||||
id: (a.id as string) ?? '',
|
||||
createdAt: (a.createdAt as string) ?? null,
|
||||
updatedAt: (a.updatedAt as string) ?? null,
|
||||
status: (a.status as string) ?? '',
|
||||
customFields: mapCustomFields(a.customFields),
|
||||
candidate: {
|
||||
id: (candidate?.id as string) ?? '',
|
||||
name: (candidate?.name as string) ?? null,
|
||||
primaryEmailAddress: mapContact(candidate?.primaryEmailAddress),
|
||||
primaryPhoneNumber: mapContact(candidate?.primaryPhoneNumber),
|
||||
},
|
||||
currentInterviewStage: stage
|
||||
? {
|
||||
id: (stage.id as string) ?? '',
|
||||
title: (stage.title as string) ?? null,
|
||||
type: (stage.type as string) ?? null,
|
||||
orderInInterviewPlan: (stage.orderInInterviewPlan as number) ?? null,
|
||||
interviewStageGroupId: (stage.interviewStageGroupId as string) ?? null,
|
||||
interviewPlanId: (stage.interviewPlanId as string) ?? null,
|
||||
}
|
||||
: null,
|
||||
source: mapSource(a.source),
|
||||
archiveReason: archiveReason
|
||||
? {
|
||||
id: (archiveReason.id as string) ?? '',
|
||||
text: (archiveReason.text as string) ?? null,
|
||||
reasonType: (archiveReason.reasonType as string) ?? null,
|
||||
isArchived: (archiveReason.isArchived as boolean) ?? false,
|
||||
customFields: mapCustomFields(archiveReason.customFields),
|
||||
}
|
||||
: null,
|
||||
archivedAt: (a.archivedAt as string) ?? null,
|
||||
job: {
|
||||
id: (job?.id as string) ?? '',
|
||||
title: (job?.title as string) ?? null,
|
||||
locationId: (job?.locationId as string) ?? null,
|
||||
departmentId: (job?.departmentId as string) ?? null,
|
||||
},
|
||||
creditedToUser: mapUserSummary(a.creditedToUser),
|
||||
hiringTeam: mapHiringTeam(a.hiringTeam),
|
||||
appliedViaJobPostingId: (a.appliedViaJobPostingId as string) ?? null,
|
||||
submitterClientIp: (a.submitterClientIp as string) ?? null,
|
||||
submitterUserAgent: (a.submitterUserAgent as string) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export const CONTACT_INFO_OUTPUT = {
|
||||
type: 'object',
|
||||
description: 'Contact info',
|
||||
optional: true,
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Value (email or phone number)' },
|
||||
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether this is the primary contact' },
|
||||
},
|
||||
} as const satisfies OutputProperty
|
||||
|
||||
export const CUSTOM_FIELDS_OUTPUT = {
|
||||
type: 'array',
|
||||
description: 'Custom field values',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Custom field UUID' },
|
||||
title: { type: 'string', description: 'Field title' },
|
||||
isPrivate: { type: 'boolean', description: 'Whether the field is private' },
|
||||
valueLabel: { type: 'string', description: 'Human-readable value label', optional: true },
|
||||
value: { type: 'string', description: 'Raw field value (type depends on fieldType)' },
|
||||
},
|
||||
},
|
||||
} as const satisfies OutputProperty
|
||||
|
||||
export const FILE_HANDLE_OUTPUT = {
|
||||
type: 'object',
|
||||
description: 'File reference',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'File UUID' },
|
||||
name: { type: 'string', description: 'File name' },
|
||||
handle: { type: 'string', description: 'File handle used with file.info' },
|
||||
},
|
||||
} as const satisfies OutputProperty
|
||||
|
||||
export const FILE_HANDLES_OUTPUT = {
|
||||
type: 'array',
|
||||
description: 'File references',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'File UUID' },
|
||||
name: { type: 'string', description: 'File name' },
|
||||
handle: { type: 'string', description: 'File handle used with file.info' },
|
||||
},
|
||||
},
|
||||
} as const satisfies OutputProperty
|
||||
|
||||
export const USER_SUMMARY_OUTPUT = {
|
||||
type: 'object',
|
||||
description: 'User summary',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'User UUID' },
|
||||
firstName: { type: 'string', description: 'First name', optional: true },
|
||||
lastName: { type: 'string', description: 'Last name', optional: true },
|
||||
email: { type: 'string', description: 'Email', optional: true },
|
||||
globalRole: { type: 'string', description: 'Role', optional: true },
|
||||
isEnabled: { type: 'boolean', description: 'Whether enabled' },
|
||||
updatedAt: { type: 'string', description: 'Last update timestamp', optional: true },
|
||||
managerId: { type: 'string', description: 'Manager user UUID', optional: true },
|
||||
},
|
||||
} as const satisfies OutputProperty
|
||||
|
||||
export const SOURCE_SUMMARY_OUTPUT = {
|
||||
type: 'object',
|
||||
description: 'Attribution source',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Source UUID' },
|
||||
title: { type: 'string', description: 'Source title' },
|
||||
isArchived: { type: 'boolean', description: 'Whether archived' },
|
||||
sourceType: {
|
||||
type: 'object',
|
||||
description: 'Source type grouping',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Source type UUID' },
|
||||
title: { type: 'string', description: 'Source type title' },
|
||||
isArchived: { type: 'boolean', description: 'Whether archived' },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies OutputProperty
|
||||
|
||||
export const HIRING_TEAM_OUTPUT = {
|
||||
type: 'array',
|
||||
description: 'Hiring team members',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', description: 'User UUID' },
|
||||
firstName: { type: 'string', description: 'First name' },
|
||||
lastName: { type: 'string', description: 'Last name' },
|
||||
email: { type: 'string', description: 'Email' },
|
||||
role: { type: 'string', description: 'Hiring team role' },
|
||||
},
|
||||
},
|
||||
} as const satisfies OutputProperty
|
||||
|
||||
export const CANDIDATE_OUTPUTS = {
|
||||
id: { type: 'string', description: 'Candidate UUID' },
|
||||
name: { type: 'string', description: 'Full name' },
|
||||
primaryEmailAddress: { ...CONTACT_INFO_OUTPUT, description: 'Primary email contact info' },
|
||||
primaryPhoneNumber: { ...CONTACT_INFO_OUTPUT, description: 'Primary phone contact info' },
|
||||
emailAddresses: {
|
||||
type: 'array',
|
||||
description: 'All email addresses',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Email address' },
|
||||
type: { type: 'string', description: 'Contact type' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether primary' },
|
||||
},
|
||||
},
|
||||
},
|
||||
phoneNumbers: {
|
||||
type: 'array',
|
||||
description: 'All phone numbers',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Phone number' },
|
||||
type: { type: 'string', description: 'Contact type' },
|
||||
isPrimary: { type: 'boolean', description: 'Whether primary' },
|
||||
},
|
||||
},
|
||||
},
|
||||
socialLinks: {
|
||||
type: 'array',
|
||||
description: 'Social network links',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', description: 'Link type (LinkedIn, GitHub, Twitter, etc.)' },
|
||||
url: { type: 'string', description: 'Profile URL' },
|
||||
},
|
||||
},
|
||||
},
|
||||
linkedInUrl: { type: 'string', description: 'LinkedIn profile URL', optional: true },
|
||||
githubUrl: { type: 'string', description: 'GitHub profile URL', optional: true },
|
||||
profileUrl: { type: 'string', description: 'URL to the candidate Ashby profile', optional: true },
|
||||
position: { type: 'string', description: 'Current position or title', optional: true },
|
||||
company: { type: 'string', description: 'Current company', optional: true },
|
||||
school: { type: 'string', description: 'Most recent school', optional: true },
|
||||
timezone: { type: 'string', description: 'Candidate timezone', optional: true },
|
||||
location: {
|
||||
type: 'object',
|
||||
description: 'Candidate location',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Location UUID', optional: true },
|
||||
locationSummary: { type: 'string', description: 'Human-readable location summary' },
|
||||
locationComponents: {
|
||||
type: 'array',
|
||||
description: 'Structured location parts (city, region, country, etc.)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', description: 'Component type' },
|
||||
name: { type: 'string', description: 'Component value' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
type: 'array',
|
||||
description: 'Tags applied to the candidate',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Tag UUID' },
|
||||
title: { type: 'string', description: 'Tag title' },
|
||||
isArchived: { type: 'boolean', description: 'Whether archived' },
|
||||
},
|
||||
},
|
||||
},
|
||||
applicationIds: {
|
||||
type: 'array',
|
||||
description: 'IDs of associated applications',
|
||||
items: { type: 'string', description: 'Application UUID' },
|
||||
},
|
||||
customFields: CUSTOM_FIELDS_OUTPUT,
|
||||
resumeFileHandle: { ...FILE_HANDLE_OUTPUT, description: 'Resume file reference' },
|
||||
fileHandles: { ...FILE_HANDLES_OUTPUT, description: 'All uploaded file references' },
|
||||
source: SOURCE_SUMMARY_OUTPUT,
|
||||
creditedToUser: { ...USER_SUMMARY_OUTPUT, description: 'User credited with sourcing' },
|
||||
fraudStatus: { type: 'string', description: 'Fraud detection status', optional: true },
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
export const APPLICATION_OUTPUTS = {
|
||||
id: { type: 'string', description: 'Application UUID' },
|
||||
status: { type: 'string', description: 'Status (Active, Hired, Archived, Lead)' },
|
||||
customFields: CUSTOM_FIELDS_OUTPUT,
|
||||
candidate: {
|
||||
type: 'object',
|
||||
description: 'Associated candidate summary',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Candidate UUID' },
|
||||
name: { type: 'string', description: 'Candidate name' },
|
||||
primaryEmailAddress: { ...CONTACT_INFO_OUTPUT, description: 'Primary email' },
|
||||
primaryPhoneNumber: { ...CONTACT_INFO_OUTPUT, description: 'Primary phone' },
|
||||
},
|
||||
},
|
||||
currentInterviewStage: {
|
||||
type: 'object',
|
||||
description: 'Current interview stage',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Stage UUID' },
|
||||
title: { type: 'string', description: 'Stage title' },
|
||||
type: { type: 'string', description: 'Stage type' },
|
||||
orderInInterviewPlan: {
|
||||
type: 'number',
|
||||
description: 'Position in plan',
|
||||
optional: true,
|
||||
},
|
||||
interviewStageGroupId: { type: 'string', description: 'Stage group UUID', optional: true },
|
||||
interviewPlanId: { type: 'string', description: 'Interview plan UUID', optional: true },
|
||||
},
|
||||
},
|
||||
source: SOURCE_SUMMARY_OUTPUT,
|
||||
archiveReason: {
|
||||
type: 'object',
|
||||
description: 'Reason for archival (when archived)',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Reason UUID' },
|
||||
text: { type: 'string', description: 'Reason text' },
|
||||
reasonType: { type: 'string', description: 'Reason category' },
|
||||
isArchived: { type: 'boolean', description: 'Whether the reason is archived' },
|
||||
customFields: CUSTOM_FIELDS_OUTPUT,
|
||||
},
|
||||
},
|
||||
archivedAt: { type: 'string', description: 'ISO 8601 archive timestamp', optional: true },
|
||||
job: {
|
||||
type: 'object',
|
||||
description: 'Associated job summary',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
locationId: { type: 'string', description: 'Location UUID', optional: true },
|
||||
departmentId: { type: 'string', description: 'Department UUID', optional: true },
|
||||
},
|
||||
},
|
||||
creditedToUser: { ...USER_SUMMARY_OUTPUT, description: 'User credited with the application' },
|
||||
hiringTeam: HIRING_TEAM_OUTPUT,
|
||||
appliedViaJobPostingId: {
|
||||
type: 'string',
|
||||
description: 'Job posting UUID the candidate applied through',
|
||||
optional: true,
|
||||
},
|
||||
submitterClientIp: { type: 'string', description: 'Submitter IP address', optional: true },
|
||||
submitterUserAgent: {
|
||||
type: 'string',
|
||||
description: 'Submitter browser user agent',
|
||||
optional: true,
|
||||
},
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
export const OPENINGS_OUTPUT = {
|
||||
type: 'array',
|
||||
description: 'Headcount openings associated with the job',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Opening UUID' },
|
||||
openedAt: { type: 'string', description: 'Opening open timestamp', optional: true },
|
||||
closedAt: { type: 'string', description: 'Opening close timestamp', optional: true },
|
||||
isArchived: { type: 'boolean', description: 'Whether archived' },
|
||||
archivedAt: { type: 'string', description: 'Archive timestamp', optional: true },
|
||||
closeReasonId: { type: 'string', description: 'Close reason UUID', optional: true },
|
||||
openingState: {
|
||||
type: 'string',
|
||||
description: 'Opening state (Approved, Open, Filled, Closed, Draft)',
|
||||
optional: true,
|
||||
},
|
||||
latestVersion: {
|
||||
type: 'object',
|
||||
description: 'Latest opening version',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Version UUID', optional: true },
|
||||
identifier: { type: 'string', description: 'Human-readable identifier' },
|
||||
description: { type: 'string', description: 'Opening description' },
|
||||
authorId: { type: 'string', description: 'Author user UUID', optional: true },
|
||||
createdAt: { type: 'string', description: 'Version creation timestamp', optional: true },
|
||||
teamId: { type: 'string', description: 'Team UUID', optional: true },
|
||||
jobIds: {
|
||||
type: 'array',
|
||||
description: 'Associated job UUIDs',
|
||||
items: { type: 'string', description: 'Job UUID' },
|
||||
},
|
||||
targetHireDate: { type: 'string', description: 'Target hire date', optional: true },
|
||||
targetStartDate: { type: 'string', description: 'Target start date', optional: true },
|
||||
isBackfill: { type: 'boolean', description: 'Whether this is a backfill opening' },
|
||||
employmentType: { type: 'string', description: 'Employment type', optional: true },
|
||||
locationIds: {
|
||||
type: 'array',
|
||||
description: 'Location UUIDs',
|
||||
items: { type: 'string', description: 'Location UUID' },
|
||||
},
|
||||
hiringTeam: HIRING_TEAM_OUTPUT,
|
||||
customFields: CUSTOM_FIELDS_OUTPUT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies OutputProperty
|
||||
|
||||
function mapOfferVersion(raw: unknown): AshbyOfferVersion | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const v = raw as Unknown
|
||||
const salary = v.salary as Unknown | undefined
|
||||
return {
|
||||
id: (v.id as string) ?? null,
|
||||
startDate: (v.startDate as string) ?? null,
|
||||
salary: salary
|
||||
? {
|
||||
currencyCode: (salary.currencyCode as string) ?? null,
|
||||
value: (salary.value as number) ?? null,
|
||||
}
|
||||
: null,
|
||||
createdAt: (v.createdAt as string) ?? null,
|
||||
openingId: (v.openingId as string) ?? null,
|
||||
customFields: mapCustomFields(v.customFields),
|
||||
fileHandles: mapFileHandles(v.fileHandles),
|
||||
author: mapUserSummary(v.author),
|
||||
approvalStatus: (v.approvalStatus as string) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapOffer(raw: unknown): AshbyOffer {
|
||||
const r = (raw ?? {}) as Unknown
|
||||
return {
|
||||
id: (r.id as string) ?? '',
|
||||
decidedAt: (r.decidedAt as string) ?? null,
|
||||
applicationId: (r.applicationId as string) ?? null,
|
||||
acceptanceStatus: (r.acceptanceStatus as string) ?? null,
|
||||
offerStatus: (r.offerStatus as string) ?? null,
|
||||
latestVersion: mapOfferVersion(r.latestVersion),
|
||||
}
|
||||
}
|
||||
|
||||
export const OFFER_OUTPUTS = {
|
||||
id: { type: 'string', description: 'Offer UUID' },
|
||||
decidedAt: {
|
||||
type: 'string',
|
||||
description: 'Timestamp the offer was decided',
|
||||
optional: true,
|
||||
},
|
||||
applicationId: {
|
||||
type: 'string',
|
||||
description: 'Associated application UUID',
|
||||
optional: true,
|
||||
},
|
||||
acceptanceStatus: {
|
||||
type: 'string',
|
||||
description: 'Acceptance status (Accepted, Declined, Pending, etc.)',
|
||||
optional: true,
|
||||
},
|
||||
offerStatus: {
|
||||
type: 'string',
|
||||
description: 'Offer status (e.g. WaitingOnCandidateResponse, CandidateAccepted)',
|
||||
optional: true,
|
||||
},
|
||||
latestVersion: {
|
||||
type: 'object',
|
||||
description: 'Most recent version of the offer with pricing and metadata',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Version UUID', optional: true },
|
||||
startDate: { type: 'string', description: 'Offer start date', optional: true },
|
||||
salary: {
|
||||
type: 'object',
|
||||
description: 'Salary details',
|
||||
optional: true,
|
||||
properties: {
|
||||
currencyCode: {
|
||||
type: 'string',
|
||||
description: 'ISO 4217 currency code',
|
||||
optional: true,
|
||||
},
|
||||
value: { type: 'number', description: 'Salary amount', optional: true },
|
||||
},
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
description: 'Version creation timestamp',
|
||||
optional: true,
|
||||
},
|
||||
openingId: {
|
||||
type: 'string',
|
||||
description: 'Associated opening UUID',
|
||||
optional: true,
|
||||
},
|
||||
customFields: CUSTOM_FIELDS_OUTPUT,
|
||||
fileHandles: {
|
||||
...FILE_HANDLES_OUTPUT,
|
||||
description:
|
||||
'Offer letter file handles (unsigned .pdf, .docx, and signed .pdf when generated)',
|
||||
},
|
||||
author: { ...USER_SUMMARY_OUTPUT, description: 'User who authored the version' },
|
||||
approvalStatus: {
|
||||
type: 'string',
|
||||
description: 'Approval workflow status',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
export const JOB_OUTPUTS = {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
confidential: { type: 'boolean', description: 'Whether the job is confidential' },
|
||||
status: { type: 'string', description: 'Status (Open, Closed, Draft, Archived)', optional: true },
|
||||
employmentType: {
|
||||
type: 'string',
|
||||
description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)',
|
||||
optional: true,
|
||||
},
|
||||
locationId: { type: 'string', description: 'Primary location UUID', optional: true },
|
||||
departmentId: { type: 'string', description: 'Department UUID', optional: true },
|
||||
defaultInterviewPlanId: {
|
||||
type: 'string',
|
||||
description: 'Default interview plan UUID',
|
||||
optional: true,
|
||||
},
|
||||
interviewPlanIds: {
|
||||
type: 'array',
|
||||
description: 'All interview plan UUIDs',
|
||||
items: { type: 'string', description: 'Interview plan UUID' },
|
||||
},
|
||||
customFields: CUSTOM_FIELDS_OUTPUT,
|
||||
jobPostingIds: {
|
||||
type: 'array',
|
||||
description: 'Associated job posting UUIDs',
|
||||
items: { type: 'string', description: 'Job posting UUID' },
|
||||
},
|
||||
customRequisitionId: {
|
||||
type: 'string',
|
||||
description: 'Custom requisition identifier',
|
||||
optional: true,
|
||||
},
|
||||
brandId: { type: 'string', description: 'Brand UUID', optional: true },
|
||||
hiringTeam: HIRING_TEAM_OUTPUT,
|
||||
author: { ...USER_SUMMARY_OUTPUT, description: 'Job author (creator)' },
|
||||
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp', optional: true },
|
||||
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp', optional: true },
|
||||
openedAt: { type: 'string', description: 'ISO 8601 opened timestamp', optional: true },
|
||||
closedAt: { type: 'string', description: 'ISO 8601 closed timestamp', optional: true },
|
||||
location: {
|
||||
type: 'object',
|
||||
description: 'Primary location details',
|
||||
optional: true,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Location UUID', optional: true },
|
||||
name: { type: 'string', description: 'Location name', optional: true },
|
||||
externalName: { type: 'string', description: 'External display name', optional: true },
|
||||
isArchived: { type: 'boolean', description: 'Whether archived' },
|
||||
isRemote: { type: 'boolean', description: 'Whether remote' },
|
||||
workplaceType: {
|
||||
type: 'string',
|
||||
description: 'Workplace type (OnSite, Remote, Hybrid)',
|
||||
optional: true,
|
||||
},
|
||||
parentLocationId: {
|
||||
type: 'string',
|
||||
description: 'Parent location UUID',
|
||||
optional: true,
|
||||
},
|
||||
type: { type: 'string', description: 'Location type', optional: true },
|
||||
address: {
|
||||
type: 'object',
|
||||
description: 'Postal address',
|
||||
optional: true,
|
||||
properties: {
|
||||
addressCountry: { type: 'string', description: 'Country', optional: true },
|
||||
addressRegion: { type: 'string', description: 'State or region', optional: true },
|
||||
addressLocality: { type: 'string', description: 'City or locality', optional: true },
|
||||
postalCode: { type: 'string', description: 'Postal code', optional: true },
|
||||
streetAddress: { type: 'string', description: 'Street address', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
openings: OPENINGS_OUTPUT,
|
||||
compensation: {
|
||||
type: 'object',
|
||||
description: 'Job compensation structure',
|
||||
optional: true,
|
||||
properties: {
|
||||
compensationTiers: {
|
||||
type: 'array',
|
||||
description: 'Compensation tiers',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Tier UUID', optional: true },
|
||||
title: { type: 'string', description: 'Tier title', optional: true },
|
||||
additionalInformation: {
|
||||
type: 'string',
|
||||
description: 'Additional info',
|
||||
optional: true,
|
||||
},
|
||||
tierSummary: { type: 'string', description: 'Tier summary', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
@@ -13,6 +13,30 @@ export const ashbyTriggerOptions = [
|
||||
{ label: 'Offer Created', id: 'ashby_offer_create' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Maps Sim trigger IDs to Ashby webhookType / event action values.
|
||||
* Used by webhook.create body and matchEvent filtering.
|
||||
*/
|
||||
export const ASHBY_TRIGGER_ACTION_MAP: Record<string, string> = {
|
||||
ashby_application_submit: 'applicationSubmit',
|
||||
ashby_candidate_stage_change: 'candidateStageChange',
|
||||
ashby_candidate_hire: 'candidateHire',
|
||||
ashby_candidate_delete: 'candidateDelete',
|
||||
ashby_job_create: 'jobCreate',
|
||||
ashby_offer_create: 'offerCreate',
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an Ashby webhook event matches the configured trigger.
|
||||
* Ashby sends a ping event on webhook create/edit; this filter rejects
|
||||
* any event whose `action` does not equal the expected webhookType.
|
||||
*/
|
||||
export function isAshbyEventMatch(triggerId: string, action: string): boolean {
|
||||
const expected = ASHBY_TRIGGER_ACTION_MAP[triggerId]
|
||||
if (!expected) return false
|
||||
return expected === action
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates setup instructions for Ashby webhooks.
|
||||
* Webhooks are automatically created/deleted via the Ashby API.
|
||||
@@ -168,9 +192,8 @@ export function buildCandidateStageChangeOutputs(): Record<string, TriggerOutput
|
||||
|
||||
/**
|
||||
* Build outputs for candidateHire events.
|
||||
* Payload: { action, data: { application: { id, createdAt, updatedAt, status,
|
||||
* candidate: { id, name }, currentInterviewStage: { id, title },
|
||||
* job: { id, title } } } }
|
||||
* Per Ashby docs, candidateHire payloads include application details and most
|
||||
* recent accepted offer information.
|
||||
*/
|
||||
export function buildCandidateHireOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
@@ -196,6 +219,19 @@ export function buildCandidateHireOutputs(): Record<string, TriggerOutput> {
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
},
|
||||
},
|
||||
offer: {
|
||||
id: { type: 'string', description: 'Accepted offer UUID' },
|
||||
applicationId: { type: 'string', description: 'Associated application UUID' },
|
||||
acceptanceStatus: { type: 'string', description: 'Offer acceptance status' },
|
||||
offerStatus: { type: 'string', description: 'Offer process status' },
|
||||
decidedAt: {
|
||||
type: 'string',
|
||||
description: 'Offer decision timestamp (ISO 8601)',
|
||||
},
|
||||
latestVersion: {
|
||||
id: { type: 'string', description: 'Latest offer version UUID' },
|
||||
},
|
||||
},
|
||||
} as Record<string, TriggerOutput>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ========================================
|
||||
# Base Stage: Debian-based Bun with Node.js 22
|
||||
# ========================================
|
||||
FROM oven/bun:1.3.11-slim AS base
|
||||
FROM oven/bun:1.3.13-slim AS base
|
||||
|
||||
# Install Node.js 22 and common dependencies once in base stage
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ========================================
|
||||
# Base Stage: Alpine Linux with Bun
|
||||
# ========================================
|
||||
FROM oven/bun:1.3.11-alpine AS base
|
||||
FROM oven/bun:1.3.13-alpine AS base
|
||||
|
||||
# ========================================
|
||||
# Dependencies Stage: Install Dependencies
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ========================================
|
||||
# Base Stage: Alpine Linux with Bun
|
||||
# ========================================
|
||||
FROM oven/bun:1.3.11-alpine AS base
|
||||
FROM oven/bun:1.3.13-alpine AS base
|
||||
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "simstudio",
|
||||
"packageManager": "bun@1.3.11",
|
||||
"packageManager": "bun@1.3.13",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
|
||||
67
packages/db/migrations/0198_tables_race_free_trigger.sql
Normal file
67
packages/db/migrations/0198_tables_race_free_trigger.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
-- Replace increment_user_table_row_count() with a race-free conditional UPDATE.
|
||||
-- The prior version did SELECT row_count -> IF check -> UPDATE, which is
|
||||
-- TOCTOU-vulnerable: two concurrent inserts near max_rows could both pass the
|
||||
-- check and push row_count past the cap. Application code compensated with
|
||||
-- SELECT ... FOR UPDATE on user_table_definitions, creating a serialization
|
||||
-- hotspot on every insert.
|
||||
--
|
||||
-- The new version performs capacity check and increment as a single
|
||||
-- conditional UPDATE. The UPDATE takes a row-level exclusive lock atomically,
|
||||
-- so concurrent inserts serialize on that lock; once row_count reaches
|
||||
-- max_rows, the WHERE clause returns zero rows and we RAISE. This lets the
|
||||
-- application drop its FOR UPDATE without losing correctness.
|
||||
CREATE OR REPLACE FUNCTION increment_user_table_row_count()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
updated_count INTEGER;
|
||||
max_allowed INTEGER;
|
||||
BEGIN
|
||||
UPDATE user_table_definitions
|
||||
SET row_count = row_count + 1,
|
||||
updated_at = now()
|
||||
WHERE id = NEW.table_id
|
||||
AND row_count < max_rows
|
||||
RETURNING row_count, max_rows INTO updated_count, max_allowed;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
SELECT max_rows INTO max_allowed
|
||||
FROM user_table_definitions
|
||||
WHERE id = NEW.table_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Table % not found', NEW.table_id
|
||||
USING ERRCODE = 'foreign_key_violation';
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'Maximum row limit (%) reached for table %',
|
||||
max_allowed, NEW.table_id
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
--> statement-breakpoint
|
||||
|
||||
-- One-shot reconcile: ensure user_table_definitions.row_count matches the
|
||||
-- actual count of user_table_rows. Guards against any historical drift before
|
||||
-- downstream readers (listTables, getTableById) start trusting the column.
|
||||
UPDATE user_table_definitions d
|
||||
SET row_count = sub.c
|
||||
FROM (
|
||||
SELECT table_id, count(*)::integer AS c
|
||||
FROM user_table_rows
|
||||
GROUP BY table_id
|
||||
) sub
|
||||
WHERE d.id = sub.table_id
|
||||
AND d.row_count IS DISTINCT FROM sub.c;
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Tables that exist but have zero rows should report row_count = 0 (not a
|
||||
-- stale non-zero value left from rows deleted while the trigger was broken).
|
||||
UPDATE user_table_definitions d
|
||||
SET row_count = 0
|
||||
WHERE d.row_count <> 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_table_rows r WHERE r.table_id = d.id
|
||||
);
|
||||
15226
packages/db/migrations/meta/0198_snapshot.json
Normal file
15226
packages/db/migrations/meta/0198_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1380,6 +1380,13 @@
|
||||
"when": 1776980739421,
|
||||
"tag": "0197_unknown_the_captain",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 198,
|
||||
"version": "7",
|
||||
"when": 1777054484443,
|
||||
"tag": "0198_tables_race_free_trigger",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const redisConfigMockFns = {
|
||||
mockOnRedisReconnect: vi.fn(),
|
||||
mockAcquireLock: vi.fn().mockResolvedValue(true),
|
||||
mockReleaseLock: vi.fn().mockResolvedValue(true),
|
||||
mockExtendLock: vi.fn().mockResolvedValue(true),
|
||||
mockCloseRedisConnection: vi.fn().mockResolvedValue(undefined),
|
||||
mockResetForTesting: vi.fn(),
|
||||
}
|
||||
@@ -34,6 +35,7 @@ export const redisConfigMock = {
|
||||
onRedisReconnect: redisConfigMockFns.mockOnRedisReconnect,
|
||||
acquireLock: redisConfigMockFns.mockAcquireLock,
|
||||
releaseLock: redisConfigMockFns.mockReleaseLock,
|
||||
extendLock: redisConfigMockFns.mockExtendLock,
|
||||
closeRedisConnection: redisConfigMockFns.mockCloseRedisConnection,
|
||||
resetForTesting: redisConfigMockFns.mockResetForTesting,
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ export function createMockRedis() {
|
||||
exec: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
|
||||
// Scripting
|
||||
eval: vi.fn().mockResolvedValue(0),
|
||||
|
||||
// Connection
|
||||
ping: vi.fn().mockResolvedValue('PONG'),
|
||||
quit: vi.fn().mockResolvedValue('OK'),
|
||||
|
||||
Reference in New Issue
Block a user