Skip to content

Commit 68aeb6c

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(quickbooks): add bounded document file routes
1 parent 16671d2 commit 68aeb6c

7 files changed

Lines changed: 1224 additions & 2 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { quickBooksAddAttachmentContract } from '@/lib/api/contracts/tools/quickbooks'
5+
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
10+
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
11+
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
12+
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
13+
import { assertToolFileAccess } from '@/app/api/files/authorization'
14+
import { buildQuickBooksCompanyUrl, buildQuickBooksHeaders } from '@/tools/quickbooks/client'
15+
import {
16+
assertSingleQuickBooksFile,
17+
buildQuickBooksAttachableMetadata,
18+
getQuickBooksDocumentError,
19+
parseQuickBooksAttachableResponse,
20+
sanitizeQuickBooksFileName,
21+
validateQuickBooksAttachmentFileType,
22+
} from '@/tools/quickbooks/documents_utils'
23+
24+
export const dynamic = 'force-dynamic'
25+
const logger = createLogger('QuickBooksAddAttachmentAPI')
26+
27+
export const POST = withRouteHandler(async (request: NextRequest) => {
28+
const requestId = `quickbooks-attachment-${Date.now()}`
29+
try {
30+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
31+
if (!authResult.success || !authResult.userId) {
32+
return NextResponse.json(
33+
{ success: false, error: authResult.error || 'Unauthorized' },
34+
{ status: 401 }
35+
)
36+
}
37+
38+
const parsed = await parseRequest(
39+
quickBooksAddAttachmentContract,
40+
request,
41+
{},
42+
{
43+
validationErrorResponse: (error) =>
44+
NextResponse.json(
45+
{ success: false, error: getValidationErrorMessage(error, 'Invalid request') },
46+
{ status: 400 }
47+
),
48+
}
49+
)
50+
if (!parsed.success) return parsed.response
51+
const data = parsed.data.body
52+
const url = buildQuickBooksCompanyUrl(
53+
data.realmId,
54+
data.attachmentKind === 'file' ? 'upload' : 'attachable'
55+
)
56+
let response: Response
57+
58+
if (data.attachmentKind === 'note') {
59+
const metadata = buildQuickBooksAttachableMetadata(data.targetType, data.targetId, {
60+
note: data.note!,
61+
})
62+
response = await fetch(url, {
63+
method: 'POST',
64+
headers: {
65+
...buildQuickBooksHeaders(data.accessToken),
66+
'Content-Type': 'application/json',
67+
},
68+
body: JSON.stringify(metadata),
69+
})
70+
} else {
71+
const rawFile = assertSingleQuickBooksFile(data.file ?? undefined)
72+
const files = processFilesToUserFiles([rawFile], requestId, logger)
73+
if (files.length !== 1) throw new Error('Exactly one valid file is required')
74+
const file = files[0]
75+
const denied = await assertToolFileAccess(file.key, authResult.userId, requestId, logger)
76+
if (denied) return denied
77+
let downloaded: Awaited<ReturnType<typeof downloadServableFileFromStorage>>
78+
try {
79+
downloaded = await downloadServableFileFromStorage(file, requestId, logger)
80+
} catch (error) {
81+
const notReady = docNotReadyResponse(error)
82+
if (notReady) return notReady
83+
throw error
84+
}
85+
assertKnownSizeWithinLimit(
86+
downloaded.buffer.length,
87+
MAX_FILE_SIZE,
88+
'QuickBooks attachment file'
89+
)
90+
if (downloaded.buffer.length === 0)
91+
throw new Error('QuickBooks attachment file cannot be empty')
92+
const resolvedName = sanitizeQuickBooksFileName(data.fileName ?? undefined, file.name)
93+
const storedMime = (downloaded.contentType || file.type || '')
94+
.split(';', 1)[0]
95+
.trim()
96+
.toLowerCase()
97+
const requestedMime = data.contentType?.trim().toLowerCase() || storedMime
98+
const mimeType = validateQuickBooksAttachmentFileType(resolvedName, requestedMime)
99+
if (data.contentType && storedMime && requestedMime !== storedMime) {
100+
validateQuickBooksAttachmentFileType(resolvedName, storedMime)
101+
}
102+
const metadata = buildQuickBooksAttachableMetadata(data.targetType, data.targetId, {
103+
fileName: resolvedName,
104+
contentType: mimeType,
105+
description: data.description ?? undefined,
106+
})
107+
const formData = new FormData()
108+
formData.append(
109+
'file_metadata_01',
110+
new Blob([JSON.stringify(metadata)], { type: 'application/json' }),
111+
'attachment.json'
112+
)
113+
formData.append(
114+
'file_content_01',
115+
new Blob([new Uint8Array(downloaded.buffer)], { type: mimeType }),
116+
resolvedName
117+
)
118+
response = await fetch(url, {
119+
method: 'POST',
120+
headers: buildQuickBooksHeaders(data.accessToken),
121+
body: formData,
122+
})
123+
}
124+
125+
if (!response.ok) throw await getQuickBooksDocumentError(response)
126+
const transformed = await parseQuickBooksAttachableResponse(response)
127+
return NextResponse.json({
128+
success: true,
129+
output: {
130+
attachment: transformed.attachment,
131+
attachmentId: transformed.attachment.Id.trim(),
132+
attachmentKind: data.attachmentKind,
133+
targetType: data.targetType,
134+
targetId: data.targetId,
135+
time: transformed.time,
136+
},
137+
})
138+
} catch (error) {
139+
logger.error(`[${requestId}] QuickBooks attachment creation failed`, {
140+
error: getErrorMessage(error),
141+
})
142+
return NextResponse.json(
143+
{ success: false, error: getErrorMessage(error, 'Failed to add QuickBooks attachment') },
144+
{ status: isPayloadSizeLimitError(error) ? 413 : 500 }
145+
)
146+
}
147+
})

0 commit comments

Comments
 (0)