Skip to content

Commit 25e0426

Browse files
committed
feat(sap): add SAP Concur integration block and SAP S/4HANA validation fixes
1 parent 7953c56 commit 25e0426

128 files changed

Lines changed: 20981 additions & 448 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/icons.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4141,6 +4141,25 @@ export function SapS4HanaIcon(props: SVGProps<SVGSVGElement>) {
41414141
)
41424142
}
41434143

4144+
export function SapConcurIcon(props: SVGProps<SVGSVGElement>) {
4145+
return (
4146+
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 43.1 43.1'>
4147+
<path
4148+
fill='#F0AB00'
4149+
d='M20.5,28.2c-3.6,0-6.6-3-6.6-6.6s2.9-6.6,6.6-6.6c1.8,0,3.5,0.7,4.6,1.9l3.4-3.4c-2.1-2.1-4.9-3.3-8.1-3.3 C14.1,10.2,9,15.3,9,21.6S14.1,33,20.4,33c3.1,0,6-1.3,8.1-3.3l-3.4-3.4C23.9,27.4,22.3,28.2,20.5,28.2'
4150+
/>
4151+
<path
4152+
fill='#F0AB00'
4153+
d='M30.1,18.7c-1.6,0-2.9,1.3-2.9,2.9s1.3,2.9,2.9,2.9c1.6,0,2.9-1.3,2.9-2.9C33,20,31.7,18.7,30.1,18.7'
4154+
/>
4155+
<path
4156+
fill='#F0AB00'
4157+
d='M0,43.1h43.1V0H0V43.1z M4.8,38.2V4.8h33.4v15.5v2.4v15.5C38.2,38.2,4.8,38.2,4.8,38.2z'
4158+
/>
4159+
</svg>
4160+
)
4161+
}
4162+
41444163
export function ServiceNowIcon(props: SVGProps<SVGSVGElement>) {
41454164
return (
41464165
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 71.1 63.6'>

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ import {
155155
RootlyIcon,
156156
S3Icon,
157157
SalesforceIcon,
158+
SapConcurIcon,
158159
SapS4HanaIcon,
159160
SESIcon,
160161
SearchIcon,
@@ -372,6 +373,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
372373
rootly: RootlyIcon,
373374
s3: S3Icon,
374375
salesforce: SalesforceIcon,
376+
sap_concur: SapConcurIcon,
375377
sap_s4hana: SapS4HanaIcon,
376378
search: SearchIcon,
377379
secrets_manager: SecretsManagerIcon,

apps/docs/content/docs/en/tools/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@
151151
"rootly",
152152
"s3",
153153
"salesforce",
154+
"sap_concur",
154155
"sap_s4hana",
155156
"search",
156157
"secrets_manager",

apps/docs/content/docs/en/tools/sap_concur.mdx

Lines changed: 2766 additions & 0 deletions
Large diffs are not rendered by default.

apps/docs/content/docs/en/tools/sap_s4hana.mdx

Lines changed: 803 additions & 202 deletions
Large diffs are not rendered by default.

apps/sim/app/(landing)/integrations/data/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ import {
155155
RootlyIcon,
156156
S3Icon,
157157
SalesforceIcon,
158+
SapConcurIcon,
158159
SapS4HanaIcon,
159160
SESIcon,
160161
SearchIcon,
@@ -354,6 +355,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
354355
rootly: RootlyIcon,
355356
s3: S3Icon,
356357
salesforce: SalesforceIcon,
358+
sap_concur: SapConcurIcon,
357359
sap_s4hana: SapS4HanaIcon,
358360
search: SearchIcon,
359361
secrets_manager: SecretsManagerIcon,

apps/sim/app/(landing)/integrations/data/integrations.json

Lines changed: 307 additions & 8 deletions
Large diffs are not rendered by default.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { getValidationErrorMessage, isZodError } from '@/lib/api/server'
5+
import { checkInternalAuth } from '@/lib/auth/hybrid'
6+
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import {
10+
assertSafeExternalUrl,
11+
extractSapConcurError,
12+
fetchSapConcurAccessToken,
13+
SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS,
14+
type SapConcurProxyRequest,
15+
SapConcurProxyRequestSchema,
16+
} from '@/app/api/tools/sap_concur/shared'
17+
18+
export const dynamic = 'force-dynamic'
19+
20+
const logger = createLogger('SapConcurProxyAPI')
21+
22+
type ProxyRequest = SapConcurProxyRequest
23+
24+
function buildApiUrl(geolocation: string, req: ProxyRequest): string {
25+
const base = geolocation.replace(/\/+$/, '')
26+
const subPath = req.path.startsWith('/') ? req.path : `/${req.path}`
27+
const url = `${base}${subPath}`
28+
29+
if (!req.query || Object.keys(req.query).length === 0) {
30+
return url
31+
}
32+
const search = new URLSearchParams()
33+
for (const [key, value] of Object.entries(req.query)) {
34+
if (value === undefined || value === null) continue
35+
search.append(key, String(value))
36+
}
37+
const queryString = search.toString()
38+
if (!queryString) return url
39+
return url.includes('?') ? `${url}&${queryString}` : `${url}?${queryString}`
40+
}
41+
42+
interface Invocation {
43+
status: number
44+
body: unknown
45+
raw: string
46+
}
47+
48+
async function callConcur(
49+
req: ProxyRequest,
50+
accessToken: string,
51+
geolocation: string
52+
): Promise<Invocation> {
53+
const url = assertSafeExternalUrl(buildApiUrl(geolocation, req), 'apiUrl').toString()
54+
const hasBody = req.body !== undefined && req.body !== null
55+
const headers: Record<string, string> = {
56+
Authorization: `Bearer ${accessToken}`,
57+
Accept: 'application/json',
58+
}
59+
if (hasBody) headers['Content-Type'] = req.contentType ?? 'application/json'
60+
if (req.companyUuid) headers['concur-correlationid'] = req.companyUuid
61+
62+
const response = await secureFetchWithValidation(
63+
url,
64+
{
65+
method: req.method,
66+
headers,
67+
body: hasBody
68+
? typeof req.body === 'string'
69+
? req.body
70+
: JSON.stringify(req.body)
71+
: undefined,
72+
timeout: SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS,
73+
},
74+
'apiUrl'
75+
)
76+
77+
const raw = await response.text()
78+
let parsed: unknown = null
79+
if (raw.length > 0) {
80+
try {
81+
parsed = JSON.parse(raw)
82+
} catch {
83+
parsed = raw
84+
}
85+
}
86+
return { status: response.status, body: parsed, raw }
87+
}
88+
89+
export const POST = withRouteHandler(async (request: NextRequest) => {
90+
const requestId = generateRequestId()
91+
92+
try {
93+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
94+
if (!authResult.success) {
95+
logger.warn(`[${requestId}] Unauthorized Concur proxy request: ${authResult.error}`)
96+
return NextResponse.json(
97+
{ success: false, error: authResult.error || 'Authentication required' },
98+
{ status: 401 }
99+
)
100+
}
101+
102+
// boundary-raw-json: internal proxy envelope validated by SapConcurProxyRequestSchema below; not a public boundary
103+
const json = await request.json()
104+
const proxyReq = SapConcurProxyRequestSchema.parse(json)
105+
106+
const { accessToken, geolocation } = await fetchSapConcurAccessToken(proxyReq, requestId)
107+
const invocation = await callConcur(proxyReq, accessToken, geolocation)
108+
109+
if (invocation.status >= 200 && invocation.status < 300) {
110+
const data = invocation.status === 204 ? null : invocation.body
111+
return NextResponse.json({ success: true, output: { status: invocation.status, data } })
112+
}
113+
114+
const message = extractSapConcurError(invocation.body, invocation.status)
115+
logger.warn(
116+
`[${requestId}] Concur API error (${invocation.status}) ${proxyReq.path}: ${message}`
117+
)
118+
return NextResponse.json(
119+
{ success: false, error: message, status: invocation.status },
120+
{ status: invocation.status }
121+
)
122+
} catch (error) {
123+
if (isZodError(error)) {
124+
logger.warn(`[${requestId}] Validation error:`, error.issues)
125+
return NextResponse.json(
126+
{ success: false, error: getValidationErrorMessage(error, 'Validation failed') },
127+
{ status: 400 }
128+
)
129+
}
130+
logger.error(`[${requestId}] Unexpected Concur proxy error:`, error)
131+
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
132+
}
133+
})

0 commit comments

Comments
 (0)