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/wo-po-lookup/index.ts
Adam Moussa 006dbf7c6b feat: Alex rollout — persona, Socket Mode, payments, channels, App Home, unanswered questions
- Rename bot to Alex with friendly/professional persona (Bedrock Agent instruction rewrite)
- Replace HTTP webhook Lambda with ECS Fargate Socket Mode service (persistent WebSocket)
- Add payment/invoice lookup via PaymentsDashboard table (vendor, invoice, check search)
- Switch from manual SiteAssignments to auto-populated verified-sites table
- Add channel support via app_mention events (threaded replies)
- Add App Home tab with Block Kit capabilities view
- Add unanswered questions logging to seahaven-unanswered-questions DynamoDB table
- Fix pre-existing compliance: add arm64 + 60-day log retention to all Lambdas
- Remove webhook Lambda and seed-sites script (both obsolete)
2026-04-30 16:38:54 -04:00

383 lines
14 KiB
TypeScript

import { DynamoDBClient, GetItemCommand, QueryCommand, ScanCommand } from '@aws-sdk/client-dynamodb';
import { unmarshall } from '@aws-sdk/util-dynamodb';
const dynamo = new DynamoDBClient({});
// ── 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 } } },
},
};
}
// ── Work Order lookup ────────────────────────────────────────────────────────
async function lookupWorkOrder(workOrderId: string): Promise<string> {
const woRes = await dynamo.send(
new GetItemCommand({
TableName: process.env.WORK_ORDERS_TABLE!,
Key: { work_order_id: { S: workOrderId } },
}),
);
if (!woRes.Item) {
return `Work order ${workOrderId} was not found.`;
}
const wo = unmarshall(woRes.Item) as Record<string, any>;
const lines: string[] = [];
lines.push(`WORK ORDER ${wo.work_order_id}`);
if (wo.description) lines.push(`Description: ${wo.description}`);
lines.push('');
// Details section
const details: string[] = [];
if (wo.wo_status) details.push(`Status: ${wo.wo_status}`);
if (wo.customer) details.push(`Customer: ${wo.customer}`);
if (wo.site_code) details.push(`Site: ${wo.site_code}${wo.building && wo.building !== wo.site_code ? ` (${wo.building})` : ''}`);
if (wo.address) details.push(`Address: ${wo.address}`);
if (wo.severity) details.push(`Severity: ${wo.severity}`);
if (wo.priority) details.push(`Priority: ${wo.priority}`);
if (wo.assigned_to) details.push(`Assigned To: ${wo.assigned_to}`);
if (details.length > 0) {
lines.push('DETAILS');
lines.push(...details);
lines.push('');
}
// Dates section
const dates: string[] = [];
if (wo.date_reported) dates.push(`Reported: ${wo.date_reported}`);
if (wo.scheduled_start) dates.push(`Scheduled Start: ${wo.scheduled_start}`);
if (wo.due_date) dates.push(`Due: ${wo.due_date}`);
if (wo.updated_at) dates.push(`Last Updated: ${wo.updated_at.split('T')[0]}`);
if (dates.length > 0) {
lines.push('DATES');
lines.push(...dates);
lines.push('');
}
// Fetch comments
const commentsRes = await dynamo.send(
new QueryCommand({
TableName: process.env.COMMENTS_TABLE!,
KeyConditionExpression: 'work_order_id = :woid',
ExpressionAttributeValues: { ':woid': { S: workOrderId } },
}),
);
const comments = (commentsRes.Items ?? [])
.map((item) => unmarshall(item) as Record<string, any>)
.sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? ''));
if (comments.length > 0) {
lines.push('COMMENT HISTORY (oldest to newest)');
for (const c of comments) {
const date = (c.created_at ?? 'unknown').split('T')[0];
const author = c.commenter ?? 'unknown';
lines.push(`[${date}] ${author}: ${c.text ?? '(no text)'}`);
}
}
return lines.join('\n');
}
// ── Purchase Order lookup ────────────────────────────────────────────────────
async function lookupPurchaseOrder(poNumber: string): Promise<string> {
const poRes = await dynamo.send(
new GetItemCommand({
TableName: process.env.PO_TABLE!,
Key: { po_number: { S: poNumber } },
}),
);
if (!poRes.Item) {
return `Purchase order ${poNumber} was not found.`;
}
const po = unmarshall(poRes.Item) as Record<string, any>;
const lines: string[] = [];
lines.push(`PURCHASE ORDER ${po.po_number}`);
if (po.po_status) lines.push(`Status: ${po.po_status}`);
if (po.email_type === 'cancellation') lines.push(`Cancelled: ${po.cancelled_at ?? 'yes'}`);
if (po.total_amount !== undefined) lines.push(`Total: ${po.currency ?? 'USD'} ${po.total_amount}`);
lines.push('');
// Details section
const details: string[] = [];
if (po.supplier?.name) details.push(`Supplier: ${po.supplier.name}`);
if (po.source_system) details.push(`Source: ${po.source_system}`);
if (po.order_date) details.push(`Order Date: ${po.order_date}`);
if (po.revision_date) details.push(`Revision Date: ${po.revision_date}`);
if (po.payment_terms) details.push(`Payment Terms: ${po.payment_terms}`);
if (po.requisition_number) details.push(`Requisition #: ${po.requisition_number}`);
if (po.department) details.push(`Department: ${po.department}`);
if (po.submitted_by) details.push(`Submitted By: ${po.submitted_by}`);
if (po.on_behalf_of) details.push(`On Behalf Of: ${po.on_behalf_of}`);
if (details.length > 0) {
lines.push('DETAILS');
lines.push(...details);
lines.push('');
}
// Ship-to section
const ship = po.ship_to;
if (ship) {
const parts: string[] = [];
if (ship.name) parts.push(`Name: ${ship.name}`);
if (ship.address) parts.push(`Address: ${ship.address}`);
if (ship.location_code) parts.push(`Location Code: ${ship.location_code}`);
if (ship.attn) parts.push(`Attn: ${ship.attn}`);
if (parts.length > 0) {
lines.push('SHIP TO');
lines.push(...parts);
lines.push('');
}
}
// Line items section
const items = po.line_items;
if (Array.isArray(items) && items.length > 0) {
lines.push('LINE ITEMS');
for (let i = 0; i < items.length; i++) {
const li = items[i];
const desc = li.description ?? 'No description';
const amt = li.amount ? ` | ${li.currency ?? 'USD'} ${li.amount}` : '';
const needBy = li.need_by ? ` | Need by: ${li.need_by}` : '';
const cat = li.category ? ` | Category: ${li.category}` : '';
lines.push(`${i + 1}. ${desc}${amt}${needBy}${cat}`);
}
}
return lines.join('\n');
}
// ── Site lookup ─────────────────────────────────────────────────────────────
async function lookupSite(siteCode: string, state?: string): Promise<string> {
// Exact site code lookup
if (siteCode) {
const res = await dynamo.send(
new GetItemCommand({
TableName: process.env.SITES_TABLE!,
Key: { siteCode: { S: siteCode.toUpperCase() } },
}),
);
if (res.Item) {
const s = unmarshall(res.Item) as Record<string, any>;
const lines: string[] = [];
lines.push(`SITE ${s.siteCode}`);
lines.push(`Address: ${s.address === 'TBD' ? 'TBD (not yet established)' : s.fullAddress || s.address}`);
if (s.city) lines.push(`City: ${s.city}`);
if (s.state) lines.push(`State: ${s.state}`);
if (s.zip) lines.push(`Zip: ${s.zip}`);
if (s.latitude && s.longitude) lines.push(`Coordinates: ${s.latitude}, ${s.longitude}`);
if (s.notes) lines.push(`Notes: ${s.notes}`);
return lines.join('\n');
}
return `No site found for code "${siteCode}". Please provide a valid Amazon site code (e.g., "ABE2", "DFW6").`;
}
// State-based search via GSI
if (state) {
const res = await dynamo.send(
new QueryCommand({
TableName: process.env.SITES_TABLE!,
IndexName: 'by-state',
KeyConditionExpression: '#st = :state',
ExpressionAttributeNames: { '#st': 'state' },
ExpressionAttributeValues: { ':state': { S: state.toUpperCase() } },
}),
);
const sites = (res.Items ?? []).map((item) => unmarshall(item) as Record<string, any>);
if (sites.length === 0) {
return `No sites found in state "${state}".`;
}
sites.sort((a, b) => (a.siteCode ?? '').localeCompare(b.siteCode ?? ''));
const lines = [`Sites in ${state.toUpperCase()} (${sites.length} total):`];
for (const s of sites) {
const addr = s.address === 'TBD' ? 'TBD' : `${s.address}, ${s.city}`;
const note = s.notes ? ` — ${s.notes}` : '';
lines.push(`- ${s.siteCode}: ${addr}${note}`);
}
return lines.join('\n');
}
return 'Please provide a site code (e.g., "ABE2") or a state abbreviation (e.g., "TX") to search.';
}
// ── Payment lookups ─────────────────────────────────────────────────────────
function formatPayment(p: Record<string, any>): string {
const lines: string[] = [];
lines.push(`Check #${p.check_number ?? 'N/A'} — ${p.payee ?? 'Unknown payee'}`);
if (p.amount_usd !== undefined) lines.push(`Amount: $${p.amount_usd}`);
if (p.method) lines.push(`Method: ${p.method}`);
if (p.status) lines.push(`Status: ${p.status}`);
if (p.send_payment_on) lines.push(`Send Date: ${p.send_payment_on}`);
if (p.clear_status) lines.push(`Clear Status: ${p.clear_status}`);
if (p.cleared_date) lines.push(`Cleared Date: ${p.cleared_date}`);
if (p.invoice_numbers) lines.push(`Invoices: ${p.invoice_numbers}`);
if (p.company_subsidiary) lines.push(`Subsidiary: ${p.company_subsidiary}`);
if (p.bank_reference) lines.push(`Bank Ref: ${p.bank_reference}`);
return lines.join('\n');
}
async function scanPayments(): Promise<Record<string, any>[]> {
const items: Record<string, any>[] = [];
let lastKey: Record<string, any> | undefined;
do {
const res = await dynamo.send(
new ScanCommand({
TableName: process.env.PAYMENTS_TABLE!,
FilterExpression: 'begins_with(pk, :prefix)',
ExpressionAttributeValues: { ':prefix': { S: 'payment#' } },
ExclusiveStartKey: lastKey,
}),
);
for (const item of res.Items ?? []) {
items.push(unmarshall(item));
}
lastKey = res.LastEvaluatedKey;
} while (lastKey);
return items;
}
async function lookupPaymentByVendor(vendorName: string): Promise<string> {
const all = await scanPayments();
const needle = vendorName.toLowerCase();
const matches = all.filter((p) => (p.payee ?? '').toLowerCase().includes(needle));
if (matches.length === 0) {
return `No payments found for vendor matching "${vendorName}" in the last 90 days.`;
}
const lines = [`Payments matching "${vendorName}" (${matches.length} found):\n`];
for (const p of matches) {
lines.push(formatPayment(p));
lines.push('');
}
return lines.join('\n');
}
async function lookupPaymentByInvoice(invoiceNumber: string): Promise<string> {
const all = await scanPayments();
const needle = invoiceNumber.toLowerCase();
const matches = all.filter((p) => (p.invoice_numbers ?? '').toLowerCase().includes(needle));
if (matches.length === 0) {
return `No payments found for invoice "${invoiceNumber}" in the last 90 days.`;
}
const lines = [`Payments for invoice "${invoiceNumber}" (${matches.length} found):\n`];
for (const p of matches) {
lines.push(formatPayment(p));
lines.push('');
}
return lines.join('\n');
}
async function lookupPaymentByCheck(checkNumber: string): Promise<string> {
const res = await dynamo.send(
new GetItemCommand({
TableName: process.env.PAYMENTS_TABLE!,
Key: { pk: { S: `payment#${checkNumber}` } },
}),
);
if (!res.Item) {
return `No payment found for check number "${checkNumber}".`;
}
return formatPayment(unmarshall(res.Item));
}
// ── Handler ──────────────────────────────────────────────────────────────────
export const handler = async (event: BedrockActionEvent): Promise<BedrockActionResponse> => {
const params = Object.fromEntries(
(event.parameters ?? []).map((p) => [p.name, p.value]),
);
try {
let result: string;
if (event.function === 'lookup_work_order') {
const woId = params.work_order_id?.trim();
if (!woId) return makeResponse(event, 'Please provide a work order ID.');
result = await lookupWorkOrder(woId);
} else if (event.function === 'lookup_purchase_order') {
const poNum = params.po_number?.trim();
if (!poNum) return makeResponse(event, 'Please provide a purchase order number.');
result = await lookupPurchaseOrder(poNum);
} else if (event.function === 'lookup_site') {
const code = params.site_code?.trim() ?? '';
const state = params.state?.trim();
if (!code && !state) return makeResponse(event, 'Please provide a site code or state abbreviation.');
result = await lookupSite(code, state);
} else if (event.function === 'lookup_payment_by_vendor') {
const name = params.vendor_name?.trim();
if (!name) return makeResponse(event, 'Please provide a vendor or payee name.');
result = await lookupPaymentByVendor(name);
} else if (event.function === 'lookup_payment_by_invoice') {
const inv = params.invoice_number?.trim();
if (!inv) return makeResponse(event, 'Please provide an invoice number.');
result = await lookupPaymentByInvoice(inv);
} else if (event.function === 'lookup_payment_by_check') {
const check = params.check_number?.trim();
if (!check) return makeResponse(event, 'Please provide a check number.');
result = await lookupPaymentByCheck(check);
} else {
result = `Unknown function: ${event.function}`;
}
return makeResponse(event, result);
} catch (err) {
console.error('Lookup error:', err);
return makeResponse(event, `Lookup failed: ${(err as Error).message}`);
}
};