diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index b0323ec3c..e50a1cae5 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -204,6 +204,7 @@ export const BaseProviders = [ 'resend', 'retailed', 'salesforce', + 'sapsuccessfactors', 'scrapegraphai', 'securitytrails', 'sendgrid', @@ -451,6 +452,7 @@ export const ProviderDisplayNames = { resend: 'Resend', retailed: 'Retailed', salesforce: 'Salesforce', + sapsuccessfactors: 'SAP SuccessFactors', scrapegraphai: 'ScrapeGraphAI', securitytrails: 'SecurityTrails', sendgrid: 'SendGrid', @@ -704,6 +706,7 @@ export type AllProviders = | 'resend' | 'retailed' | 'salesforce' + | 'sapsuccessfactors' | 'scrapegraphai' | 'securitytrails' | 'sendgrid' diff --git a/packages/sapsuccessfactors/api.test.ts b/packages/sapsuccessfactors/api.test.ts new file mode 100644 index 000000000..15947aabb --- /dev/null +++ b/packages/sapsuccessfactors/api.test.ts @@ -0,0 +1,323 @@ +import { request } from 'corsair/http'; +import { makeSapsuccessfactorsRequest } from './client'; +import { executeSapOperation } from './endpoints/factory'; +import { getSapRoute, sapRoutes } from './endpoints/routes'; +import { SapsuccessfactorsEndpointInputSchemas } from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import type { SapsuccessfactorsContext } from './index'; +import { sapsuccessfactors } from './index'; + +jest.mock('corsair/http', () => ({ + request: jest.fn().mockResolvedValue({ + d: { + results: [ + { + userId: 'cgrant', + personIdExternal: 'p1', + jobReqId: 1, + candidateId: 1, + applicationId: 1, + code: 'POS-1', + sessionId: 's1', + subjectId: 'sub1', + externalCode: '1000', + id: '1', + nominationTargetId: 'nt-1', + picklistId: 'pk1', + formContentId: 'fc1', + }, + ], + }, + }), + ApiError: class ApiError extends Error { + constructor( + public status: number, + message: string, + public retryAfter?: number, + ) { + super(message); + this.name = 'ApiError'; + } + }, +})); + +const mockedRequest = request as jest.MockedFunction; + +const plugin = sapsuccessfactors({ + authType: 'api_key', + key: 'test-token', + host: 'api10.successfactors.com', + companyId: 'ACME', +}); + +const mockCtx = { + key: 'test-token', + options: { host: 'api10.successfactors.com' }, + $getAccountId: async () => 'acc_test', + log: jest.fn(), +} as unknown as SapsuccessfactorsContext; + +function run(name: Parameters[0], input: unknown) { + return executeSapOperation(mockCtx, input as never, getSapRoute(name)); +} + +function lastCall() { + expect(mockedRequest).toHaveBeenCalled(); + const [, opts] = mockedRequest.mock.calls.at(-1) ?? []; + return opts as { + method?: string; + url?: string; + query?: Record; + }; +} + +const fixtures: Record> = { + approveCalibrationSession: { session_id: 's1' }, + getCalibrationSessionById: { session_id: 's1' }, + getCalibrationSessions: { top: 10 }, + getOdataMetadataCalibSessionService: {}, + getCalibrationSubjectById: { subject_id: 'sub1' }, + getCalibrationSubjectRatings: { session_id: 's1' }, + updateCalibrationSubjectRatings: { subject_id: 'sub1', body: { rating: 3 } }, + createOnboardee: { userId: 'nhire1', username: 'nhire1' }, + getOnb2Process: { top: 5 }, + getOdataMetadataOnboardingAddl: {}, + updateInternalUsernameNewHiresAfter: { + userId: 'nhire1', + newUsername: 'nhire1.int', + }, + createAFeedbackRequest: { + questions: [{ question: 'What should they start doing?' }], + }, + getFeedbackRecordsServiceAvailable: { top: 5 }, + getPendingFeedbackRequestsFeedback: { top: 5 }, + giveFeedbackOrRespondToAFeedbackRequest: { + questions: [{ question: 'Strengths', answer: 'Clear communicator' }], + }, + refreshMetadataContFeedbackService: {}, + createUpdateSuccessorNomination: { userId: 'cgrant', positionCode: 'POS-1' }, + deleteNominationPositionTalentPool: { + nominationTargetId: 'nt-1', + userId: 'cgrant', + isPoolNomination: true, + }, + getOdataMetadataForNominationService: {}, + getTalentPool: { top: 5 }, + getApplicationInterview: { applicationId: '1001' }, + getInterviewOverallAssessment: { top: 5 }, + getJobApplication: { top: 5 }, + getJobRequisition: { top: 5 }, + getJobReqScreeningQuestion: { top: 5 }, + listCandidates: { top: 5 }, + getFoBusinessUnit: { top: 5 }, + getFoCompany: { top: 5 }, + getFoCostCenter: { top: 5 }, + getFoDepartment: { top: 5 }, + getFoJobCode: { top: 5 }, + getFoJobFunction: { top: 5 }, + getFoLocation: { top: 5 }, + getFoPayGroup: { top: 5 }, + getPosition: { top: 5 }, + getCustomMdfObject: { custom_object: 'cust_TeamGoal' }, + getPicklist: { top: 5 }, + getPicklistOption: { top: 5 }, + getCurrentUser: {}, + getOdataUserMetadata: {}, + listUsers: { top: 10, filter: "status eq 't'" }, + getPerPersonById: { person_id_external: 'p1' }, + listPerPerson: { top: 5 }, + getPerPersonal: { top: 5 }, + getBackgroundEducation: { top: 5 }, + getBackgroundMobility: { top: 5 }, + listEmpEmployment: { top: 5 }, + getEmpEmploymentTermination: { top: 5 }, + getWorkOrder: { top: 5 }, + getEmpPayCompRecurring: { top: 5 }, + getEmpPayCompNonRecurring: { top: 5 }, + getGoalPlanTemplate: { top: 5 }, + getGoalsByPlan: { goal_plan_id: '11' }, + getFormContent: { top: 5 }, + createLearningActivitiesBulk: { body: { activities: [] } }, + getCdpLearningMetadata: {}, + refreshCdpLearningMetadata: {}, + getEmployeeTime: { top: 5 }, + getEmployeeTimesheet: { top: 5 }, + getTemporaryTimeInformation: { top: 5 }, + getTimeAccountSnapshot: { top: 5 }, + getOdataMetadataClockInclockOut: {}, + queryAllAvailableClockClockOut: { top: 5 }, + queryClockClockOutGroupCodeTime: { code: 'CICO1' }, +}; + +describe('SAP SuccessFactors plugin', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('registers oauth_2 and api_key auth', () => { + expect(plugin.id).toBe('sapsuccessfactors'); + expect(plugin.authConfig).toEqual( + expect.objectContaining({ + oauth_2: expect.anything(), + api_key: expect.anything(), + }), + ); + expect(plugin.oauthConfig?.tokenUrl).toBe( + 'https://api10.successfactors.com/oauth/token', + ); + expect(plugin.errorHandlers?.RATE_LIMIT_ERROR).toBeDefined(); + }); + + it('maps OData query keys and sends query on GET', async () => { + await makeSapsuccessfactorsRequest('odata/v2/User', 'k', { + method: 'GET', + query: { top: 10, filter: "status eq 't'" }, + host: 'api10.successfactors.com', + }); + expect(lastCall().query).toEqual( + expect.objectContaining({ + $format: 'json', + $top: 10, + $filter: "status eq 't'", + }), + ); + }); + + it('uses APIKey header on SAP API Business Hub sandbox', async () => { + await makeSapsuccessfactorsRequest('odata/v2/User', 'hub-key', { + host: 'sandbox.api.sap.com', + }); + const [config, opts] = mockedRequest.mock.calls.at(-1) ?? []; + expect(config).toEqual( + expect.objectContaining({ + BASE: 'https://sandbox.api.sap.com', + HEADERS: expect.objectContaining({ APIKey: 'hub-key' }), + }), + ); + expect(opts).toEqual(expect.objectContaining({ url: '/odata/v2/User' })); + }); + + it('rejects non-numeric paging before the HTTP call', async () => { + await expect(run('listUsers', { top: 'nope' })).rejects.toThrow(); + expect(mockedRequest).not.toHaveBeenCalled(); + }); + + it('rejects a response that is not an OData envelope', async () => { + mockedRequest.mockResolvedValueOnce({ garbage: true } as never); + await expect(run('listUsers', { top: 1 })).rejects.toThrow(); + }); + + it('treats 204 writes as success', async () => { + mockedRequest.mockResolvedValueOnce(undefined as never); + await expect( + run('updateCalibrationSubjectRatings', { + subject_id: 'sub1', + body: { rating: 3 }, + }), + ).resolves.toBeUndefined(); + }); + + it('rejects User as a custom MDF entity', async () => { + await expect( + run('getCustomMdfObject', { custom_object: 'User' }), + ).rejects.toThrow(/cust_/); + expect(mockedRequest).not.toHaveBeenCalled(); + }); + + it('encodes cust_* MDF names into the OData path', async () => { + await run('getCustomMdfObject', { custom_object: 'cust_TeamGoal' }); + expect(lastCall().url).toBe('/odata/v2/cust_TeamGoal'); + }); + + it('deletes NominationTarget with userId and isPoolNomination', async () => { + mockedRequest.mockResolvedValueOnce(undefined as never); + await run('deleteNominationPositionTalentPool', { + nominationTargetId: 'nt-1', + userId: 'cgrant', + isPoolNomination: true, + }); + expect(lastCall()).toEqual( + expect.objectContaining({ + method: 'DELETE', + url: "/odata/v4/NominationService.svc/NominationTarget('nt-1')", + query: expect.objectContaining({ + userId: 'cgrant', + isPoolNomination: true, + }), + }), + ); + }); + + it('requires applicationId for Interview Central', async () => { + await expect(run('getApplicationInterview', {})).rejects.toThrow( + /applicationId/, + ); + await run('getApplicationInterview', { applicationId: '1001' }); + expect(lastCall().query).toEqual( + expect.objectContaining({ + $filter: "applicationId eq '1001'", + }), + ); + }); + + it('maps Goal_11 plan ids to the Goal_11 entity set', async () => { + await run('getGoalsByPlan', { goal_plan_id: 'Goal_11' }); + expect(lastCall().url).toBe('/odata/v2/Goal_11'); + }); + + it('filters current user with $loggedInUser', async () => { + await run('getCurrentUser', {}); + expect(lastCall()).toEqual( + expect.objectContaining({ + method: 'GET', + url: '/odata/v2/User', + query: expect.objectContaining({ + $filter: "userId eq '$loggedInUser'", + }), + }), + ); + }); + + it('matches rate-limit errors', async () => { + expect(errorHandlers.RATE_LIMIT_ERROR.match(new Error('429'))).toBe(true); + const res = await errorHandlers.RATE_LIMIT_ERROR.handler(new Error('429')); + expect(res.maxRetries).toBe(3); + }); + + it.each(sapRoutes.map((route) => [route.name, route.method] as const))( + '%s sends %s', + async (name, method) => { + const input = fixtures[name]; + expect(input).toBeDefined(); + SapsuccessfactorsEndpointInputSchemas[name].parse(input); + const route = getSapRoute(name); + const record = { + userId: 'cgrant', + personIdExternal: 'p1', + jobReqId: 1, + candidateId: 1, + applicationId: 1, + code: 'CICO1', + sessionId: 's1', + subjectId: 'sub1', + externalCode: '1000', + id: '1', + nominationTargetId: 'nt-1', + picklistId: 'pk1', + formContentId: 'fc1', + }; + mockedRequest.mockResolvedValueOnce( + (route.path.includes('$metadata') + ? '' + : route.method === 'GET' && route.path.includes('({') + ? { d: record } + : route.method === 'GET' + ? { d: { results: [record] } } + : { d: record }) as never, + ); + await run(name, input); + expect(lastCall().method).toBe(method); + expect(lastCall().url).toMatch(/^\//); + }, + ); +}); diff --git a/packages/sapsuccessfactors/client.ts b/packages/sapsuccessfactors/client.ts new file mode 100644 index 000000000..1167774bc --- /dev/null +++ b/packages/sapsuccessfactors/client.ts @@ -0,0 +1,157 @@ +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export class SapsuccessfactorsAPIError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'SapsuccessfactorsAPIError'; + } +} + +export const SAP_SUCCESSFACTORS_DEFAULT_HOST = 'api10.successfactors.com'; + +const HOST_PATTERN = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/i; + +const RATE_LIMIT: RateLimitConfig = { + enabled: true, + maxRetries: 3, + initialRetryDelay: 1000, + backoffMultiplier: 2, + headerNames: { retryAfter: 'Retry-After' }, +}; + +export type SapsuccessfactorsConnection = { + host: string; + companyId?: string; +}; + +export function normalizeSapsuccessfactorsHost(host: string): string { + const trimmed = host.trim(); + if (!trimmed) throw new Error('[sapsuccessfactors] host is required'); + + let value = trimmed; + if (trimmed.includes('://')) { + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error('[sapsuccessfactors] host is not a valid URL'); + } + if (url.protocol !== 'https:') { + throw new Error('[sapsuccessfactors] host must use https'); + } + if (url.username || url.password) { + throw new Error('[sapsuccessfactors] host must not contain credentials'); + } + value = url.host; + } + + while (value.endsWith('/')) { + value = value.slice(0, -1); + } + if (value.includes('/')) { + value = value.split('/')[0] ?? value; + } + if (!HOST_PATTERN.test(value)) { + throw new Error('[sapsuccessfactors] host must be a bare hostname'); + } + return value; +} + +export function sapSuccessfactorsOAuthUrls(host: string) { + const normalized = normalizeSapsuccessfactorsHost(host); + const base = `https://${normalized}/oauth`; + return { + authUrl: `${base}/authorize`, + tokenUrl: `${base}/token`, + }; +} + +function isSapSandboxHost(host: string): boolean { + return host === 'sandbox.api.sap.com'; +} + +function authorizationHeader(apiKey: string): string { + if (apiKey.startsWith('Basic ') || apiKey.startsWith('Bearer ')) + return apiKey; + return `Bearer ${apiKey}`; +} + +export async function makeSapsuccessfactorsRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + host?: string; + body?: Record; + query?: Record; + } = {}, +): Promise { + const { method = 'GET', body, query } = options; + const host = normalizeSapsuccessfactorsHost( + options.host ?? SAP_SUCCESSFACTORS_DEFAULT_HOST, + ); + const sandbox = isSapSandboxHost(host); + const url = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + + const config: OpenAPIConfig = { + BASE: `https://${host}`, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: sandbox ? undefined : apiKey, + HEADERS: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...(sandbox + ? { APIKey: apiKey, apikey: apiKey } + : { Authorization: authorizationHeader(apiKey) }), + }, + }; + + const formattedQuery: Record = + url.includes('$metadata') ? {} : { $format: 'json' }; + if (query) { + const odataKeys = new Set([ + 'filter', + 'select', + 'expand', + 'top', + 'skip', + 'orderby', + ]); + for (const [k, v] of Object.entries(query)) { + if (v === undefined) continue; + formattedQuery[odataKeys.has(k) ? `$${k}` : k] = v; + } + } + + const requestOptions: ApiRequestOptions = { + method, + url, + body: + method === 'POST' || method === 'PUT' || method === 'PATCH' + ? body + : undefined, + mediaType: 'application/json; charset=utf-8', + query: Object.keys(formattedQuery).length > 0 ? formattedQuery : undefined, + }; + + try { + return await request(config, requestOptions, { + rateLimitConfig: RATE_LIMIT, + }); + } catch (error) { + if (error instanceof ApiError) throw error; + if (error instanceof Error) + throw new SapsuccessfactorsAPIError(error.message); + throw new SapsuccessfactorsAPIError('Unknown error occurred'); + } +} diff --git a/packages/sapsuccessfactors/endpoints/factory.ts b/packages/sapsuccessfactors/endpoints/factory.ts new file mode 100644 index 000000000..a8257e173 --- /dev/null +++ b/packages/sapsuccessfactors/endpoints/factory.ts @@ -0,0 +1,205 @@ +import type { CorsairEndpoint } from 'corsair/core'; +import { AuthMissingError, logEventFromContext } from 'corsair/core'; +import { makeSapsuccessfactorsRequest } from '../client'; +import type { + SapsuccessfactorsContext, + SapsuccessfactorsKeyBuilderContext, +} from '../index'; +import type { SapRoute, SapRouteName } from './routes'; +import { getSapRoute } from './routes'; +import type { + SapsuccessfactorsEndpointInput, + SapsuccessfactorsEndpointOutputs, +} from './types'; +import { + SapsuccessfactorsEndpointInputSchemas, + SapsuccessfactorsEndpointOutputSchemas, +} from './types'; + +export type SapEndpoint = CorsairEndpoint< + SapsuccessfactorsContext, + SapsuccessfactorsEndpointInput, + unknown +>; + +const QUERY_KEYS = new Set([ + 'filter', + 'select', + 'expand', + 'top', + 'skip', + 'orderby', +]); + +function odataLiteral(value: unknown): string { + if (value === undefined || value === null || value === '') { + throw new Error('[sapsuccessfactors] missing required path parameter'); + } + return `'${String(value).replace(/'/g, "''")}'`; +} + +function resolveHost( + ctx: Pick & { + keys?: Partial; + }, +): string | undefined { + return ctx.options?.host ?? ctx.options?.apiBaseUrl; +} + +function escapeODataString(value: string): string { + return value.replace(/'/g, "''"); +} + +function resolvePath(route: SapRoute, input: Record): string { + if (route.special === 'customMdf') { + const name = String(input.custom_object ?? ''); + if (!/^cust_[A-Za-z0-9_]+$/.test(name)) { + throw new Error( + '[sapsuccessfactors] custom_object must be a cust_* MDF entity', + ); + } + return `odata/v2/${name}`; + } + if (route.special === 'goalPlan') { + const raw = String(input.goal_plan_id ?? ''); + const id = raw.replace(/^Goal_/i, '').replace(/[^0-9]/g, ''); + if (!id) { + throw new Error( + '[sapsuccessfactors] goal_plan_id must include the numeric plan id', + ); + } + return `odata/v2/Goal_${id}`; + } + return route.path.replace(/\{([^}]+)\}/g, (_, key: string) => + odataLiteral(input[key]), + ); +} + +function buildQuery( + route: SapRoute, + input: Record, +): Record { + const query: Record = {}; + for (const key of QUERY_KEYS) { + const value = input[key]; + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + query[key] = value; + } + } + if (route.special === 'currentUser' && !query.filter) { + query.filter = "userId eq '$loggedInUser'"; + } + if (route.special === 'applicationInterview') { + const applicationId = input.applicationId; + if (typeof applicationId === 'string' && applicationId && !query.filter) { + query.filter = `applicationId eq '${escapeODataString(applicationId)}'`; + } + } + if (route.special === 'nominationDelete') { + const userId = input.userId; + if (typeof userId === 'string') query.userId = userId; + if (input.isPoolNomination === true) query.isPoolNomination = true; + } + if ( + route.name === 'getCalibrationSubjectRatings' && + typeof input.session_id === 'string' && + !query.filter + ) { + query.filter = `sessionId eq '${escapeODataString(input.session_id)}'`; + } + return query; +} + +const PATH_AND_CONTROL = new Set([ + 'body', + 'filter', + 'select', + 'expand', + 'top', + 'skip', + 'orderby', + 'session_id', + 'subject_id', + 'person_id_external', + 'goal_plan_id', + 'custom_object', + 'nominationTargetId', + 'applicationId', + 'code', +]); + +function requestBody( + route: SapRoute, + input: Record, +): Record | undefined { + if (route.method === 'GET' || route.method === 'DELETE') return undefined; + if (input.body && typeof input.body === 'object') { + return input.body as Record; + } + const body = Object.fromEntries( + Object.entries(input).filter( + ([key, value]) => !PATH_AND_CONTROL.has(key) && value !== undefined, + ), + ); + return Object.keys(body).length > 0 ? body : undefined; +} + +export async function executeSapOperation( + ctx: SapsuccessfactorsContext, + rawInput: SapsuccessfactorsEndpointInput | undefined, + route: SapRoute, +) { + if (!ctx.key) { + throw new AuthMissingError('sapsuccessfactors', 'oauth_2'); + } + const parsed = SapsuccessfactorsEndpointInputSchemas[ + route.name as SapRouteName + ].parse(rawInput ?? {}); + const input = parsed as Record; + const path = resolvePath(route, input); + const host = resolveHost(ctx); + + let status: 'completed' | 'failed' = 'completed'; + try { + const response = await makeSapsuccessfactorsRequest( + path, + ctx.key, + { + method: route.method, + body: requestBody(route, input), + query: buildQuery(route, input), + host, + }, + ); + return SapsuccessfactorsEndpointOutputSchemas[ + route.name as SapRouteName + ].parse(response) as SapsuccessfactorsEndpointOutputs[SapRouteName]; + } catch (error) { + status = 'failed'; + throw error; + } finally { + try { + await logEventFromContext( + ctx, + `sapsuccessfactors.${route.group}.${route.name}`, + { method: route.method, path }, + status, + ); + } catch (logError) { + console.warn( + '[sapsuccessfactors] Failed to log operation event:', + logError, + ); + } + } +} + +export function createSapEndpoint(name: SapRouteName): SapEndpoint { + const route = getSapRoute(name); + return (async (ctx, input) => + executeSapOperation(ctx, input ?? {}, route)) as SapEndpoint; +} diff --git a/packages/sapsuccessfactors/endpoints/index.ts b/packages/sapsuccessfactors/endpoints/index.ts new file mode 100644 index 000000000..b97b14966 --- /dev/null +++ b/packages/sapsuccessfactors/endpoints/index.ts @@ -0,0 +1,146 @@ +import { createSapEndpoint } from './factory'; +import type { SapRouteName } from './routes'; +import { sapRoutes } from './routes'; + +export const sapOperations = Object.fromEntries( + sapRoutes.map((route) => [route.name, createSapEndpoint(route.name)]), +) as { [K in SapRouteName]: ReturnType }; + +export const sapsuccessfactorsEndpointsNested = { + approve: { + approveCalibrationSession: sapOperations.approveCalibrationSession, + }, + calibration: { + getCalibrationSessionById: sapOperations.getCalibrationSessionById, + getCalibrationSessions: sapOperations.getCalibrationSessions, + getCalibrationSubjectById: sapOperations.getCalibrationSubjectById, + getCalibrationSubjectRatings: sapOperations.getCalibrationSubjectRatings, + updateCalibrationSubjectRatings: + sapOperations.updateCalibrationSubjectRatings, + }, + odata: { + getOdataMetadataCalibSessionService: + sapOperations.getOdataMetadataCalibSessionService, + getOdataMetadataOnboardingAddl: + sapOperations.getOdataMetadataOnboardingAddl, + getOdataMetadataForNominationService: + sapOperations.getOdataMetadataForNominationService, + getOdataUserMetadata: sapOperations.getOdataUserMetadata, + getOdataMetadataClockInclockOut: + sapOperations.getOdataMetadataClockInclockOut, + }, + onboardee: { createOnboardee: sapOperations.createOnboardee }, + onb2: { getOnb2Process: sapOperations.getOnb2Process }, + internal: { + updateInternalUsernameNewHiresAfter: + sapOperations.updateInternalUsernameNewHiresAfter, + }, + a: { createAFeedbackRequest: sapOperations.createAFeedbackRequest }, + feedback: { + getFeedbackRecordsServiceAvailable: + sapOperations.getFeedbackRecordsServiceAvailable, + }, + pending: { + getPendingFeedbackRequestsFeedback: + sapOperations.getPendingFeedbackRequestsFeedback, + }, + give: { + giveFeedbackOrRespondToAFeedbackRequest: + sapOperations.giveFeedbackOrRespondToAFeedbackRequest, + }, + metadata: { + refreshMetadataContFeedbackService: + sapOperations.refreshMetadataContFeedbackService, + }, + successor: { + createUpdateSuccessorNomination: + sapOperations.createUpdateSuccessorNomination, + }, + nomination: { + deleteNominationPositionTalentPool: + sapOperations.deleteNominationPositionTalentPool, + }, + talent: { getTalentPool: sapOperations.getTalentPool }, + application: { + getApplicationInterview: sapOperations.getApplicationInterview, + }, + interview: { + getInterviewOverallAssessment: sapOperations.getInterviewOverallAssessment, + }, + job: { + getJobApplication: sapOperations.getJobApplication, + getJobRequisition: sapOperations.getJobRequisition, + getJobReqScreeningQuestion: sapOperations.getJobReqScreeningQuestion, + }, + candidates: { listCandidates: sapOperations.listCandidates }, + fo: { + getFoBusinessUnit: sapOperations.getFoBusinessUnit, + getFoCompany: sapOperations.getFoCompany, + getFoCostCenter: sapOperations.getFoCostCenter, + getFoDepartment: sapOperations.getFoDepartment, + getFoJobCode: sapOperations.getFoJobCode, + getFoJobFunction: sapOperations.getFoJobFunction, + getFoLocation: sapOperations.getFoLocation, + getFoPayGroup: sapOperations.getFoPayGroup, + }, + position: { getPosition: sapOperations.getPosition }, + custom: { getCustomMdfObject: sapOperations.getCustomMdfObject }, + picklist: { + getPicklist: sapOperations.getPicklist, + getPicklistOption: sapOperations.getPicklistOption, + }, + current: { getCurrentUser: sapOperations.getCurrentUser }, + users: { listUsers: sapOperations.listUsers }, + per: { + getPerPersonById: sapOperations.getPerPersonById, + listPerPerson: sapOperations.listPerPerson, + getPerPersonal: sapOperations.getPerPersonal, + }, + background: { + getBackgroundEducation: sapOperations.getBackgroundEducation, + getBackgroundMobility: sapOperations.getBackgroundMobility, + }, + emp: { + listEmpEmployment: sapOperations.listEmpEmployment, + getEmpEmploymentTermination: sapOperations.getEmpEmploymentTermination, + getEmpPayCompRecurring: sapOperations.getEmpPayCompRecurring, + getEmpPayCompNonRecurring: sapOperations.getEmpPayCompNonRecurring, + }, + work: { getWorkOrder: sapOperations.getWorkOrder }, + goal: { getGoalPlanTemplate: sapOperations.getGoalPlanTemplate }, + goals: { getGoalsByPlan: sapOperations.getGoalsByPlan }, + form: { getFormContent: sapOperations.getFormContent }, + learning: { + createLearningActivitiesBulk: sapOperations.createLearningActivitiesBulk, + }, + cdp: { + getCdpLearningMetadata: sapOperations.getCdpLearningMetadata, + refreshCdpLearningMetadata: sapOperations.refreshCdpLearningMetadata, + }, + employee: { + getEmployeeTime: sapOperations.getEmployeeTime, + getEmployeeTimesheet: sapOperations.getEmployeeTimesheet, + }, + temporary: { + getTemporaryTimeInformation: sapOperations.getTemporaryTimeInformation, + }, + time: { getTimeAccountSnapshot: sapOperations.getTimeAccountSnapshot }, + query: { + queryAllAvailableClockClockOut: + sapOperations.queryAllAvailableClockClockOut, + queryClockClockOutGroupCodeTime: + sapOperations.queryClockClockOutGroupCodeTime, + }, +} as const; + +export { createSapEndpoint, executeSapOperation } from './factory'; +export type { SapRoute, SapRouteName } from './routes'; +export { getSapRoute, sapRouteByName, sapRoutes } from './routes'; +export type { + SapsuccessfactorsEndpointInputs, + SapsuccessfactorsEndpointOutputs, +} from './types'; +export { + SapsuccessfactorsEndpointInputSchemas, + SapsuccessfactorsEndpointOutputSchemas, +} from './types'; diff --git a/packages/sapsuccessfactors/endpoints/routes.ts b/packages/sapsuccessfactors/endpoints/routes.ts new file mode 100644 index 000000000..80845ecb6 --- /dev/null +++ b/packages/sapsuccessfactors/endpoints/routes.ts @@ -0,0 +1,553 @@ +export type SapRisk = 'read' | 'write' | 'destructive'; + +export type SapSpecial = + | 'customMdf' + | 'goalPlan' + | 'nominationDelete' + | 'applicationInterview' + | 'currentUser'; + +export type SapRoute = { + name: string; + group: string; + method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + path: string; + description: string; + riskLevel: SapRisk; + irreversible?: true; + special?: SapSpecial; +}; + +export const sapRoutes = [ + { + name: 'approveCalibrationSession', + group: 'approve', + method: 'POST', + path: 'odata/v4/CalSession.svc/Approve', + description: + 'Finalize a calibration session that is In Progress or Approving', + riskLevel: 'write', + }, + { + name: 'getCalibrationSessionById', + group: 'calibration', + method: 'GET', + path: 'odata/v4/CalSession.svc/CalibrationSession({session_id})', + description: 'Get a specific calibration session by session ID', + riskLevel: 'read', + }, + { + name: 'getCalibrationSessions', + group: 'calibration', + method: 'GET', + path: 'odata/v4/CalSession.svc/CalibrationSession', + description: 'Query all calibration sessions the current user can access', + riskLevel: 'read', + }, + { + name: 'getCalibrationSubjectById', + group: 'calibration', + method: 'GET', + path: 'odata/v4/CalSession.svc/CalibrationSubject({subject_id})', + description: + "Query a subject's competency ratings within a calibration session", + riskLevel: 'read', + }, + { + name: 'getCalibrationSubjectRatings', + group: 'calibration', + method: 'GET', + path: 'odata/v4/CalSession.svc/CalibrationSubject', + description: "Query a subject's ratings by session ID", + riskLevel: 'read', + }, + { + name: 'updateCalibrationSubjectRatings', + group: 'calibration', + method: 'PATCH', + path: 'odata/v4/CalSession.svc/CalibrationSubject({subject_id})', + description: + "Update a subject's competency ratings in a calibration session", + riskLevel: 'write', + }, + { + name: 'getOdataMetadataCalibSessionService', + group: 'odata', + method: 'GET', + path: 'odata/v4/CalSession.svc/$metadata', + description: 'Get OData metadata for Calibration Session service', + riskLevel: 'read', + }, + { + name: 'getOdataMetadataOnboardingAddl', + group: 'odata', + method: 'GET', + path: 'odata/v2/$metadata', + description: 'Get OData metadata for Onboarding Additional Services', + riskLevel: 'read', + }, + { + name: 'getOdataMetadataForNominationService', + group: 'odata', + method: 'GET', + path: 'odata/v4/NominationService.svc/$metadata', + description: 'Get OData metadata for Nomination service', + riskLevel: 'read', + }, + { + name: 'getOdataUserMetadata', + group: 'odata', + method: 'GET', + path: 'odata/v2/User/$metadata', + description: 'Get OData metadata for the User entity', + riskLevel: 'read', + }, + { + name: 'getOdataMetadataClockInclockOut', + group: 'odata', + method: 'GET', + path: 'odata/v2/ClockInClockOutGroup/$metadata', + description: 'Get OData metadata for Clock In/Clock Out Integration', + riskLevel: 'read', + }, + { + name: 'createOnboardee', + group: 'onboardee', + method: 'POST', + path: 'odata/v2/User', + description: 'Create a new onboardee (User) for Onboarding 2.0', + riskLevel: 'write', + }, + { + name: 'getOnb2Process', + group: 'onb2', + method: 'GET', + path: 'odata/v2/ONB2Process', + description: 'Retrieve Onboarding 2.0 process records', + riskLevel: 'read', + }, + { + name: 'updateInternalUsernameNewHiresAfter', + group: 'internal', + method: 'POST', + path: 'odata/v2/updateUserNamePostHiring', + description: 'Update internal username of new hires after MPH submit', + riskLevel: 'write', + }, + { + name: 'createAFeedbackRequest', + group: 'a', + method: 'POST', + path: 'odata/v4/ContinuousPerformanceManagement.svc/FeedbackRequest', + description: 'Create a continuous feedback request', + riskLevel: 'write', + }, + { + name: 'getFeedbackRecordsServiceAvailable', + group: 'feedback', + method: 'GET', + path: 'odata/v4/ContinuousPerformanceManagement.svc/Feedback', + description: 'Retrieve continuous feedback records (OData V4)', + riskLevel: 'read', + }, + { + name: 'getPendingFeedbackRequestsFeedback', + group: 'pending', + method: 'GET', + path: 'odata/v4/ContinuousPerformanceManagement.svc/FeedbackRequest', + description: 'Retrieve pending feedback requests', + riskLevel: 'read', + }, + { + name: 'giveFeedbackOrRespondToAFeedbackRequest', + group: 'give', + method: 'POST', + path: 'odata/v4/ContinuousPerformanceManagement.svc/Feedback', + description: 'Give feedback or respond to a feedback request', + riskLevel: 'write', + }, + { + name: 'refreshMetadataContFeedbackService', + group: 'metadata', + method: 'POST', + path: 'odata/v4/ContinuousPerformanceManagement.svc/RefreshMetadata', + description: 'Refresh metadata cache for Continuous Feedback', + riskLevel: 'write', + }, + { + name: 'createUpdateSuccessorNomination', + group: 'successor', + method: 'POST', + path: 'odata/v4/NominationService.svc/NominationTarget', + description: 'Create or update a successor nomination', + riskLevel: 'write', + }, + { + name: 'deleteNominationPositionTalentPool', + group: 'nomination', + method: 'DELETE', + path: 'odata/v4/NominationService.svc/NominationTarget({nominationTargetId})', + description: 'Delete a nomination for a position or talent pool', + riskLevel: 'destructive', + irreversible: true, + special: 'nominationDelete', + }, + { + name: 'getTalentPool', + group: 'talent', + method: 'GET', + path: 'odata/v2/TalentPool', + description: 'Retrieve talent pool records', + riskLevel: 'read', + }, + { + name: 'getApplicationInterview', + group: 'application', + method: 'GET', + path: 'odata/v2/ApplicationInterview', + description: 'Retrieve interview information for job applications', + riskLevel: 'read', + special: 'applicationInterview', + }, + { + name: 'getInterviewOverallAssessment', + group: 'interview', + method: 'GET', + path: 'odata/v2/OverallInterviewAssessment', + description: 'Retrieve overall interview ratings', + riskLevel: 'read', + }, + { + name: 'getJobApplication', + group: 'job', + method: 'GET', + path: 'odata/v2/JobApplication', + description: 'Retrieve job application records', + riskLevel: 'read', + }, + { + name: 'getJobRequisition', + group: 'job', + method: 'GET', + path: 'odata/v2/JobRequisition', + description: 'Retrieve job requisition records', + riskLevel: 'read', + }, + { + name: 'getJobReqScreeningQuestion', + group: 'job', + method: 'GET', + path: 'odata/v2/JobReqScreeningQuestion', + description: 'Retrieve screening questions for job requisitions', + riskLevel: 'read', + }, + { + name: 'listCandidates', + group: 'candidates', + method: 'GET', + path: 'odata/v2/Candidate', + description: 'Retrieve candidates', + riskLevel: 'read', + }, + { + name: 'getFoBusinessUnit', + group: 'fo', + method: 'GET', + path: 'odata/v2/FOBusinessUnit', + description: 'Retrieve FOBusinessUnit records', + riskLevel: 'read', + }, + { + name: 'getFoCompany', + group: 'fo', + method: 'GET', + path: 'odata/v2/FOCompany', + description: 'Retrieve FOCompany records', + riskLevel: 'read', + }, + { + name: 'getFoCostCenter', + group: 'fo', + method: 'GET', + path: 'odata/v2/FOCostCenter', + description: 'Retrieve FOCostCenter records', + riskLevel: 'read', + }, + { + name: 'getFoDepartment', + group: 'fo', + method: 'GET', + path: 'odata/v2/FODepartment', + description: 'Retrieve FODepartment records', + riskLevel: 'read', + }, + { + name: 'getFoJobCode', + group: 'fo', + method: 'GET', + path: 'odata/v2/FOJobCode', + description: 'Retrieve FOJobCode records', + riskLevel: 'read', + }, + { + name: 'getFoJobFunction', + group: 'fo', + method: 'GET', + path: 'odata/v2/FOJobFunction', + description: 'Retrieve FOJobFunction records', + riskLevel: 'read', + }, + { + name: 'getFoLocation', + group: 'fo', + method: 'GET', + path: 'odata/v2/FOLocation', + description: 'Retrieve FOLocation records', + riskLevel: 'read', + }, + { + name: 'getFoPayGroup', + group: 'fo', + method: 'GET', + path: 'odata/v2/FOPayGroup', + description: 'Retrieve FOPayGroup records', + riskLevel: 'read', + }, + { + name: 'getPosition', + group: 'position', + method: 'GET', + path: 'odata/v2/Position', + description: 'Retrieve position management records', + riskLevel: 'read', + }, + { + name: 'getCustomMdfObject', + group: 'custom', + method: 'GET', + path: 'odata/v2/{custom_object}', + description: 'Retrieve custom MDF objects (cust_* entities)', + riskLevel: 'read', + special: 'customMdf', + }, + { + name: 'getPicklist', + group: 'picklist', + method: 'GET', + path: 'odata/v2/Picklist', + description: 'Retrieve picklist definitions', + riskLevel: 'read', + }, + { + name: 'getPicklistOption', + group: 'picklist', + method: 'GET', + path: 'odata/v2/PicklistOption', + description: 'Retrieve picklist option values', + riskLevel: 'read', + }, + { + name: 'getCurrentUser', + group: 'current', + method: 'GET', + path: 'odata/v2/User', + description: 'Retrieve the currently authenticated user', + riskLevel: 'read', + special: 'currentUser', + }, + { + name: 'listUsers', + group: 'users', + method: 'GET', + path: 'odata/v2/User', + description: 'List User entity records', + riskLevel: 'read', + }, + { + name: 'getPerPersonById', + group: 'per', + method: 'GET', + path: 'odata/v2/PerPerson({person_id_external})', + description: 'Retrieve PerPerson by personIdExternal', + riskLevel: 'read', + }, + { + name: 'listPerPerson', + group: 'per', + method: 'GET', + path: 'odata/v2/PerPerson', + description: 'List PerPerson records', + riskLevel: 'read', + }, + { + name: 'getPerPersonal', + group: 'per', + method: 'GET', + path: 'odata/v2/PerPersonal', + description: 'Retrieve PerPersonal biographical records', + riskLevel: 'read', + }, + { + name: 'getBackgroundEducation', + group: 'background', + method: 'GET', + path: 'odata/v2/Background_Education', + description: 'Retrieve Background_Education records', + riskLevel: 'read', + }, + { + name: 'getBackgroundMobility', + group: 'background', + method: 'GET', + path: 'odata/v2/Background_Mobility', + description: 'Retrieve Background_Mobility records', + riskLevel: 'read', + }, + { + name: 'listEmpEmployment', + group: 'emp', + method: 'GET', + path: 'odata/v2/EmpEmployment', + description: 'List EmpEmployment records', + riskLevel: 'read', + }, + { + name: 'getEmpEmploymentTermination', + group: 'emp', + method: 'GET', + path: 'odata/v2/EmpEmploymentTermination', + description: 'Retrieve EmpEmploymentTermination records', + riskLevel: 'read', + }, + { + name: 'getEmpPayCompRecurring', + group: 'emp', + method: 'GET', + path: 'odata/v2/EmpPayCompRecurring', + description: 'Retrieve EmpPayCompRecurring records', + riskLevel: 'read', + }, + { + name: 'getEmpPayCompNonRecurring', + group: 'emp', + method: 'GET', + path: 'odata/v2/EmpPayCompNonRecurring', + description: 'Retrieve EmpPayCompNonRecurring records', + riskLevel: 'read', + }, + { + name: 'getWorkOrder', + group: 'work', + method: 'GET', + path: 'odata/v2/WorkOrder', + description: 'Retrieve WorkOrder records for contingent workers', + riskLevel: 'read', + }, + { + name: 'getGoalPlanTemplate', + group: 'goal', + method: 'GET', + path: 'odata/v2/GoalPlanTemplate', + description: 'Retrieve goal plan template records', + riskLevel: 'read', + }, + { + name: 'getGoalsByPlan', + group: 'goals', + method: 'GET', + path: 'odata/v2/Goal_{goal_plan_id}', + description: 'Retrieve goals for a Goal_ entity', + riskLevel: 'read', + special: 'goalPlan', + }, + { + name: 'getFormContent', + group: 'form', + method: 'GET', + path: 'odata/v2/FormContent', + description: 'Retrieve performance form content', + riskLevel: 'read', + }, + { + name: 'createLearningActivitiesBulk', + group: 'learning', + method: 'POST', + path: 'odata/v2/LearningActivity', + description: 'Create learning activities in bulk', + riskLevel: 'write', + }, + { + name: 'getCdpLearningMetadata', + group: 'cdp', + method: 'GET', + path: 'odata/v2/$metadata', + description: 'Get metadata for Career Development Planning Learning', + riskLevel: 'read', + }, + { + name: 'refreshCdpLearningMetadata', + group: 'cdp', + method: 'POST', + path: 'odata/v2/refreshCDPLearningMetadata', + description: 'Refresh CDP Learning metadata', + riskLevel: 'write', + }, + { + name: 'getEmployeeTime', + group: 'employee', + method: 'GET', + path: 'odata/v2/EmployeeTime', + description: 'Retrieve EmployeeTime records', + riskLevel: 'read', + }, + { + name: 'getEmployeeTimesheet', + group: 'employee', + method: 'GET', + path: 'odata/v2/EmployeeTimeSheet', + description: 'Retrieve EmployeeTimeSheet records', + riskLevel: 'read', + }, + { + name: 'getTemporaryTimeInformation', + group: 'temporary', + method: 'GET', + path: 'odata/v2/TemporaryTimeInformation', + description: 'Retrieve TemporaryTimeInformation records', + riskLevel: 'read', + }, + { + name: 'getTimeAccountSnapshot', + group: 'time', + method: 'GET', + path: 'odata/v2/TimeAccountSnapshot', + description: 'Retrieve TimeAccountSnapshot records', + riskLevel: 'read', + }, + { + name: 'queryAllAvailableClockClockOut', + group: 'query', + method: 'GET', + path: 'odata/v2/ClockInClockOutGroup', + description: 'Query all clock in/out groups', + riskLevel: 'read', + }, + { + name: 'queryClockClockOutGroupCodeTime', + group: 'query', + method: 'GET', + path: 'odata/v2/ClockInClockOutGroup({code})', + description: 'Query a clock in/out group by code', + riskLevel: 'read', + }, +] as const satisfies readonly SapRoute[]; + +export type SapRouteName = (typeof sapRoutes)[number]['name']; + +export const sapRouteByName = Object.fromEntries( + sapRoutes.map((route) => [route.name, route]), +) as { [K in SapRouteName]: Extract<(typeof sapRoutes)[number], { name: K }> }; + +export function getSapRoute(name: SapRouteName): SapRoute { + return sapRouteByName[name]; +} diff --git a/packages/sapsuccessfactors/endpoints/types.ts b/packages/sapsuccessfactors/endpoints/types.ts new file mode 100644 index 000000000..a722d3e13 --- /dev/null +++ b/packages/sapsuccessfactors/endpoints/types.ts @@ -0,0 +1,329 @@ +import { z } from 'zod'; +import { + SapsuccessfactorsCandidateEntity, + SapsuccessfactorsEmploymentEntity, + SapsuccessfactorsJobApplicationEntity, + SapsuccessfactorsJobRequisitionEntity, + SapsuccessfactorsPersonalEntity, + SapsuccessfactorsPersonEntity, + SapsuccessfactorsPositionEntity, + SapsuccessfactorsUserEntity, +} from '../schema/database'; +import type { SapRouteName } from './routes'; + +const odataQuery = { + filter: z.string().optional(), + select: z.string().optional(), + expand: z.string().optional(), + top: z.number().int().min(1).optional(), + skip: z.number().int().min(0).optional(), + orderby: z.string().optional(), +}; + +const ODataQuery = z.object(odataQuery); +const Empty = z.object({}).optional(); + +const rec = (shape: z.ZodRawShape) => z.object(shape).catchall(z.unknown()); +const Id = z.union([z.string(), z.number()]); + +const CalibrationSessionOut = rec({ sessionId: z.string() }); +const CalibrationSubjectOut = rec({ subjectId: z.string() }); +const Onb2ProcessOut = rec({ userId: z.string() }); +const FeedbackOut = rec({ id: Id }); +const FeedbackRequestOut = rec({ id: Id }); +const NominationOut = rec({ nominationTargetId: z.string() }); +const TalentPoolOut = rec({ id: Id }); +const ApplicationInterviewOut = rec({ applicationId: Id }); +const InterviewAssessmentOut = rec({ applicationId: Id }); +const ScreeningQuestionOut = rec({ jobReqId: Id }); +const FoBusinessUnitOut = rec({ externalCode: z.string() }); +const FoCompanyOut = rec({ externalCode: z.string() }); +const FoCostCenterOut = rec({ externalCode: z.string() }); +const FoDepartmentOut = rec({ externalCode: z.string() }); +const FoJobCodeOut = rec({ externalCode: z.string() }); +const FoJobFunctionOut = rec({ externalCode: z.string() }); +const FoLocationOut = rec({ externalCode: z.string() }); +const FoPayGroupOut = rec({ externalCode: z.string() }); +const MdfOut = rec({ externalCode: z.string().optional() }); +const PicklistOut = rec({ picklistId: Id }); +const PicklistOptionOut = rec({ id: Id }); +const GoalPlanOut = rec({ id: Id }); +const GoalOut = rec({ id: Id }); +const BackgroundEducationOut = rec({ userId: z.string() }); +const BackgroundMobilityOut = rec({ userId: z.string() }); +const EmploymentTerminationOut = rec({ userId: z.string() }); +const PayCompOut = rec({ userId: z.string() }); +const WorkOrderOut = rec({ userId: z.string() }); +const FormContentOut = rec({ formContentId: Id }); +const LearningActivityOut = rec({ userId: z.string() }); +const ActionOut = rec({ message: z.string().optional() }); +const EmployeeTimeOut = rec({ userId: z.string() }); +const EmployeeTimeSheetOut = rec({ userId: z.string() }); +const TemporaryTimeOut = rec({ userId: z.string() }); +const TimeAccountSnapshotOut = rec({ userId: z.string() }); +const ClockInClockOutGroupOut = rec({ code: z.string() }); + +function collectionOf(item: T) { + return z.union([ + z + .object({ + d: z.object({ results: z.array(item) }).passthrough(), + }) + .passthrough(), + z.object({ value: z.array(item) }).passthrough(), + ]); +} + +function entityOf(item: T) { + return z.union([ + z.object({ d: item }).passthrough(), + z + .object({ '@odata.context': z.string().min(1) }) + .passthrough() + .and(item) + .refine((v) => !Array.isArray((v as { value?: unknown }).value), { + message: 'V4 entity must not be a collection', + }), + ]); +} + +function writeOf(item: T) { + return z.union([ + z.undefined(), + z.null(), + z.object({}).strict(), + entityOf(item), + ]); +} + +export const SapMetadataSchema = z.union([ + z.string().refine((s) => s.includes(' (v.questions?.length ?? 0) > 0 || v.body != null, { + message: 'At least one question must be provided', + }), + getFeedbackRecordsServiceAvailable: ODataQuery, + getPendingFeedbackRequestsFeedback: ODataQuery, + giveFeedbackOrRespondToAFeedbackRequest: z.object({ + questions: z.array(FeedbackQuestion).max(3).optional(), + body: Body, + }), + refreshMetadataContFeedbackService: Empty, + createUpdateSuccessorNomination: z.object({ + userId: z.string().optional(), + positionCode: z.string().optional(), + isPoolNomination: z.boolean().optional(), + body: Body, + }), + deleteNominationPositionTalentPool: z.object({ + nominationTargetId: z.string().min(1), + userId: z.string().min(1), + isPoolNomination: z.boolean().optional(), + }), + getOdataMetadataForNominationService: Empty, + getTalentPool: ODataQuery, + getApplicationInterview: z + .object({ + applicationId: z.string().min(1).optional(), + ...odataQuery, + }) + .refine((v) => Boolean(v.applicationId || v.filter), { + message: + 'applicationId (or $filter including applicationId) is required; Interview Central only scans the first 1000 rows', + }), + getInterviewOverallAssessment: ODataQuery, + getJobApplication: ODataQuery, + getJobRequisition: ODataQuery, + getJobReqScreeningQuestion: ODataQuery, + listCandidates: ODataQuery, + getFoBusinessUnit: ODataQuery, + getFoCompany: ODataQuery, + getFoCostCenter: ODataQuery, + getFoDepartment: ODataQuery, + getFoJobCode: ODataQuery, + getFoJobFunction: ODataQuery, + getFoLocation: ODataQuery, + getFoPayGroup: ODataQuery, + getPosition: ODataQuery, + getCustomMdfObject: ODataQuery.extend({ + custom_object: z + .string() + .regex( + /^cust_[A-Za-z0-9_]+$/, + 'custom_object must be a cust_* MDF entity name', + ), + }), + getPicklist: ODataQuery, + getPicklistOption: ODataQuery, + getCurrentUser: ODataQuery, + getOdataUserMetadata: Empty, + listUsers: ODataQuery, + getPerPersonById: z.object({ + person_id_external: z.string().min(1), + select: odataQuery.select, + expand: odataQuery.expand, + }), + listPerPerson: ODataQuery, + getPerPersonal: ODataQuery, + getBackgroundEducation: ODataQuery, + getBackgroundMobility: ODataQuery, + listEmpEmployment: ODataQuery, + getEmpEmploymentTermination: ODataQuery, + getWorkOrder: ODataQuery, + getEmpPayCompRecurring: ODataQuery, + getEmpPayCompNonRecurring: ODataQuery, + getGoalPlanTemplate: ODataQuery, + getGoalsByPlan: ODataQuery.extend({ + goal_plan_id: z.string().min(1), + }), + getFormContent: ODataQuery, + createLearningActivitiesBulk: z.object({ body: Body }), + getCdpLearningMetadata: Empty, + refreshCdpLearningMetadata: Empty, + getEmployeeTime: ODataQuery, + getEmployeeTimesheet: ODataQuery, + getTemporaryTimeInformation: ODataQuery, + getTimeAccountSnapshot: ODataQuery, + getOdataMetadataClockInclockOut: Empty, + queryAllAvailableClockClockOut: ODataQuery, + queryClockClockOutGroupCodeTime: z.object({ + code: z.string().min(1), + expand: odataQuery.expand, + select: odataQuery.select, + }), +} as const; + +export type SapsuccessfactorsEndpointInputs = { + [K in keyof typeof SapsuccessfactorsEndpointInputSchemas]: z.infer< + (typeof SapsuccessfactorsEndpointInputSchemas)[K] + >; +}; + +export const SapsuccessfactorsEndpointOutputSchemas = { + approveCalibrationSession: writeOf(CalibrationSessionOut), + getCalibrationSessionById: entityOf(CalibrationSessionOut), + getCalibrationSessions: collectionOf(CalibrationSessionOut), + getOdataMetadataCalibSessionService: SapMetadataSchema, + getCalibrationSubjectById: entityOf(CalibrationSubjectOut), + getCalibrationSubjectRatings: collectionOf(CalibrationSubjectOut), + updateCalibrationSubjectRatings: writeOf(CalibrationSubjectOut), + createOnboardee: writeOf(SapsuccessfactorsUserEntity), + getOnb2Process: collectionOf(Onb2ProcessOut), + getOdataMetadataOnboardingAddl: SapMetadataSchema, + updateInternalUsernameNewHiresAfter: writeOf(SapsuccessfactorsUserEntity), + createAFeedbackRequest: writeOf(FeedbackRequestOut), + getFeedbackRecordsServiceAvailable: collectionOf(FeedbackOut), + getPendingFeedbackRequestsFeedback: collectionOf(FeedbackRequestOut), + giveFeedbackOrRespondToAFeedbackRequest: writeOf(FeedbackOut), + refreshMetadataContFeedbackService: writeOf(ActionOut), + createUpdateSuccessorNomination: writeOf(NominationOut), + deleteNominationPositionTalentPool: writeOf(NominationOut), + getOdataMetadataForNominationService: SapMetadataSchema, + getTalentPool: collectionOf(TalentPoolOut), + getApplicationInterview: collectionOf(ApplicationInterviewOut), + getInterviewOverallAssessment: collectionOf(InterviewAssessmentOut), + getJobApplication: collectionOf(SapsuccessfactorsJobApplicationEntity), + getJobRequisition: collectionOf(SapsuccessfactorsJobRequisitionEntity), + getJobReqScreeningQuestion: collectionOf(ScreeningQuestionOut), + listCandidates: collectionOf(SapsuccessfactorsCandidateEntity), + getFoBusinessUnit: collectionOf(FoBusinessUnitOut), + getFoCompany: collectionOf(FoCompanyOut), + getFoCostCenter: collectionOf(FoCostCenterOut), + getFoDepartment: collectionOf(FoDepartmentOut), + getFoJobCode: collectionOf(FoJobCodeOut), + getFoJobFunction: collectionOf(FoJobFunctionOut), + getFoLocation: collectionOf(FoLocationOut), + getFoPayGroup: collectionOf(FoPayGroupOut), + getPosition: collectionOf(SapsuccessfactorsPositionEntity), + getCustomMdfObject: collectionOf(MdfOut), + getPicklist: collectionOf(PicklistOut), + getPicklistOption: collectionOf(PicklistOptionOut), + getCurrentUser: collectionOf(SapsuccessfactorsUserEntity), + getOdataUserMetadata: SapMetadataSchema, + listUsers: collectionOf(SapsuccessfactorsUserEntity), + getPerPersonById: entityOf(SapsuccessfactorsPersonEntity), + listPerPerson: collectionOf(SapsuccessfactorsPersonEntity), + getPerPersonal: collectionOf(SapsuccessfactorsPersonalEntity), + getBackgroundEducation: collectionOf(BackgroundEducationOut), + getBackgroundMobility: collectionOf(BackgroundMobilityOut), + listEmpEmployment: collectionOf(SapsuccessfactorsEmploymentEntity), + getEmpEmploymentTermination: collectionOf(EmploymentTerminationOut), + getWorkOrder: collectionOf(WorkOrderOut), + getEmpPayCompRecurring: collectionOf(PayCompOut), + getEmpPayCompNonRecurring: collectionOf(PayCompOut), + getGoalPlanTemplate: collectionOf(GoalPlanOut), + getGoalsByPlan: collectionOf(GoalOut), + getFormContent: collectionOf(FormContentOut), + createLearningActivitiesBulk: writeOf(LearningActivityOut), + getCdpLearningMetadata: SapMetadataSchema, + refreshCdpLearningMetadata: writeOf(ActionOut), + getEmployeeTime: collectionOf(EmployeeTimeOut), + getEmployeeTimesheet: collectionOf(EmployeeTimeSheetOut), + getTemporaryTimeInformation: collectionOf(TemporaryTimeOut), + getTimeAccountSnapshot: collectionOf(TimeAccountSnapshotOut), + getOdataMetadataClockInclockOut: SapMetadataSchema, + queryAllAvailableClockClockOut: collectionOf(ClockInClockOutGroupOut), + queryClockClockOutGroupCodeTime: entityOf(ClockInClockOutGroupOut), +} as const satisfies Record; + +export type SapsuccessfactorsEndpointOutputs = { + [K in keyof typeof SapsuccessfactorsEndpointOutputSchemas]: z.infer< + (typeof SapsuccessfactorsEndpointOutputSchemas)[K] + >; +}; + +export type SapsuccessfactorsEndpointInput = + SapsuccessfactorsEndpointInputs[keyof SapsuccessfactorsEndpointInputs] & + Record; diff --git a/packages/sapsuccessfactors/error-handlers.ts b/packages/sapsuccessfactors/error-handlers.ts new file mode 100644 index 000000000..7395b53ba --- /dev/null +++ b/packages/sapsuccessfactors/error-handlers.ts @@ -0,0 +1,50 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return ( + msg.includes('429') || + msg.includes('rate limit') || + msg.includes('too many requests') + ); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 3, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if ( + error instanceof ApiError && + (error.status === 401 || error.status === 403) + ) + return true; + const msg = error.message.toLowerCase(); + return ( + msg.includes('unauthorized') || + msg.includes('forbidden') || + msg.includes('invalid credentials') + ); + }, + handler: async () => ({ maxRetries: 0 }), + }, + NOT_FOUND: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 404) return true; + return error.message.toLowerCase().includes('not found'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/sapsuccessfactors/index.ts b/packages/sapsuccessfactors/index.ts new file mode 100644 index 000000000..4dfbfca58 --- /dev/null +++ b/packages/sapsuccessfactors/index.ts @@ -0,0 +1,196 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + normalizeSapsuccessfactorsHost, + SAP_SUCCESSFACTORS_DEFAULT_HOST, + sapSuccessfactorsOAuthUrls, +} from './client'; +import { + SapsuccessfactorsEndpointInputSchemas, + SapsuccessfactorsEndpointOutputSchemas, + sapRoutes, + sapsuccessfactorsEndpointsNested, +} from './endpoints'; +import type { + SapsuccessfactorsEndpointInputs, + SapsuccessfactorsEndpointOutputs, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { SapsuccessfactorsSchema } from './schema'; + +export const sapsuccessfactorsAuthConfig = { + api_key: { + account: ['host', 'company_id'] as const, + }, + oauth_2: { + account: ['host', 'company_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type SapsuccessfactorsPluginOptions = { + authType?: PickAuth<'api_key' | 'oauth_2'>; + /** Bearer token or `Basic …` (tests / BYO). */ + key?: string; + /** API hostname, e.g. api10.successfactors.com */ + host?: string; + /** Alias for host (older option name). */ + apiBaseUrl?: string; + /** SuccessFactors company ID (OAuth token request). */ + companyId?: string; + hooks?: InternalSapsuccessfactorsPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig< + typeof sapsuccessfactorsEndpointsNested + >; +}; + +export type SapsuccessfactorsContext = CorsairPluginContext< + typeof SapsuccessfactorsSchema, + SapsuccessfactorsPluginOptions, + undefined, + typeof sapsuccessfactorsAuthConfig +>; +export type SapsuccessfactorsKeyBuilderContext = KeyBuilderContext< + SapsuccessfactorsPluginOptions, + typeof sapsuccessfactorsAuthConfig +>; +export type SapsuccessfactorsBoundEndpoints = BindEndpoints< + typeof sapsuccessfactorsEndpointsNested +>; + +type SapsuccessfactorsEndpoint< + K extends keyof SapsuccessfactorsEndpointOutputs, +> = CorsairEndpoint< + SapsuccessfactorsContext, + SapsuccessfactorsEndpointInputs[K], + SapsuccessfactorsEndpointOutputs[K] +>; + +export type SapsuccessfactorsEndpoints = { + [K in keyof SapsuccessfactorsEndpointOutputs]: SapsuccessfactorsEndpoint; +}; + +const sapsuccessfactorsEndpointSchemas = Object.fromEntries( + sapRoutes.map((route) => [ + `${route.group}.${route.name}`, + { + input: SapsuccessfactorsEndpointInputSchemas[route.name], + output: SapsuccessfactorsEndpointOutputSchemas[route.name], + }, + ]), +) as unknown as RequiredPluginEndpointSchemas< + typeof sapsuccessfactorsEndpointsNested +>; + +const sapsuccessfactorsEndpointMeta = Object.fromEntries( + sapRoutes.map((route) => [ + `${route.group}.${route.name}`, + { + riskLevel: route.riskLevel, + description: route.description, + ...('irreversible' in route && route.irreversible + ? { irreversible: true as const } + : {}), + }, + ]), +) as unknown as RequiredPluginEndpointMeta< + typeof sapsuccessfactorsEndpointsNested +> satisfies RequiredPluginEndpointMeta; + +const defaultAuthType: AuthTypes = 'oauth_2'; + +export type BaseSapsuccessfactorsPlugin< + T extends SapsuccessfactorsPluginOptions, +> = CorsairPlugin< + 'sapsuccessfactors', + typeof SapsuccessfactorsSchema, + typeof sapsuccessfactorsEndpointsNested, + Record, + T, + typeof defaultAuthType, + typeof sapsuccessfactorsAuthConfig +>; + +export type InternalSapsuccessfactorsPlugin = + BaseSapsuccessfactorsPlugin; +export type ExternalSapsuccessfactorsPlugin< + T extends SapsuccessfactorsPluginOptions, +> = BaseSapsuccessfactorsPlugin; + +export function sapsuccessfactors< + const T extends SapsuccessfactorsPluginOptions, +>( + incomingOptions: SapsuccessfactorsPluginOptions & + T = {} as SapsuccessfactorsPluginOptions & T, +): ExternalSapsuccessfactorsPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + const rawHost = options.host?.trim() || options.apiBaseUrl?.trim(); + const host = rawHost + ? normalizeSapsuccessfactorsHost(rawHost) + : SAP_SUCCESSFACTORS_DEFAULT_HOST; + options.host = host; + const oauthUrls = sapSuccessfactorsOAuthUrls(host); + + return { + id: 'sapsuccessfactors', + authConfig: sapsuccessfactorsAuthConfig, + oauthConfig: { + providerName: 'SAP SuccessFactors', + authUrl: oauthUrls.authUrl, + tokenUrl: oauthUrls.tokenUrl, + scopes: [], + tokenAuthMethod: 'body' as const, + requiresRegisteredRedirect: true, + }, + schema: SapsuccessfactorsSchema, + options, + hooks: options.hooks, + webhookHooks: undefined, + endpoints: sapsuccessfactorsEndpointsNested, + webhooks: {} as const, + endpointMeta: sapsuccessfactorsEndpointMeta, + endpointSchemas: sapsuccessfactorsEndpointSchemas, + webhookSchemas: {} as const, + pluginWebhookMatcher: undefined, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: SapsuccessfactorsKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) return options.key; + if (source === 'endpoint' && ctx.authType === 'oauth_2') { + const res = await ctx.keys.get_access_token(); + if (!res) throw new AuthMissingError('sapsuccessfactors', 'oauth_2'); + return res; + } + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + if (!res) throw new AuthMissingError('sapsuccessfactors', 'api_key'); + return res; + } + throw new AuthMissingError('sapsuccessfactors', options.authType); + }, + } satisfies InternalSapsuccessfactorsPlugin; +} + +export { sapRoutes } from './endpoints/routes'; +export type { + SapsuccessfactorsEndpointInputs, + SapsuccessfactorsEndpointOutputs, +} from './endpoints/types'; diff --git a/packages/sapsuccessfactors/jest.config.cjs b/packages/sapsuccessfactors/jest.config.cjs new file mode 100644 index 000000000..5d4bfd6ec --- /dev/null +++ b/packages/sapsuccessfactors/jest.config.cjs @@ -0,0 +1,30 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: 'tsconfig.test.json', + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: 'tsconfig.test.json', + }, + ], + }, + moduleNameMapper: { + '^corsair/http$': '/../corsair/http.ts', + '^corsair/core$': '/../corsair/core.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, +}; diff --git a/packages/sapsuccessfactors/package.json b/packages/sapsuccessfactors/package.json new file mode 100644 index 000000000..809f6f6af --- /dev/null +++ b/packages/sapsuccessfactors/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/sapsuccessfactors", + "version": "0.1.0", + "description": "SAP SuccessFactors 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", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "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", + "sapsuccessfactors", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/sapsuccessfactors/schema.test.ts b/packages/sapsuccessfactors/schema.test.ts new file mode 100644 index 000000000..2840c2617 --- /dev/null +++ b/packages/sapsuccessfactors/schema.test.ts @@ -0,0 +1,114 @@ +import { sapRoutes } from './endpoints/routes'; +import { + SapsuccessfactorsEndpointInputSchemas, + SapsuccessfactorsEndpointOutputSchemas, +} from './endpoints/types'; +import { SapsuccessfactorsSchema } from './schema'; +import { SapsuccessfactorsUserEntity } from './schema/database'; + +describe('sapsuccessfactors schemas', () => { + it('declares labeled User fields from the OData dictionary', () => { + const user = SapsuccessfactorsUserEntity.parse({ + userId: 'cgrant', + username: 'cgrant', + firstName: 'Carla', + lastName: 'Grant', + email: 'cgrant@example.com', + status: 't', + custom01: 'tenant-extra', + }); + expect(user.userId).toBe('cgrant'); + expect(SapsuccessfactorsSchema.entities.user).toBeDefined(); + expect(SapsuccessfactorsSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('covers every registered operation with input and output schemas', () => { + for (const route of sapRoutes) { + expect( + SapsuccessfactorsEndpointInputSchemas[ + route.name as keyof typeof SapsuccessfactorsEndpointInputSchemas + ], + ).toBeDefined(); + expect( + SapsuccessfactorsEndpointOutputSchemas[ + route.name as keyof typeof SapsuccessfactorsEndpointOutputSchemas + ], + ).toBeDefined(); + } + expect(sapRoutes).toHaveLength(64); + }); + + it('rejects invalid paging and missing keys', () => { + expect( + SapsuccessfactorsEndpointInputSchemas.listUsers.safeParse({ + top: 'nope', + }).success, + ).toBe(false); + expect( + SapsuccessfactorsEndpointInputSchemas.approveCalibrationSession.safeParse( + {}, + ).success, + ).toBe(false); + expect( + SapsuccessfactorsEndpointInputSchemas.getPerPersonById.safeParse({}) + .success, + ).toBe(false); + expect( + SapsuccessfactorsEndpointInputSchemas.getCustomMdfObject.safeParse({ + custom_object: 'User', + }).success, + ).toBe(false); + expect( + SapsuccessfactorsEndpointInputSchemas.createAFeedbackRequest.safeParse({}) + .success, + ).toBe(false); + }); + + it('accepts OData v2, v4, and metadata payloads', () => { + expect( + SapsuccessfactorsEndpointOutputSchemas.listUsers.parse({ + d: { results: [{ userId: 'cgrant' }] }, + }), + ).toBeDefined(); + expect( + SapsuccessfactorsEndpointOutputSchemas.getFeedbackRecordsServiceAvailable.parse( + { value: [{ id: '1' }] }, + ), + ).toBeDefined(); + expect( + SapsuccessfactorsEndpointOutputSchemas.getOdataUserMetadata.parse( + '', + ), + ).toBeDefined(); + expect( + SapsuccessfactorsEndpointOutputSchemas.listUsers.safeParse(null).success, + ).toBe(false); + expect( + SapsuccessfactorsEndpointOutputSchemas.listUsers.safeParse('oops') + .success, + ).toBe(false); + expect( + SapsuccessfactorsEndpointOutputSchemas.listUsers.safeParse({ foo: 1 }) + .success, + ).toBe(false); + expect( + SapsuccessfactorsEndpointOutputSchemas.listUsers.safeParse({ + d: { results: [{ jobReqId: 1 }] }, + }).success, + ).toBe(false); + expect( + SapsuccessfactorsEndpointOutputSchemas.getPerPersonById.safeParse({ + d: { userId: 'cgrant' }, + }).success, + ).toBe(false); + expect( + SapsuccessfactorsEndpointOutputSchemas.updateCalibrationSubjectRatings.safeParse( + undefined, + ).success, + ).toBe(true); + expect( + SapsuccessfactorsEndpointOutputSchemas.listUsers.safeParse(undefined) + .success, + ).toBe(false); + }); +}); diff --git a/packages/sapsuccessfactors/schema/database.ts b/packages/sapsuccessfactors/schema/database.ts new file mode 100644 index 000000000..9dc00c3bf --- /dev/null +++ b/packages/sapsuccessfactors/schema/database.ts @@ -0,0 +1,279 @@ +import { z } from 'zod'; + +/** + * SAP SuccessFactors OData entity shapes for Corsair DB cache (`ctx.db.*`). + * Field names follow the labeled properties in the OData API Data Dictionary + * (Admin Center → API Center → OData API Data Dictionary) and the HCM OData + * API Reference: User, PerPerson, PerPersonal, EmpEmployment, JobRequisition, + * Candidate, JobApplication, Position, FO*. + * + * Loose + catchall — tenants add custom fields; OData also returns `__metadata`. + */ + +const S = z.string().nullable().optional(); +const N = z.number().nullable().optional(); +const B = z.boolean().nullable().optional(); +const Deferred = z + .object({ __deferred: z.object({ uri: z.string().optional() }).optional() }) + .catchall(z.unknown()) + .optional(); + +const ODataMeta = z + .object({ + uri: z.string().optional(), + type: z.string().optional(), + }) + .catchall(z.unknown()) + .optional(); + +/** User — business key `userId`. OData: GET /odata/v2/User */ +export const SapsuccessfactorsUserEntity = z + .object({ + __metadata: ODataMeta, + userId: z.string(), + username: S, + defaultFullName: S, + firstName: S, + mi: S, + lastName: S, + email: S, + status: S, + department: S, + division: S, + location: S, + title: S, + managerId: S, + hrId: S, + hireDate: S, + lastModifiedDateTime: S, + lastModified: S, + timeZone: S, + country: S, + state: S, + city: S, + zipCode: S, + addressLine1: S, + businessPhone: S, + cellPhone: S, + empId: S, + totalTeamSize: N, + directReports: Deferred, + manager: Deferred, + hr: Deferred, + }) + .catchall(z.unknown()); +export type SapsuccessfactorsUserEntity = z.infer< + typeof SapsuccessfactorsUserEntity +>; + +/** PerPerson — Employee Central person; business key `personIdExternal`. */ +export const SapsuccessfactorsPersonEntity = z + .object({ + __metadata: ODataMeta, + personIdExternal: z.string(), + personId: S, + dateOfBirth: S, + countryOfBirth: S, + regionOfBirth: S, + placeOfBirth: S, + perPersonUuid: S, + lastModifiedDateTime: S, + personalInfoNav: Deferred, + employmentNav: Deferred, + emailNav: Deferred, + phoneNav: Deferred, + }) + .catchall(z.unknown()); +export type SapsuccessfactorsPersonEntity = z.infer< + typeof SapsuccessfactorsPersonEntity +>; + +/** PerPersonal — effective-dated biographical info. */ +export const SapsuccessfactorsPersonalEntity = z + .object({ + __metadata: ODataMeta, + personIdExternal: z.string(), + startDate: S, + endDate: S, + firstName: S, + lastName: S, + middleName: S, + formalName: S, + birthName: S, + gender: S, + maritalStatus: S, + nationality: S, + preferredName: S, + salutation: S, + lastModifiedDateTime: S, + }) + .catchall(z.unknown()); +export type SapsuccessfactorsPersonalEntity = z.infer< + typeof SapsuccessfactorsPersonalEntity +>; + +/** EmpEmployment — employment assignment. */ +export const SapsuccessfactorsEmploymentEntity = z + .object({ + __metadata: ODataMeta, + userId: z.string(), + personIdExternal: S, + startDate: S, + endDate: S, + originalStartDate: S, + seniorityDate: S, + assignmentClass: S, + employmentType: S, + isContingentWorker: B, + lastModifiedDateTime: S, + jobInfoNav: Deferred, + compInfoNav: Deferred, + }) + .catchall(z.unknown()); +export type SapsuccessfactorsEmploymentEntity = z.infer< + typeof SapsuccessfactorsEmploymentEntity +>; + +/** CalibrationSession — CalSession.svc OData V4. */ +export const SapsuccessfactorsCalibrationSessionEntity = z + .object({ + sessionId: z.string().optional(), + sessionName: S, + sessionOwnerId: S, + sessionType: S, + status: S, + startDate: S, + endDate: S, + }) + .catchall(z.unknown()); +export type SapsuccessfactorsCalibrationSessionEntity = z.infer< + typeof SapsuccessfactorsCalibrationSessionEntity +>; + +/** GoalPlanTemplate */ +export const SapsuccessfactorsGoalPlanEntity = z + .object({ + id: z.union([z.string(), z.number()]).optional(), + name: S, + type: S, + dueDate: S, + }) + .catchall(z.unknown()); +export type SapsuccessfactorsGoalPlanEntity = z.infer< + typeof SapsuccessfactorsGoalPlanEntity +>; + +/** Goal_ */ +export const SapsuccessfactorsGoalEntity = z + .object({ + id: z.union([z.string(), z.number()]).optional(), + userId: S, + name: S, + flag: S, + state: S, + type: S, + metric: S, + done: N, + start: S, + due: S, + }) + .catchall(z.unknown()); +export type SapsuccessfactorsGoalEntity = z.infer< + typeof SapsuccessfactorsGoalEntity +>; + +/** JobRequisition — business key `jobReqId`. */ +export const SapsuccessfactorsJobRequisitionEntity = z + .object({ + __metadata: ODataMeta, + jobReqId: z.union([z.string(), z.number()]), + internalStatus: S, + jobTitle: S, + jobCode: S, + department: S, + division: S, + location: S, + country: S, + statusSetId: S, + appStatusSetId: S, + lastModifiedDateTime: S, + }) + .catchall(z.unknown()); +export type SapsuccessfactorsJobRequisitionEntity = z.infer< + typeof SapsuccessfactorsJobRequisitionEntity +>; + +/** Candidate */ +export const SapsuccessfactorsCandidateEntity = z + .object({ + __metadata: ODataMeta, + candidateId: z.union([z.string(), z.number()]), + firstName: S, + lastName: S, + primaryEmail: S, + contactEmail: S, + cellPhone: S, + city: S, + country: S, + currentTitle: S, + lastModifiedDateTime: S, + }) + .catchall(z.unknown()); +export type SapsuccessfactorsCandidateEntity = z.infer< + typeof SapsuccessfactorsCandidateEntity +>; + +/** JobApplication */ +export const SapsuccessfactorsJobApplicationEntity = z + .object({ + __metadata: ODataMeta, + applicationId: z.union([z.string(), z.number()]), + jobReqId: z.union([z.string(), z.number()]).nullable().optional(), + candidateId: z.union([z.string(), z.number()]).nullable().optional(), + status: S, + appStatusSetId: S, + applicationDate: S, + lastModifiedDateTime: S, + }) + .catchall(z.unknown()); +export type SapsuccessfactorsJobApplicationEntity = z.infer< + typeof SapsuccessfactorsJobApplicationEntity +>; + +/** Position — Employee Central Position Management. */ +export const SapsuccessfactorsPositionEntity = z + .object({ + __metadata: ODataMeta, + code: z.string(), + effectiveStartDate: S, + effectiveEndDate: S, + effectiveStatus: S, + externalName_defaultValue: S, + jobCode: S, + department: S, + division: S, + company: S, + location: S, + payGrade: S, + lastModifiedDateTime: S, + }) + .catchall(z.unknown()); +export type SapsuccessfactorsPositionEntity = z.infer< + typeof SapsuccessfactorsPositionEntity +>; + +/** FOCompany — foundation object. */ +export const SapsuccessfactorsCompanyEntity = z + .object({ + externalCode: z.string().optional(), + startDate: S, + name_defaultValue: S, + status: S, + country: S, + currency: S, + entityOID: S, + }) + .catchall(z.unknown()); +export type SapsuccessfactorsCompanyEntity = z.infer< + typeof SapsuccessfactorsCompanyEntity +>; diff --git a/packages/sapsuccessfactors/schema/index.ts b/packages/sapsuccessfactors/schema/index.ts new file mode 100644 index 000000000..ff7268d8c --- /dev/null +++ b/packages/sapsuccessfactors/schema/index.ts @@ -0,0 +1,34 @@ +import { + SapsuccessfactorsCalibrationSessionEntity, + SapsuccessfactorsCandidateEntity, + SapsuccessfactorsCompanyEntity, + SapsuccessfactorsEmploymentEntity, + SapsuccessfactorsGoalEntity, + SapsuccessfactorsGoalPlanEntity, + SapsuccessfactorsJobApplicationEntity, + SapsuccessfactorsJobRequisitionEntity, + SapsuccessfactorsPersonalEntity, + SapsuccessfactorsPersonEntity, + SapsuccessfactorsPositionEntity, + SapsuccessfactorsUserEntity, +} from './database'; + +export const SapsuccessfactorsSchema = { + version: '1.0.0', + entities: { + user: SapsuccessfactorsUserEntity, + person: SapsuccessfactorsPersonEntity, + personal: SapsuccessfactorsPersonalEntity, + employment: SapsuccessfactorsEmploymentEntity, + calibrationSession: SapsuccessfactorsCalibrationSessionEntity, + goalPlan: SapsuccessfactorsGoalPlanEntity, + goal: SapsuccessfactorsGoalEntity, + jobRequisition: SapsuccessfactorsJobRequisitionEntity, + candidate: SapsuccessfactorsCandidateEntity, + jobApplication: SapsuccessfactorsJobApplicationEntity, + position: SapsuccessfactorsPositionEntity, + company: SapsuccessfactorsCompanyEntity, + }, +} as const; + +export * from './database'; diff --git a/packages/sapsuccessfactors/tsconfig.json b/packages/sapsuccessfactors/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/sapsuccessfactors/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/sapsuccessfactors/tsconfig.test.json b/packages/sapsuccessfactors/tsconfig.test.json new file mode 100644 index 000000000..99f74b817 --- /dev/null +++ b/packages/sapsuccessfactors/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "verbatimModuleSyntax": false, + "lib": ["es2022", "dom", "esnext"], + "types": ["node", "jest"] + } +} diff --git a/packages/sapsuccessfactors/tsup.config.ts b/packages/sapsuccessfactors/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/sapsuccessfactors/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/pnpm-lock.yaml b/pnpm-lock.yaml index 45ec32231..22e4d9be8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5241,6 +5241,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/sapsuccessfactors: + 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/scrapegraphai: devDependencies: '@types/jest':