import { SecretsManagerClient, GetSecretValueCommand, PutSecretValueCommand } from '@aws-sdk/client-secrets-manager'; const secretsClient = new SecretsManagerClient({}); // ── Bedrock action group event / response types ─────────────────────────────── interface ActionParameter { name: string; type: string; value: string; } interface BedrockActionEvent { messageVersion: string; agent: { name: string; id: string; alias: string; version: string }; inputText: string; sessionId: string; actionGroup: string; function: string; parameters?: ActionParameter[]; sessionAttributes: Record; promptSessionAttributes: Record; } interface BedrockActionResponse { messageVersion: string; response: { actionGroup: string; function: string; functionResponse: { responseBody: { TEXT: { body: string } }; }; }; } function makeResponse(event: BedrockActionEvent, body: string): BedrockActionResponse { return { messageVersion: '1.0', response: { actionGroup: event.actionGroup, function: event.function, functionResponse: { responseBody: { TEXT: { body } } }, }, }; } // ── QBO types ───────────────────────────────────────────────────────────────── interface QBOSecret { clientId: string; clientSecret: string; refreshToken: string; realmId: string; } interface QBOVendor { Id: string; DisplayName: string; CompanyName?: string; PrimaryPhone?: { FreeFormNumber: string }; PrimaryEmailAddr?: { Address: string }; Notes?: string; Balance?: number; MetaData: { CreateTime: string; LastUpdatedTime: string }; } let cachedSecret: QBOSecret | undefined; // Intuit discovery document — token endpoint resolved at runtime const DISCOVERY_URL = 'https://developer.api.intuit.com/.well-known/openid_configuration'; let cachedTokenEndpoint: string | undefined; async function getTokenEndpoint(): Promise { if (!cachedTokenEndpoint) { const res = await fetch(DISCOVERY_URL); if (!res.ok) throw new Error(`Discovery fetch failed: ${res.status}`); const doc = (await res.json()) as { token_endpoint: string }; cachedTokenEndpoint = doc.token_endpoint; } return cachedTokenEndpoint; } async function getQBOSecret(): Promise { if (!cachedSecret) { const res = await secretsClient.send( new GetSecretValueCommand({ SecretId: process.env.QBO_SECRET_ARN! }), ); cachedSecret = JSON.parse(res.SecretString!) as QBOSecret; } return cachedSecret; } async function refreshAccessToken(secret: QBOSecret): Promise { const tokenEndpoint = await getTokenEndpoint(); const credentials = Buffer.from(`${secret.clientId}:${secret.clientSecret}`).toString('base64'); const res = await fetch(tokenEndpoint, { method: 'POST', headers: { Authorization: `Basic ${credentials}`, 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: secret.refreshToken, }).toString(), }); if (!res.ok) { throw new Error(`QBO token refresh failed: ${res.status} ${res.statusText}`); } const data = (await res.json()) as { access_token: string; refresh_token: string }; // Intuit rotates the refresh token on each use — persist it so it doesn't expire if (data.refresh_token && data.refresh_token !== secret.refreshToken) { secret.refreshToken = data.refresh_token; cachedSecret = secret; await secretsClient.send( new PutSecretValueCommand({ SecretId: process.env.QBO_SECRET_ARN!, SecretString: JSON.stringify(secret), }), ); } return data.access_token; } function buildVendorQuery(trade?: string, name?: string): string { const clauses: string[] = []; // Sanitize inputs to prevent SOQL injection (single quotes escaped) const sanitize = (s: string) => s.replace(/'/g, "''").substring(0, 100); if (name) { clauses.push(`DisplayName LIKE '%${sanitize(name)}%'`); } if (trade) { const t = sanitize(trade); clauses.push(`DisplayName LIKE '%${t}%'`); clauses.push(`Notes LIKE '%${t}%'`); } const where = clauses.length > 0 ? `WHERE ${clauses.join(' OR ')}` : ''; return `SELECT * FROM Vendor ${where} MAXRESULTS 10`; } async function queryVendors( accessToken: string, realmId: string, trade?: string, name?: string, ): Promise { const sql = buildVendorQuery(trade, name); const url = `https://quickbooks.api.intuit.com/v3/company/${realmId}/query?query=${encodeURIComponent(sql)}&minorversion=65`; const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, }); if (!res.ok) { throw new Error(`QBO query failed: ${res.status} ${res.statusText}`); } const data = (await res.json()) as { QueryResponse: { Vendor?: QBOVendor[]; totalCount?: number }; }; return data.QueryResponse.Vendor ?? []; } function formatVendors(vendors: QBOVendor[]): string { if (vendors.length === 0) { return 'No matching vendors found in QuickBooks.'; } return vendors .map((v) => { const lines: string[] = [`Vendor: ${v.DisplayName}`]; if (v.CompanyName && v.CompanyName !== v.DisplayName) { lines.push(` Company: ${v.CompanyName}`); } if (v.PrimaryPhone) lines.push(` Phone: ${v.PrimaryPhone.FreeFormNumber}`); if (v.PrimaryEmailAddr) lines.push(` Email: ${v.PrimaryEmailAddr.Address}`); if (v.Notes) lines.push(` Notes: ${v.Notes}`); if (v.Balance !== undefined) lines.push(` Outstanding balance: $${v.Balance.toFixed(2)}`); lines.push(` Last updated: ${new Date(v.MetaData.LastUpdatedTime).toLocaleDateString()}`); return lines.join('\n'); }) .join('\n\n'); } // ── Handler ─────────────────────────────────────────────────────────────────── export const handler = async (event: BedrockActionEvent): Promise => { const params = Object.fromEntries( (event.parameters ?? []).map((p) => [p.name, p.value]), ); const trade = params.trade?.trim() || undefined; const name = params.name?.trim() || undefined; if (!trade && !name) { return makeResponse(event, 'Please provide at least one of: trade or name to search for.'); } try { const secret = await getQBOSecret(); const accessToken = await refreshAccessToken(secret); const vendors = await queryVendors(accessToken, secret.realmId, trade, name); return makeResponse(event, formatVendors(vendors)); } catch (err) { console.error('QBO lookup error:', (err as Error).message); return makeResponse(event, `QuickBooks lookup failed: ${(err as Error).message}`); } };