This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
seahaven-slack-bot/lambda/qbo-lookup/index.ts

222 lines
7 KiB
TypeScript
Raw Permalink Normal View History

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}`);
}
};