diff --git a/packages/aeroleads/api.test.ts b/packages/aeroleads/api.test.ts new file mode 100644 index 000000000..719387ddd --- /dev/null +++ b/packages/aeroleads/api.test.ts @@ -0,0 +1,407 @@ +import { AuthMissingError, logEventFromContext } from 'corsair/core'; +import { ApiError, request } from 'corsair/http'; +import { AeroleadsAPIError, makeAeroleadsRequest } from './client'; +import { GetLinkedinDetailsInputSchema } from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import type { AeroleadsContext, AeroleadsKeyBuilderContext } from './index'; +import { aeroleads, aeroleadsEndpointSchemas } from './index'; + +jest.mock('corsair/core', () => ({ + ...jest.requireActual('corsair/core'), + logEventFromContext: jest.fn(), +})); + +jest.mock('corsair/http', () => { + const original = jest.requireActual('corsair/http'); + return { + ...original, + request: jest.fn(), + }; +}); + +const mockRequest = request as jest.Mock; +const mockLog = jest.mocked(logEventFromContext); + +function countLeaves(tree: Record): number { + return Object.values(tree).reduce((count, value) => { + if (typeof value === 'function') return count + 1; + if (value && typeof value === 'object') { + return count + countLeaves(value as Record); + } + return count; + }, 0); +} + +function endpointPaths(tree: Record, prefix = ''): string[] { + return Object.entries(tree).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'function') return [path]; + if (value && typeof value === 'object') { + return endpointPaths(value as Record, path); + } + return []; + }); +} + +const mockCtx = { + key: 'test-api-key', + $getAccountId: () => 'test-account-id', + options: {}, + logEvent: jest.fn(), + db: {}, + keyBuilder: async () => 'test-api-key', +} as unknown as AeroleadsContext; + +const profile = { + full_name: 'Ayushi Mathur', + linkedin_url: 'https://www.linkedin.com/in/ayushi-mathur-061b9010b/', + job_title: 'Software Engineer', + emails: 'test@example.com', +}; + +type LinkedinDetailsGet = ( + ctx: AeroleadsContext, + input: { linkedin_url: string }, +) => Promise; + +function getLinkedinDetails(): LinkedinDetailsGet { + const plugin = aeroleads({ key: 'test-api-key' }); + const endpoints = plugin.endpoints as NonNullable & { + linkedinDetails: { get: LinkedinDetailsGet }; + }; + return endpoints.linkedinDetails.get; +} + +function classify(error: Error): string { + const name = ( + Object.keys(errorHandlers) as Array + ).find((key) => errorHandlers[key].match(error)); + return name ?? 'none'; +} + +function httpError(status: number, message: string): ApiError { + return new ApiError( + { method: 'GET', url: 'https://aeroleads.com/api/get_linkedin_details' }, + { + url: 'https://aeroleads.com/api/get_linkedin_details', + ok: false, + status, + statusText: 'Error', + body: { message }, + }, + message, + ); +} + +describe('Aeroleads plugin shape', () => { + it('exposes every listed operation with schemas and no webhooks', () => { + const plugin = aeroleads(); + const endpoints = plugin.endpoints as Record; + const paths = endpointPaths(endpoints).sort(); + + expect(countLeaves(endpoints)).toBe(1); + expect(Object.keys(plugin.endpointMeta ?? {})).toHaveLength(1); + expect(Object.keys(aeroleadsEndpointSchemas)).toHaveLength(1); + expect(Object.keys(plugin.endpointMeta ?? {}).sort()).toEqual(paths); + expect(Object.keys(aeroleadsEndpointSchemas).sort()).toEqual(paths); + expect(plugin.webhooks).toEqual({}); + expect(typeof plugin.pluginWebhookMatcher).toBe('function'); + }); + + it('supports api key auth configuration', () => { + const plugin = aeroleads(); + expect(plugin.options?.authType).toBe('api_key'); + expect(plugin.authConfig).toEqual({ + api_key: { account: ['tenant_external_id'] }, + }); + }); +}); + +describe('Aeroleads request client', () => { + beforeEach(() => { + mockRequest.mockReset(); + mockRequest.mockResolvedValue(profile); + }); + + it('adds API key to config TOKEN and request query', async () => { + await makeAeroleadsRequest('/api/get_linkedin_details', 'test-api-key', { + method: 'GET', + query: { linkedin_url: 'https://linkedin.com/in/test' }, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + BASE: 'https://aeroleads.com', + TOKEN: 'test-api-key', + HEADERS: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }), + expect.objectContaining({ + method: 'GET', + url: '/api/get_linkedin_details', + query: { + api_key: 'test-api-key', + linkedin_url: 'https://linkedin.com/in/test', + }, + }), + ); + }); + + it('throws on a 200 body with an error status', async () => { + mockRequest.mockResolvedValue({ + message: 'User not Found, Please Pass Valid Api Key', + status: 400, + }); + + await expect( + makeAeroleadsRequest('/api/get_linkedin_details', 'test-api-key', { + query: { linkedin_url: 'https://linkedin.com/in/test' }, + }), + ).rejects.toBeInstanceOf(AeroleadsAPIError); + }); + + it('throws on a 200 body that only asks for an API key', async () => { + mockRequest.mockResolvedValue({ + message: 'Pass your Api Key also as params', + status: 400, + }); + + await expect( + makeAeroleadsRequest('/api/get_linkedin_details', 'test-api-key'), + ).rejects.toThrow(/api key/i); + }); + + it('throws on an empty 200 body', async () => { + mockRequest.mockResolvedValue({}); + + await expect( + makeAeroleadsRequest('/api/get_linkedin_details', 'test-api-key', { + query: { linkedin_url: 'https://linkedin.com/in/test' }, + }), + ).rejects.toBeInstanceOf(AeroleadsAPIError); + }); + + it('returns a profile payload unchanged', async () => { + await expect( + makeAeroleadsRequest('/api/get_linkedin_details', 'test-api-key', { + query: { linkedin_url: 'https://linkedin.com/in/test' }, + }), + ).resolves.toEqual(profile); + }); + + it('accepts a profile that only has documented job fields', async () => { + const sparse = { job_title_role: 'Engineer', city: 'Bengaluru' }; + mockRequest.mockResolvedValue(sparse); + + await expect( + makeAeroleadsRequest('/api/get_linkedin_details', 'test-api-key', { + query: { linkedin_url: 'https://linkedin.com/in/test' }, + }), + ).resolves.toEqual(sparse); + }); +}); + +describe('Aeroleads endpoints', () => { + beforeEach(() => { + mockRequest.mockReset(); + mockLog.mockReset(); + mockRequest.mockResolvedValue(profile); + }); + + it('maps representative operations to API routes', async () => { + await getLinkedinDetails()(mockCtx, { + linkedin_url: 'https://linkedin.com/in/test', + }); + + expect(mockRequest.mock.calls.map((call) => call[1])).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: 'GET', + url: '/api/get_linkedin_details', + query: { + api_key: 'test-api-key', + linkedin_url: 'https://linkedin.com/in/test', + }, + }), + ]), + ); + expect(mockLog).toHaveBeenCalledWith( + mockCtx, + 'aeroleads.linkedinDetails.get', + { linkedin_url: 'https://linkedin.com/in/test' }, + 'completed', + ); + }); + + it('does not log completed when Aeroleads returns an error envelope', async () => { + mockRequest.mockResolvedValue({ + message: 'User not Found, Please Pass Valid Api Key', + status: 400, + }); + + await expect( + getLinkedinDetails()(mockCtx, { + linkedin_url: 'https://linkedin.com/in/test', + }), + ).rejects.toBeInstanceOf(AeroleadsAPIError); + expect(mockLog).not.toHaveBeenCalled(); + }); + + it('caches linkedin details by profile URL', async () => { + const linkedinDetails = { + upsertByEntityId: jest.fn().mockResolvedValue(undefined), + }; + const ctx = { + ...mockCtx, + db: { linkedinDetails }, + } as unknown as AeroleadsContext; + + await getLinkedinDetails()(ctx, { + linkedin_url: 'https://linkedin.com/in/test', + }); + + expect(linkedinDetails.upsertByEntityId).toHaveBeenCalledWith( + 'https://linkedin.com/in/test', + expect.objectContaining({ + full_name: 'Ayushi Mathur', + linkedin_url: profile.linkedin_url, + }), + ); + }); + + it('cache write failures do not fail the API call', async () => { + const linkedinDetails = { + upsertByEntityId: jest.fn().mockRejectedValue(new Error('db down')), + }; + const ctx = { + ...mockCtx, + db: { linkedinDetails }, + } as unknown as AeroleadsContext; + + await expect( + getLinkedinDetails()(ctx, { + linkedin_url: 'https://linkedin.com/in/test', + }), + ).resolves.toMatchObject({ full_name: 'Ayushi Mathur' }); + }); + + it('rejects a company page URL before calling Aeroleads', async () => { + await expect( + getLinkedinDetails()(mockCtx, { + linkedin_url: 'https://www.linkedin.com/company/microsoft', + }), + ).rejects.toThrow(); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); + +describe('linkedin_url input', () => { + it('accepts public profile URLs', () => { + expect(() => + GetLinkedinDetailsInputSchema.parse({ + linkedin_url: 'https://www.linkedin.com/in/satyanadella', + }), + ).not.toThrow(); + expect(() => + GetLinkedinDetailsInputSchema.parse({ + linkedin_url: 'https://uk.linkedin.com/in/example/', + }), + ).not.toThrow(); + }); + + it('rejects company pages and lookalike hosts', () => { + expect(() => + GetLinkedinDetailsInputSchema.parse({ + linkedin_url: 'https://www.linkedin.com/company/microsoft', + }), + ).toThrow(); + expect(() => + GetLinkedinDetailsInputSchema.parse({ + linkedin_url: 'https://evil.com/linkedin.com/in/test', + }), + ).toThrow(); + expect(() => + GetLinkedinDetailsInputSchema.parse({ + linkedin_url: 'https://linkedin.com.evil.com/in/test', + }), + ).toThrow(); + }); +}); + +describe('error handler classification', () => { + it('treats a 200 invalid-key envelope as auth, not success', () => { + expect( + classify( + new AeroleadsAPIError('User not Found, Please Pass Valid Api Key'), + ), + ).toBe('AUTH_ERROR'); + expect( + classify(new AeroleadsAPIError('Pass your Api Key also as params')), + ).toBe('AUTH_ERROR'); + }); + + it('treats a missing LinkedIn URL envelope as a bad request', () => { + expect( + classify(new AeroleadsAPIError('Pass Linkedin Url also as params')), + ).toBe('BAD_REQUEST_ERROR'); + }); + + it('does not retry auth, credit, or empty-profile failures', async () => { + const auth = await errorHandlers.AUTH_ERROR.handler(); + const credit = await errorHandlers.CREDIT_LIMIT_ERROR.handler(); + + expect(classify(httpError(401, 'Wrong API key'))).toBe('AUTH_ERROR'); + expect(classify(httpError(402, 'Credit Limit Reached'))).toBe( + 'CREDIT_LIMIT_ERROR', + ); + expect( + classify(new AeroleadsAPIError('Aeroleads returned no profile details')), + ).toBe('NOT_FOUND_ERROR'); + expect(auth.maxRetries).toBe(0); + expect(credit.maxRetries).toBe(0); + }); + + it('does not stack handler retries on transport 429 retries', async () => { + const rateLimit = await errorHandlers.RATE_LIMIT_ERROR.handler(); + expect(classify(httpError(429, 'too many requests'))).toBe( + 'RATE_LIMIT_ERROR', + ); + expect(rateLimit.maxRetries).toBe(0); + }); +}); + +describe('aeroleads keyBuilder authentication', () => { + const plugin = aeroleads(); + + it('returns options.key for endpoint source', async () => { + const withOptionsKey = aeroleads({ key: 'test-api-key' }); + const out = await (withOptionsKey.keyBuilder as any)( + { authType: 'api_key' } as unknown as AeroleadsKeyBuilderContext, + 'endpoint', + ); + expect(out).toBe('test-api-key'); + }); + + it('throws AuthMissingError when api key is absent', async () => { + const noKeyCtx = { + authType: 'api_key', + keys: { get_api_key: async (): Promise => null }, + } as unknown as AeroleadsKeyBuilderContext; + + await expect( + (plugin.keyBuilder as any)(noKeyCtx, 'endpoint'), + ).rejects.toBeInstanceOf(AuthMissingError); + }); + + it('reads api key from key manager', async () => { + const withKeyCtx = { + authType: 'api_key', + keys: { get_api_key: async (): Promise => 'test-api-key' }, + } as unknown as AeroleadsKeyBuilderContext; + + await expect( + (plugin.keyBuilder as any)(withKeyCtx, 'endpoint'), + ).resolves.toBe('test-api-key'); + }); +}); diff --git a/packages/aeroleads/client.ts b/packages/aeroleads/client.ts new file mode 100644 index 000000000..4026e5e5e --- /dev/null +++ b/packages/aeroleads/client.ts @@ -0,0 +1,112 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export class AeroleadsAPIError extends Error { + readonly status?: number; + readonly body?: unknown; + + constructor( + message: string, + options: { status?: number; body?: unknown } = {}, + ) { + super(message); + this.name = 'AeroleadsAPIError'; + this.status = options.status; + this.body = options.body; + } +} + +const AEROLEADS_API_BASE = 'https://aeroleads.com'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasProfileData(body: Record): boolean { + return Object.entries(body).some(([key, value]) => { + if (key === 'message' || key === 'status') return false; + if (value == null) return false; + if (typeof value === 'string') return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + return true; + }); +} + +export function assertAeroleadsSuccess(body: unknown): void { + if (!isRecord(body)) { + throw new AeroleadsAPIError('Aeroleads returned no profile details', { + body, + }); + } + + const status = typeof body.status === 'number' ? body.status : undefined; + const message = typeof body.message === 'string' ? body.message.trim() : ''; + + if (status !== undefined && status >= 400) { + throw new AeroleadsAPIError( + message || `Aeroleads request failed with status ${status}`, + { status, body }, + ); + } + + if (message.length > 0 && !hasProfileData(body)) { + throw new AeroleadsAPIError(message, { status, body }); + } + + if (!hasProfileData(body)) { + throw new AeroleadsAPIError('Aeroleads returned no profile details', { + body, + }); + } +} + +export async function makeAeroleadsRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + query?: Record; + } = {}, +): Promise { + const { method = 'GET', body, query = {} } = options; + + const config: OpenAPIConfig = { + BASE: AEROLEADS_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: apiKey, + HEADERS: { + 'Content-Type': 'application/json', + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: + method === 'POST' || method === 'PUT' || method === 'PATCH' + ? body + : undefined, + mediaType: 'application/json; charset=utf-8', + query: { + api_key: apiKey, + ...query, + }, + }; + + try { + const result = await request(config, requestOptions); + assertAeroleadsSuccess(result); + return result; + } catch (error) { + if (error instanceof ApiError || error instanceof AeroleadsAPIError) { + throw error; + } + if (error instanceof Error) { + throw new AeroleadsAPIError(error.message); + } + throw new AeroleadsAPIError('Unknown error'); + } +} diff --git a/packages/aeroleads/endpoints/index.ts b/packages/aeroleads/endpoints/index.ts new file mode 100644 index 000000000..cf2a893e4 --- /dev/null +++ b/packages/aeroleads/endpoints/index.ts @@ -0,0 +1,2 @@ +export { LinkedinDetails } from './linkedin-details'; +export * from './types'; diff --git a/packages/aeroleads/endpoints/linkedin-details.ts b/packages/aeroleads/endpoints/linkedin-details.ts new file mode 100644 index 000000000..4df40b085 --- /dev/null +++ b/packages/aeroleads/endpoints/linkedin-details.ts @@ -0,0 +1,43 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAeroleadsRequest } from '../client'; +import type { AeroleadsContext } from '../index'; +import { cacheLinkedinDetails } from './persist'; +import type { + GetLinkedinDetailsInput, + GetLinkedinDetailsResponse, +} from './types'; +import { + AeroleadsEndpointInputSchemas, + AeroleadsEndpointOutputSchemas, +} from './types'; + +export const LinkedinDetails = { + get: async ( + ctx: AeroleadsContext, + input: GetLinkedinDetailsInput, + ): Promise => { + const parsedInput = + AeroleadsEndpointInputSchemas.linkedinDetailsGet.parse(input); + + const rawResponse = await makeAeroleadsRequest( + '/api/get_linkedin_details', + ctx.key, + { + method: 'GET', + query: { + linkedin_url: parsedInput.linkedin_url, + }, + }, + ); + const response = + AeroleadsEndpointOutputSchemas.linkedinDetailsGet.parse(rawResponse); + await cacheLinkedinDetails(ctx, parsedInput.linkedin_url, response); + await logEventFromContext( + ctx, + 'aeroleads.linkedinDetails.get', + { ...input }, + 'completed', + ); + return response; + }, +}; diff --git a/packages/aeroleads/endpoints/persist.ts b/packages/aeroleads/endpoints/persist.ts new file mode 100644 index 000000000..e5b36e6ff --- /dev/null +++ b/packages/aeroleads/endpoints/persist.ts @@ -0,0 +1,41 @@ +import type { AeroleadsLinkedinDetails } from '../schema/database'; + +type EntityStore = { + upsertByEntityId: (entityId: string, data: T) => Promise; +}; + +type CacheCtx = { + db?: { + linkedinDetails?: EntityStore; + }; +}; + +function entityDb(ctx: unknown): NonNullable { + if (typeof ctx !== 'object' || ctx === null) return {}; + return (ctx as CacheCtx).db ?? {}; +} + +async function safely(operation: () => Promise, what: string) { + try { + await operation(); + } catch (error) { + console.warn(`[AEROLEADS] failed to cache ${what}:`, error); + } +} + +export async function cacheLinkedinDetails( + ctx: unknown, + linkedinUrl: string | undefined, + details: AeroleadsLinkedinDetails | undefined, +) { + const store = entityDb(ctx).linkedinDetails; + if (!store || !linkedinUrl || !details) return; + await safely( + () => + store.upsertByEntityId(linkedinUrl, { + ...details, + linkedin_url: details.linkedin_url ?? linkedinUrl, + }), + `linkedin ${linkedinUrl}`, + ); +} diff --git a/packages/aeroleads/endpoints/types.ts b/packages/aeroleads/endpoints/types.ts new file mode 100644 index 000000000..1fb1adef7 --- /dev/null +++ b/packages/aeroleads/endpoints/types.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +import { AeroleadsLinkedinDetails } from '../schema/database'; + +export const GetLinkedinDetailsInputSchema = z.object({ + linkedin_url: z + .string() + .url() + .refine( + (val) => { + try { + const url = new URL(val); + const host = url.hostname.toLowerCase(); + const hostOk = + host === 'linkedin.com' || host.endsWith('.linkedin.com'); + return hostOk && /^\/in\/[^/]+/i.test(url.pathname); + } catch { + return false; + } + }, + { message: 'Must be a valid LinkedIn profile URL' }, + ) + .describe('The LinkedIn profile URL of the prospect'), +}); +export type GetLinkedinDetailsInput = z.infer< + typeof GetLinkedinDetailsInputSchema +>; + +export const GetLinkedinDetailsResponseSchema = + AeroleadsLinkedinDetails.passthrough(); +export type GetLinkedinDetailsResponse = z.infer< + typeof GetLinkedinDetailsResponseSchema +>; + +export type AeroleadsEndpointInputs = { + linkedinDetailsGet: GetLinkedinDetailsInput; +}; + +export type AeroleadsEndpointOutputs = { + linkedinDetailsGet: GetLinkedinDetailsResponse; +}; + +export const AeroleadsEndpointInputSchemas = { + linkedinDetailsGet: GetLinkedinDetailsInputSchema, +} as const; + +export const AeroleadsEndpointOutputSchemas = { + linkedinDetailsGet: GetLinkedinDetailsResponseSchema, +} as const; diff --git a/packages/aeroleads/error-handlers.ts b/packages/aeroleads/error-handlers.ts new file mode 100644 index 000000000..f66abb405 --- /dev/null +++ b/packages/aeroleads/error-handlers.ts @@ -0,0 +1,59 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; +import { AeroleadsAPIError } from './client'; + +function getStatus(error: Error): number | undefined { + if (error instanceof ApiError) return error.status; + if (error instanceof AeroleadsAPIError) return error.status; + return undefined; +} + +function messageOf(error: Error): string { + return error.message.toLowerCase(); +} + +export const errorHandlers = { + AUTH_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 401) return true; + const msg = messageOf(error); + return ( + msg.includes('unauthorized') || + msg.includes('wrong api key') || + msg.includes('valid api key') || + msg.includes('pass your api key') + ); + }, + handler: async () => ({ maxRetries: 0 }), + }, + CREDIT_LIMIT_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 402) return true; + return messageOf(error).includes('credit limit'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 429) return true; + const msg = messageOf(error); + return msg.includes('rate limit') || msg.includes('too many requests'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + BAD_REQUEST_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 400) return true; + return messageOf(error).includes('linkedin url'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + NOT_FOUND_ERROR: { + match: (error: Error) => messageOf(error).includes('no profile details'), + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/aeroleads/index.ts b/packages/aeroleads/index.ts new file mode 100644 index 000000000..8aafe5074 --- /dev/null +++ b/packages/aeroleads/index.ts @@ -0,0 +1,154 @@ +import type { + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { LinkedinDetails } from './endpoints'; +import type { + AeroleadsEndpointInputs, + AeroleadsEndpointOutputs, +} from './endpoints/types'; +import { + AeroleadsEndpointInputSchemas, + AeroleadsEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AeroleadsSchema } from './schema'; + +export type AeroleadsPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalAeroleadsPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type AeroleadsContext = CorsairPluginContext< + typeof AeroleadsSchema, + AeroleadsPluginOptions +>; + +export type AeroleadsKeyBuilderContext = + KeyBuilderContext; + +export type AeroleadsBoundEndpoints = BindEndpoints< + typeof aeroleadsEndpointsNested +>; + +type AeroleadsEndpoint = + CorsairEndpoint< + AeroleadsContext, + AeroleadsEndpointInputs[K], + AeroleadsEndpointOutputs[K] + >; + +export type AeroleadsEndpoints = { + linkedinDetailsGet: AeroleadsEndpoint<'linkedinDetailsGet'>; +}; + +const aeroleadsEndpointsNested = { + linkedinDetails: { + get: LinkedinDetails.get, + }, +} as const; + +export const aeroleadsEndpointSchemas = { + 'linkedinDetails.get': { + input: AeroleadsEndpointInputSchemas.linkedinDetailsGet, + output: AeroleadsEndpointOutputSchemas.linkedinDetailsGet, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof aeroleadsEndpointsNested +>; + +const defaultAuthType = 'api_key' as const; + +const aeroleadsEndpointMeta = { + 'linkedinDetails.get': { + riskLevel: 'read', + description: + 'Retrieve detailed information about a prospect using their LinkedIn profile URL, including emails, phone numbers, job details, and company data', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof aeroleadsEndpointsNested +>; + +export const aeroleadsAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseAeroleadsPlugin = + CorsairPlugin< + 'aeroleads', + typeof AeroleadsSchema, + typeof aeroleadsEndpointsNested, + Record, + T, + typeof defaultAuthType + >; + +export type InternalAeroleadsPlugin = + BaseAeroleadsPlugin; + +export type ExternalAeroleadsPlugin = + BaseAeroleadsPlugin; + +export function aeroleads( + incomingOptions: AeroleadsPluginOptions & T = {} as AeroleadsPluginOptions & + T, +): ExternalAeroleadsPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'aeroleads', + authConfig: aeroleadsAuthConfig, + schema: AeroleadsSchema, + options: options, + hooks: options.hooks, + endpoints: aeroleadsEndpointsNested, + webhooks: {}, + endpointMeta: aeroleadsEndpointMeta, + endpointSchemas: aeroleadsEndpointSchemas, + webhookSchemas: {}, + pluginWebhookMatcher: () => false, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: AeroleadsKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys?.get_api_key(); + if (!res) { + throw new AuthMissingError('aeroleads', 'api_key'); + } + return res; + } + + throw new AuthMissingError('aeroleads', 'api_key'); + }, + } satisfies InternalAeroleadsPlugin; +} + +export type { + AeroleadsEndpointInputs, + AeroleadsEndpointOutputs, + GetLinkedinDetailsInput, + GetLinkedinDetailsResponse, +} from './endpoints/types'; diff --git a/packages/aeroleads/jest.config.cjs b/packages/aeroleads/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/aeroleads/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/aeroleads/package.json b/packages/aeroleads/package.json new file mode 100644 index 000000000..2f1c7e0ae --- /dev/null +++ b/packages/aeroleads/package.json @@ -0,0 +1,46 @@ +{ + "name": "@corsair-dev/aeroleads", + "version": "0.1.0", + "description": "Aeroleads plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest", + "lint": "biome check ." + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "aeroleads", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/aeroleads/schema.test.ts b/packages/aeroleads/schema.test.ts new file mode 100644 index 000000000..21258663d --- /dev/null +++ b/packages/aeroleads/schema.test.ts @@ -0,0 +1,24 @@ +import { AeroleadsEndpointOutputSchemas } from './endpoints/types'; +import { AeroleadsSchema } from './schema'; + +describe('Aeroleads schema', () => { + it('declares a semver version', () => { + expect(AeroleadsSchema.version).toBeDefined(); + expect(AeroleadsSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares db schema entities aligned to Aeroleads resources', () => { + expect(Object.keys(AeroleadsSchema.entities)).toEqual(['linkedinDetails']); + }); + it('accepts valid linkedin details response', () => { + const live = { + full_name: 'Ayushi Mathur', + linkedin_url: 'https://www.linkedin.com/in/ayushi-mathur-061b9010b/', + job_title: 'Software Engineer', + emails: 'test@example.com', + }; + expect( + AeroleadsEndpointOutputSchemas.linkedinDetailsGet.parse(live), + ).toMatchObject(live); + }); +}); diff --git a/packages/aeroleads/schema/database.ts b/packages/aeroleads/schema/database.ts new file mode 100644 index 000000000..a263d1db4 --- /dev/null +++ b/packages/aeroleads/schema/database.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +// Response schema for GET /api/get_linkedin_details +// Based on https://aeroleads.com/api#linkedin_api_get_started +export const AeroleadsLinkedinDetails = z.object({ + full_name: z.string().nullable().optional(), + first_name: z.string().nullable().optional(), + last_name: z.string().nullable().optional(), + linkedin_url: z.string().nullable().optional(), + location: z.string().nullable().optional(), + job_title: z.string().nullable().optional(), + job_company_name: z.string().nullable().optional(), + job_company_url: z.string().nullable().optional(), + job_company_linkedin_url: z.string().nullable().optional(), + job_description: z.string().nullable().optional(), + education: z + .union([z.string(), z.array(z.unknown())]) + .nullable() + .optional(), + experience: z + .union([z.string(), z.array(z.unknown())]) + .nullable() + .optional(), + interests: z + .union([z.string(), z.array(z.unknown())]) + .nullable() + .optional(), + skills: z + .union([z.string(), z.array(z.unknown())]) + .nullable() + .optional(), + languages: z + .union([ + z.string(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullable() + .optional(), + emails: z + .union([z.string(), z.array(z.unknown())]) + .nullable() + .optional(), + phone_numbers: z + .union([z.string(), z.array(z.unknown())]) + .nullable() + .optional(), + cb_rank: z.string().nullable().optional(), + db_logo_url: z.string().nullable().optional(), + profile_picture_url: z.string().nullable().optional(), + job_company_size: z.union([z.string(), z.number()]).nullable().optional(), + industry: z.string().nullable().optional(), + job_title_detailed_role_s2: z.string().nullable().optional(), + organization_founded_year_s2: z.string().nullable().optional(), + organization_facebook_url_s2: z.string().nullable().optional(), + organization_twitter_url_s2: z.string().nullable().optional(), + organization_current_technologies_s2: z.string().nullable().optional(), + emails_s2: z.string().nullable().optional(), + phone_numbers_s2: z.string().nullable().optional(), +}); +export type AeroleadsLinkedinDetails = z.infer; diff --git a/packages/aeroleads/schema/index.ts b/packages/aeroleads/schema/index.ts new file mode 100644 index 000000000..4b5d18391 --- /dev/null +++ b/packages/aeroleads/schema/index.ts @@ -0,0 +1,10 @@ +import { AeroleadsLinkedinDetails } from './database'; + +export const AeroleadsSchema = { + version: '1.0.0', + entities: { + linkedinDetails: AeroleadsLinkedinDetails, + }, +} as const; + +export * from './database'; diff --git a/packages/aeroleads/tsconfig.json b/packages/aeroleads/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/aeroleads/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/aeroleads/tsup.config.ts b/packages/aeroleads/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/aeroleads/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 7f168fd0c..d45d57a26 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -16,6 +16,7 @@ export const BaseProviders = [ 'abstract', 'activetrail', 'addresszen', + 'aeroleads', 'affinda', 'agencyzoom', 'agentmail', @@ -141,6 +142,7 @@ export const ProviderDisplayNames = { abstract: 'Abstract', activetrail: 'Active Trail', addresszen: 'Addresszen', + aeroleads: 'Aeroleads', affinda: 'Affinda', agencyzoom: 'AgencyZoom', agentmail: 'AgentMail', @@ -273,6 +275,7 @@ export type AllProviders = | 'abstract' | 'activetrail' | 'addresszen' + | 'aeroleads' | 'affinda' | 'agencyzoom' | 'agentmail' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2e91d2ba..f6c0af82d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -374,6 +374,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/aeroleads: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/affinda: devDependencies: '@types/jest':