From 003884a04ab49d0080045dd2ea63236e68515d72 Mon Sep 17 00:00:00 2001 From: Kiril Date: Thu, 15 Oct 2026 17:20:04 +0200 Subject: [PATCH] Add endorsePersonSkill and endorsePersonSkills operations - endorsePersonSkill: endorse one skill on a 1st-degree connection's profile by its exact name. - endorsePersonSkills: endorse every skill whose name matches any of the given case-insensitive RE2 patterns (all skills when omitted); returns the selected skills in profile order, each with its own success/error. - Add the skillNotFound, endorsementUnavailable and limitExceeded action error types. - Bump version to 2.3.19. --- examples/endorse-skills.ts | 48 +++ package.json | 2 +- src/core/operation.ts | 2 + src/index.ts | 19 ++ src/mappers/endorse-person-skills-mapper.ts | 88 ++++++ .../__tests__/endorse-operations.test.ts | 293 ++++++++++++++++++ src/operations/endorse-person-skill.ts | 10 + src/operations/endorse-person-skills.ts | 11 + src/operations/index.ts | 2 + .../endorse-person-skill-params.type.ts | 6 + .../endorse-person-skills-params.type.ts | 6 + src/types/actions/index.ts | 3 + .../actions/person-skill-endorsement.type.ts | 7 + src/types/errors.ts | 10 +- 14 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 examples/endorse-skills.ts create mode 100644 src/mappers/endorse-person-skills-mapper.ts create mode 100644 src/operations/__tests__/endorse-operations.test.ts create mode 100644 src/operations/endorse-person-skill.ts create mode 100644 src/operations/endorse-person-skills.ts create mode 100644 src/types/actions/endorse-person-skill-params.type.ts create mode 100644 src/types/actions/endorse-person-skills-params.type.ts create mode 100644 src/types/actions/person-skill-endorsement.type.ts diff --git a/examples/endorse-skills.ts b/examples/endorse-skills.ts new file mode 100644 index 0000000..d760b9b --- /dev/null +++ b/examples/endorse-skills.ts @@ -0,0 +1,48 @@ +import LinkedApi, { LinkedApiError } from '@linkedapi/node'; + +async function endorseSkillsExample(): Promise { + const linkedApi = new LinkedApi({ + linkedApiToken: process.env.LINKED_API_TOKEN!, + identificationToken: process.env.IDENTIFICATION_TOKEN!, + }); + const personUrl = 'https://www.linkedin.com/in/example-person'; + + try { + const workflow = await linkedApi.endorsePersonSkill.execute({ + personUrl, + skillName: 'Project Management', + }); + const result = await linkedApi.endorsePersonSkill.result(workflow.workflowId); + if (result.errors.length > 0) { + console.error('Skill endorsement failed:', result.errors); + } else { + console.log('Skill endorsed successfully'); + } + + const skillsWorkflow = await linkedApi.endorsePersonSkills.execute({ + personUrl, + skillNamePatterns: ['business', 'project\\s*manage'], + }); + const skillsResult = await linkedApi.endorsePersonSkills.result(skillsWorkflow.workflowId); + if (skillsResult.errors.length > 0) { + console.error('Unable to read the profile skills:', skillsResult.errors); + } + for (const { skillName, success, error } of skillsResult.data ?? []) { + if (success) { + console.log('Endorsed:', skillName); + } else { + console.error('Endorsement failed:', skillName, error); + } + } + } catch (error) { + if (error instanceof LinkedApiError) { + console.error('Linked API error:', error.type, error.message); + } else { + console.error('Unknown error:', error); + } + } +} + +if (require.main === module) { + void endorseSkillsExample(); +} diff --git a/package.json b/package.json index ee98371..36c84de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@linkedapi/node", - "version": "2.3.18", + "version": "2.3.19", "description": "Control your LinkedIn accounts and retrieve real-time data, all through this Node.js SDK.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/core/operation.ts b/src/core/operation.ts index f107526..e14698b 100644 --- a/src/core/operation.ts +++ b/src/core/operation.ts @@ -30,6 +30,8 @@ export const OPERATION_NAME = { ignoreInvitation: 'ignoreInvitation', retrieveConnections: 'retrieveConnections', removeConnection: 'removeConnection', + endorsePersonSkill: 'endorsePersonSkill', + endorsePersonSkills: 'endorsePersonSkills', searchCompanies: 'searchCompanies', searchPeople: 'searchPeople', searchJobs: 'searchJobs', diff --git a/src/index.ts b/src/index.ts index b034dd5..0180047 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ import { CreatePost, CreateRepost, CustomWorkflow, + EndorsePersonSkill, + EndorsePersonSkills, FetchCompany, FetchJob, FetchPerson, @@ -125,6 +127,8 @@ class LinkedApi { this.ignoreInvitation = new IgnoreInvitation(this.httpClient); this.retrieveConnections = new RetrieveConnections(this.httpClient); this.removeConnection = new RemoveConnection(this.httpClient); + this.endorsePersonSkill = new EndorsePersonSkill(this.httpClient); + this.endorsePersonSkills = new EndorsePersonSkills(this.httpClient); this.searchCompanies = new SearchCompanies(this.httpClient); this.searchPeople = new SearchPeople(this.httpClient); this.searchJobs = new SearchJobs(this.httpClient); @@ -168,6 +172,8 @@ class LinkedApi { this.ignoreInvitation, this.retrieveConnections, this.removeConnection, + this.endorsePersonSkill, + this.endorsePersonSkills, this.searchCompanies, this.searchPeople, this.searchJobs, @@ -1390,6 +1396,19 @@ class LinkedApi { */ public removeConnection: RemoveConnection; + /** + * Endorse a skill by its exact name on a 1st-degree connection's profile. + * Letter case and extra spaces are ignored. An existing endorsement is preserved. + */ + public endorsePersonSkill: EndorsePersonSkill; + + /** + * Endorse skills matching any RE2 pattern, or every skill when patterns are omitted. + * Results contain only selected skills in profile order, with per-skill errors on each item. + * Failures opening the profile or reading skills appear in the top-level errors with no data. + */ + public endorsePersonSkills: EndorsePersonSkills; + /** * React to a LinkedIn post with an emoji reaction. * diff --git a/src/mappers/endorse-person-skills-mapper.ts b/src/mappers/endorse-person-skills-mapper.ts new file mode 100644 index 0000000..d97bba1 --- /dev/null +++ b/src/mappers/endorse-person-skills-mapper.ts @@ -0,0 +1,88 @@ +import type { + TEndorsePersonSkillsParams, + TPersonSkill, + TPersonSkillEndorsement, +} from '../types/actions'; +import { LinkedApiError } from '../types/errors'; +import type { + TWorkflowCompletion, + TWorkflowDefinition, + TWorkflowSingleData, +} from '../types/workflows'; + +import { BaseMapper, TMappedResponse } from './base-mapper.abstract'; + +type TSkillCompletion = TPersonSkill & TWorkflowSingleData; + +export class EndorsePersonSkillsMapper extends BaseMapper< + TEndorsePersonSkillsParams, + Array +> { + public override mapRequest(params: TEndorsePersonSkillsParams): TWorkflowDefinition { + const { personUrl, skillNamePatterns } = params; + + return { + actionType: 'st.openPersonPage', + personUrl, + basicInfo: false, + then: { + actionType: 'st.retrievePersonSkills', + then: { + actionType: 'st.doForSkills', + ...(skillNamePatterns !== undefined ? { skillNamePatterns } : {}), + then: { + actionType: 'st.endorsePersonSkill', + }, + }, + }, + }; + } + + public override mapResponse( + completion: TWorkflowCompletion, + ): TMappedResponse> { + const root = Array.isArray(completion) ? completion[0] : completion; + if (!root) { + throw LinkedApiError.unknownError(); + } + if (root.error) { + return { errors: [root.error] }; + } + + const then = Array.isArray(root.data) ? undefined : root.data?.then; + const skillsAction = Array.isArray(then) + ? then.find((action) => action.actionType === 'st.retrievePersonSkills') + : then; + if (!skillsAction || skillsAction.actionType !== 'st.retrievePersonSkills') { + throw LinkedApiError.unknownError(); + } + if (skillsAction.error) { + return { errors: [skillsAction.error] }; + } + if (!Array.isArray(skillsAction.data)) { + throw LinkedApiError.unknownError(); + } + + const skills = skillsAction.data as Array; + const data: Array = []; + for (const skill of skills) { + const endorsement = Array.isArray(skill.then) + ? skill.then.find((action) => action.actionType === 'st.endorsePersonSkill') + : skill.then; + if (!endorsement || endorsement.actionType !== 'st.endorsePersonSkill') { + continue; + } + + data.push({ + skillName: skill.name, + success: endorsement.success, + ...(endorsement.error ? { error: endorsement.error } : {}), + }); + } + + return { + data, + errors: [], + }; + } +} diff --git a/src/operations/__tests__/endorse-operations.test.ts b/src/operations/__tests__/endorse-operations.test.ts new file mode 100644 index 0000000..fa46ba6 --- /dev/null +++ b/src/operations/__tests__/endorse-operations.test.ts @@ -0,0 +1,293 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import LinkedApi from '../../index'; + +const PERSON_URL = 'https://www.linkedin.com/in/example-person' as const; +const WORKFLOW_ID = 'endorsement-workflow' as const; +const PATTERNS = ['project\\s*manage', '^scrum$', 'business'] as const; + +let fetchMock: jest.SpiedFunction; +let linkedApi: LinkedApi; + +beforeEach((): void => { + fetchMock = jest + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('Unexpected HTTP request')); + linkedApi = new LinkedApi({ + linkedApiToken: 'test-linked-api-token', + identificationToken: 'test-identification-token', + baseUrl: 'https://api.test', + }); +}); + +afterEach((): void => { + jest.restoreAllMocks(); +}); + +function queueWorkflow(completion: unknown): void { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + result: { workflowId: WORKFLOW_ID, +workflowStatus: 'pending' }, + }), + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + result: { workflowStatus: 'completed', +completion }, + }), + ), + ); +} + +function postedWorkflow(): unknown { + const request = fetchMock.mock.calls[0]; + expect(request?.[0]).toBe('https://api.test/workflows'); + expect(request?.[1]?.method).toBe('POST'); + return JSON.parse(String(request?.[1]?.body)); +} + +function skillsCompletion(skills: Array>): Record { + return { + actionType: 'st.openPersonPage', + success: true, + data: { + then: { + actionType: 'st.retrievePersonSkills', + success: true, + data: skills, + }, + }, + }; +} + +describe('endorsePersonSkill', (): void => { + it('posts one root action and returns no data or errors on success', async (): Promise => { + queueWorkflow({ actionType: 'st.endorsePersonSkill', +success: true }); + + const started = await linkedApi.endorsePersonSkill.execute({ + personUrl: PERSON_URL, + skillName: 'Project Management', + }); + const result = await linkedApi.endorsePersonSkill.result(started.workflowId); + + expect(linkedApi.endorsePersonSkill.operationName).toBe('endorsePersonSkill'); + expect(postedWorkflow()).toEqual({ + actionType: 'st.endorsePersonSkill', + personUrl: PERSON_URL, + skillName: 'Project Management', + }); + expect(result.data).toBeUndefined(); + expect(result.errors).toEqual([]); + expect(fetchMock.mock.calls[1]?.[0]).toBe(`https://api.test/workflows/${WORKFLOW_ID}`); + }); + + it.each([ + 'personNotFound', + 'connectionNotFound', + 'skillNotFound', + 'endorsementUnavailable', + 'limitExceeded', + ])('preserves the %s action error', async (type): Promise => { + const error = { type, +message: `Fixture ${type}` }; + queueWorkflow({ actionType: 'st.endorsePersonSkill', +success: false, +error }); + + const started = await linkedApi.endorsePersonSkill.execute({ + personUrl: PERSON_URL, + skillName: 'Project Management', + }); + const result = await linkedApi.endorsePersonSkill.result(started.workflowId); + + expect(result.data).toBeUndefined(); + expect(result.errors).toEqual([error]); + }); +}); + +describe('endorsePersonSkills', (): void => { + it('posts the pattern workflow and maps only selected skills in profile order', async (): Promise => { + const unavailable = { type: 'endorsementUnavailable', +message: 'Fixture unavailable' }; + const disconnected = { type: 'connectionNotFound', +message: 'Fixture disconnected' }; + queueWorkflow( + skillsCompletion([ + { + name: 'Project Management', + then: [{ actionType: 'st.endorsePersonSkill', +success: true }], + }, + { name: 'Python' }, + { + name: 'Business Strategy', + then: [{ actionType: 'st.endorsePersonSkill', +success: false, +error: unavailable }], + }, + { + name: 'Scrum', + then: [{ actionType: 'st.endorsePersonSkill', +success: false, +error: disconnected }], + }, + ]), + ); + + const started = await linkedApi.endorsePersonSkills.execute({ + personUrl: PERSON_URL, + skillNamePatterns: [...PATTERNS], + }); + const result = await linkedApi.endorsePersonSkills.result(started.workflowId); + + expect(linkedApi.endorsePersonSkills.operationName).toBe('endorsePersonSkills'); + expect(postedWorkflow()).toEqual({ + actionType: 'st.openPersonPage', + personUrl: PERSON_URL, + basicInfo: false, + then: { + actionType: 'st.retrievePersonSkills', + then: { + actionType: 'st.doForSkills', + skillNamePatterns: [...PATTERNS], + then: { actionType: 'st.endorsePersonSkill' }, + }, + }, + }); + expect(result.data).toEqual([ + { skillName: 'Project Management', +success: true }, + { skillName: 'Business Strategy', +success: false, +error: unavailable }, + { skillName: 'Scrum', +success: false, +error: disconnected }, + ]); + expect(result.errors).toEqual([]); + }); + + it.each([{ personUrl: PERSON_URL }, { personUrl: PERSON_URL, +skillNamePatterns: undefined }])( + 'omits absent patterns and maps every skill for %j', + async (params): Promise => { + queueWorkflow( + skillsCompletion([ + { + name: 'Business Strategy', + then: [{ actionType: 'st.endorsePersonSkill', +success: true }], + }, + { name: 'Python', +then: [{ actionType: 'st.endorsePersonSkill', +success: true }] }, + ]), + ); + + const started = await linkedApi.endorsePersonSkills.execute(params); + const result = await linkedApi.endorsePersonSkills.result(started.workflowId); + + expect(postedWorkflow()).toEqual({ + actionType: 'st.openPersonPage', + personUrl: PERSON_URL, + basicInfo: false, + then: { + actionType: 'st.retrievePersonSkills', + then: { actionType: 'st.doForSkills', +then: { actionType: 'st.endorsePersonSkill' } }, + }, + }); + expect(result.data).toEqual([ + { skillName: 'Business Strategy', +success: true }, + { skillName: 'Python', +success: true }, + ]); + expect(result.errors).toEqual([]); + }, + ); + + it('keeps limitExceeded on each remaining selected skill', async (): Promise => { + const error = { type: 'limitExceeded', +message: 'Fixture endorsement cap' }; + queueWorkflow( + skillsCompletion([ + { name: 'Python', +then: [{ actionType: 'st.endorsePersonSkill', +success: true }] }, + { name: 'Scrum', +then: [{ actionType: 'st.endorsePersonSkill', +success: false, +error }] }, + { name: 'Agile', +then: [{ actionType: 'st.endorsePersonSkill', +success: false, +error }] }, + ]), + ); + + const started = await linkedApi.endorsePersonSkills.execute({ personUrl: PERSON_URL }); + const result = await linkedApi.endorsePersonSkills.result(started.workflowId); + + expect(result.data).toEqual([ + { skillName: 'Python', +success: true }, + { skillName: 'Scrum', +success: false, +error }, + { skillName: 'Agile', +success: false, +error }, + ]); + expect(result.errors).toEqual([]); + }); + + it.each(['page', 'skills'])( + 'reports a failed %s action as a top-level error', + async (stage): Promise => { + const error = { type: 'personNotFound', +message: 'Fixture missing person' }; + const failedAction = { + actionType: stage === 'page' ? 'st.openPersonPage' : 'st.retrievePersonSkills', + success: false, + error, + }; + queueWorkflow( + stage === 'page' + ? failedAction + : { actionType: 'st.openPersonPage', +success: true, +data: { then: failedAction } }, + ); + + const started = await linkedApi.endorsePersonSkills.execute({ personUrl: PERSON_URL }); + const result = await linkedApi.endorsePersonSkills.result(started.workflowId); + + expect(result.data).toBeUndefined(); + expect(result.errors).toEqual([error]); + }, + ); + + it.each([{ skills: [] }, { skills: [{ name: 'Python' }] }])( + 'returns an empty selection for %j', + async ({ skills }): Promise => { + queueWorkflow(skillsCompletion(skills)); + + const started = await linkedApi.endorsePersonSkills.execute({ + personUrl: PERSON_URL, + skillNamePatterns: ['^scrum$'], + }); + const result = await linkedApi.endorsePersonSkills.result(started.workflowId); + + expect(result.data).toEqual([]); + expect(result.errors).toEqual([]); + }, + ); +}); diff --git a/src/operations/endorse-person-skill.ts b/src/operations/endorse-person-skill.ts new file mode 100644 index 0000000..97a5243 --- /dev/null +++ b/src/operations/endorse-person-skill.ts @@ -0,0 +1,10 @@ +import { Operation, TOperationName } from '../core'; +import { VoidWorkflowMapper } from '../mappers'; +import { TEndorsePersonSkillParams } from '../types'; + +export class EndorsePersonSkill extends Operation { + public override readonly operationName: TOperationName = 'endorsePersonSkill'; + protected override readonly mapper = new VoidWorkflowMapper( + 'st.endorsePersonSkill', + ); +} diff --git a/src/operations/endorse-person-skills.ts b/src/operations/endorse-person-skills.ts new file mode 100644 index 0000000..677fa90 --- /dev/null +++ b/src/operations/endorse-person-skills.ts @@ -0,0 +1,11 @@ +import { Operation, TOperationName } from '../core'; +import { EndorsePersonSkillsMapper } from '../mappers/endorse-person-skills-mapper'; +import { TEndorsePersonSkillsParams, TPersonSkillEndorsement } from '../types'; + +export class EndorsePersonSkills extends Operation< + TEndorsePersonSkillsParams, + Array +> { + public override readonly operationName: TOperationName = 'endorsePersonSkills'; + protected override readonly mapper = new EndorsePersonSkillsMapper(); +} diff --git a/src/operations/index.ts b/src/operations/index.ts index c0535b7..294ed80 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -30,6 +30,8 @@ export * from './accept-invitation'; export * from './ignore-invitation'; export * from './retrieve-connections'; export * from './remove-connection'; +export * from './endorse-person-skill'; +export * from './endorse-person-skills'; export * from './react-to-post'; export * from './comment-on-post'; export * from './react-to-comment'; diff --git a/src/types/actions/endorse-person-skill-params.type.ts b/src/types/actions/endorse-person-skill-params.type.ts new file mode 100644 index 0000000..e99d70b --- /dev/null +++ b/src/types/actions/endorse-person-skill-params.type.ts @@ -0,0 +1,6 @@ +import type { TBaseActionParams } from '../params'; + +export interface TEndorsePersonSkillParams extends TBaseActionParams { + personUrl: string; + skillName: string; +} diff --git a/src/types/actions/endorse-person-skills-params.type.ts b/src/types/actions/endorse-person-skills-params.type.ts new file mode 100644 index 0000000..3248dc4 --- /dev/null +++ b/src/types/actions/endorse-person-skills-params.type.ts @@ -0,0 +1,6 @@ +import type { TBaseActionParams } from '../params'; + +export interface TEndorsePersonSkillsParams extends TBaseActionParams { + personUrl: string; + skillNamePatterns?: Array; +} diff --git a/src/types/actions/index.ts b/src/types/actions/index.ts index 5f1b29e..69a7301 100644 --- a/src/types/actions/index.ts +++ b/src/types/actions/index.ts @@ -2,6 +2,8 @@ export * from './account'; export * from './accept-invitation-params.type'; export * from './company'; export * from './connection'; +export * from './endorse-person-skill-params.type'; +export * from './endorse-person-skills-params.type'; export * from './feed-post.type'; export * from './ignore-invitation-params.type'; export * from './invitation-target.type'; @@ -10,6 +12,7 @@ export * from './job'; export * from './message'; export * from './network'; export * from './person'; +export * from './person-skill-endorsement.type'; export * from './post'; export * from './profile-viewer.type'; export * from './retrieve-feed-params.type'; diff --git a/src/types/actions/person-skill-endorsement.type.ts b/src/types/actions/person-skill-endorsement.type.ts new file mode 100644 index 0000000..390d9c0 --- /dev/null +++ b/src/types/actions/person-skill-endorsement.type.ts @@ -0,0 +1,7 @@ +import type { TLinkedApiActionError } from '../errors'; + +export interface TPersonSkillEndorsement { + skillName: string; + success: boolean; + error?: TLinkedApiActionError; +} diff --git a/src/types/errors.ts b/src/types/errors.ts index 0204ecd..633e70c 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -5,7 +5,7 @@ import { TOperationName } from '../core'; * @see {@link https://linkedapi.io/docs/actions-overview/#result-options} * This error signals that the workflow action failed to complete, but the workflow completed as expected. * Common types: - * - personNotFound (sendMessage, syncConversation, checkConnectionStatus, sendConnectionRequest, withdrawConnectionRequest, removeConnection, fetchPerson, nvSendMessage, nvSyncConversation, nvFetchPerson) + * - personNotFound (sendMessage, syncConversation, checkConnectionStatus, sendConnectionRequest, withdrawConnectionRequest, removeConnection, endorsePersonSkill, endorsePersonSkills, fetchPerson, nvSendMessage, nvSyncConversation, nvFetchPerson) * - selfProfileNotAllowed (fetchPerson, nvFetchPerson) * - messagingNotAllowed (sendMessage, nvSendMessage) * - alreadyPending (sendConnectionRequest) @@ -16,7 +16,10 @@ import { TOperationName } from '../core'; * - requestNotAllowed (sendConnectionRequest) * - notPending (withdrawConnectionRequest)) * - retrievingNotAllowed (retrieveConnections, fetchCompany, nvFetchCompany) - * - connectionNotFound (removeConnection) + * - connectionNotFound (removeConnection, endorsePersonSkill, endorsePersonSkills) + * - skillNotFound (endorsePersonSkill) + * - endorsementUnavailable (endorsePersonSkill, endorsePersonSkills) + * - limitExceeded (operations subject to action limits, including endorsePersonSkill and endorsePersonSkills) * - searchingNotAllowed (searchCompanies, searchPeople, searchJobs, searchPosts, nvSearchCompanies, nvSearchPeople) * - filterNotApplied (searchPeople, searchCompanies, searchPosts, retrieveConnections, fetchCompany, nvSearchPeople, nvSearchCompanies, nvFetchCompany) * - searchInterfaceMismatch (searchJobs) @@ -48,6 +51,9 @@ export const LINKED_API_ACTION_ERROR = { notPending: 'notPending', retrievingNotAllowed: 'retrievingNotAllowed', connectionNotFound: 'connectionNotFound', + skillNotFound: 'skillNotFound', + endorsementUnavailable: 'endorsementUnavailable', + limitExceeded: 'limitExceeded', searchingNotAllowed: 'searchingNotAllowed', searchInterfaceMismatch: 'searchInterfaceMismatch', filterNotApplied: 'filterNotApplied',