mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-02 13:03:16 +00:00
177 lines
8 KiB
TypeScript
177 lines
8 KiB
TypeScript
|
|
/**
|
|||
|
|
* DynamoDB client interface + thin implementation.
|
|||
|
|
*
|
|||
|
|
* The interface is what every tool handler receives — callers (including tests)
|
|||
|
|
* inject any object that satisfies it. The real implementation wraps the AWS SDK
|
|||
|
|
* DynamoDB DocumentClient, but the SDK is only instantiated when
|
|||
|
|
* RealDynamoClient.create() is explicitly called; nothing happens at import time
|
|||
|
|
* and no AWS calls are made unless you call a method.
|
|||
|
|
*
|
|||
|
|
* Tables used by this package:
|
|||
|
|
* WorkOrders – work-order records, keyed on `workOrderId` (PK)
|
|||
|
|
* purchase-orders – purchase-order records, keyed on `purchaseOrderId` (PK)
|
|||
|
|
* SiteAssignments – site records, keyed on `siteId` (PK)
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// DynamoDB record shapes returned from each table
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
export interface WorkOrderRecord {
|
|||
|
|
workOrderId: string;
|
|||
|
|
title: string;
|
|||
|
|
status: string;
|
|||
|
|
siteId?: string;
|
|||
|
|
assignedTo?: string;
|
|||
|
|
createdAt: string;
|
|||
|
|
updatedAt: string;
|
|||
|
|
description?: string;
|
|||
|
|
[key: string]: unknown;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface PurchaseOrderRecord {
|
|||
|
|
purchaseOrderId: string;
|
|||
|
|
vendor: string;
|
|||
|
|
status: string;
|
|||
|
|
totalAmount?: number;
|
|||
|
|
currency?: string;
|
|||
|
|
issuedAt: string;
|
|||
|
|
updatedAt: string;
|
|||
|
|
lineItems?: Array<{ description: string; quantity: number; unitPrice: number }>;
|
|||
|
|
[key: string]: unknown;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface SiteRecord {
|
|||
|
|
siteId: string;
|
|||
|
|
name: string;
|
|||
|
|
address?: string;
|
|||
|
|
region?: string;
|
|||
|
|
status: string;
|
|||
|
|
assignedTechnicians?: string[];
|
|||
|
|
[key: string]: unknown;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Client interface — inject this everywhere; never import the AWS SDK directly
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
export interface InternalDataClient {
|
|||
|
|
getWorkOrder(workOrderId: string): Promise<WorkOrderRecord | null>;
|
|||
|
|
getPurchaseOrder(purchaseOrderId: string): Promise<PurchaseOrderRecord | null>;
|
|||
|
|
getSite(siteId: string): Promise<SiteRecord | null>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Real (AWS SDK-backed) implementation
|
|||
|
|
//
|
|||
|
|
// The AWS SDK import lives here — behind this class — so that:
|
|||
|
|
// a) Nothing happens at module load time (no credential resolution, no env reads).
|
|||
|
|
// b) Tests never reach this code; they inject a mock that satisfies the interface.
|
|||
|
|
//
|
|||
|
|
// TODO (DEFERRED auth layer): When the Gateway layer is built, the Lambda execution
|
|||
|
|
// role will supply credentials via the standard AWS environment variables. At that
|
|||
|
|
// point ensure the DocumentClient is constructed with the correct region and that
|
|||
|
|
// the table names are injected via environment variables (WORK_ORDERS_TABLE,
|
|||
|
|
// PURCHASE_ORDERS_TABLE, SITE_ASSIGNMENTS_TABLE) rather than hard-coded.
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
export class RealDynamoClient implements InternalDataClient {
|
|||
|
|
// Table names — override via environment variables at Lambda deploy time.
|
|||
|
|
private readonly workOrdersTable: string;
|
|||
|
|
private readonly purchaseOrdersTable: string;
|
|||
|
|
private readonly siteAssignmentsTable: string;
|
|||
|
|
|
|||
|
|
// The DocumentClient is typed as `unknown` here to avoid importing the AWS SDK
|
|||
|
|
// at module scope. It is cast when needed inside each method.
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
private readonly ddb: any;
|
|||
|
|
|
|||
|
|
private constructor(
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
ddb: any,
|
|||
|
|
workOrdersTable: string,
|
|||
|
|
purchaseOrdersTable: string,
|
|||
|
|
siteAssignmentsTable: string,
|
|||
|
|
) {
|
|||
|
|
this.ddb = ddb;
|
|||
|
|
this.workOrdersTable = workOrdersTable;
|
|||
|
|
this.purchaseOrdersTable = purchaseOrdersTable;
|
|||
|
|
this.siteAssignmentsTable = siteAssignmentsTable;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Factory — the only place the AWS SDK DocumentClient is instantiated.
|
|||
|
|
* Calling this from a Lambda handler (not at module scope) is the correct pattern.
|
|||
|
|
*
|
|||
|
|
* NOTE: @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb are intentionally absent
|
|||
|
|
* from package.json until the Lambda runtime bundle is assembled (see TODO above).
|
|||
|
|
* The module specifiers are stored in runtime variables so TypeScript does not attempt
|
|||
|
|
* static module-resolution at build time.
|
|||
|
|
*/
|
|||
|
|
static async create(): Promise<RealDynamoClient> {
|
|||
|
|
// Store specifiers in variables to prevent TypeScript static module resolution.
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
|
|||
|
|
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
const { DynamoDBClient } = (await dynImport('@aws-sdk/client-dynamodb')) as { DynamoDBClient: new (cfg: { region: string }) => any };
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
const { DynamoDBDocumentClient } = (await dynImport('@aws-sdk/lib-dynamodb')) as { DynamoDBDocumentClient: { from: (c: any) => any } };
|
|||
|
|
|
|||
|
|
const region = process.env['AWS_REGION'] ?? 'us-east-1';
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
|
|||
|
|
const raw = new DynamoDBClient({ region });
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
|
|||
|
|
const ddb = DynamoDBDocumentClient.from(raw);
|
|||
|
|
|
|||
|
|
return new RealDynamoClient(
|
|||
|
|
ddb,
|
|||
|
|
process.env['WORK_ORDERS_TABLE'] ?? 'WorkOrders',
|
|||
|
|
process.env['PURCHASE_ORDERS_TABLE'] ?? 'purchase-orders',
|
|||
|
|
process.env['SITE_ASSIGNMENTS_TABLE'] ?? 'SiteAssignments',
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async getWorkOrder(workOrderId: string): Promise<WorkOrderRecord | null> {
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { GetCommand: new (i: any) => any };
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
|
|||
|
|
const result = await this.ddb.send(
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|||
|
|
new GetCommand({ TableName: this.workOrdersTable, Key: { workOrderId } }),
|
|||
|
|
);
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|||
|
|
return (result.Item as WorkOrderRecord) ?? null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async getPurchaseOrder(purchaseOrderId: string): Promise<PurchaseOrderRecord | null> {
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { GetCommand: new (i: any) => any };
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
|
|||
|
|
const result = await this.ddb.send(
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|||
|
|
new GetCommand({ TableName: this.purchaseOrdersTable, Key: { purchaseOrderId } }),
|
|||
|
|
);
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|||
|
|
return (result.Item as PurchaseOrderRecord) ?? null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async getSite(siteId: string): Promise<SiteRecord | null> {
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { GetCommand: new (i: any) => any };
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
|
|||
|
|
const result = await this.ddb.send(
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|||
|
|
new GetCommand({ TableName: this.siteAssignmentsTable, Key: { siteId } }),
|
|||
|
|
);
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|||
|
|
return (result.Item as SiteRecord) ?? null;
|
|||
|
|
}
|
|||
|
|
}
|