Fetch authorization, token, and revocation endpoints from Intuit's .well-known/openid_configuration at runtime instead of hardcoding. Cached per Lambda instance for performance.
221 lines
7 KiB
TypeScript
221 lines
7 KiB
TypeScript
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<string, string>;
|
|
promptSessionAttributes: Record<string, string>;
|
|
}
|
|
|
|
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<string> {
|
|
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<QBOSecret> {
|
|
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<string> {
|
|
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<QBOVendor[]> {
|
|
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<BedrockActionResponse> => {
|
|
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}`);
|
|
}
|
|
};
|