/** * 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; getPurchaseOrder(purchaseOrderId: string): Promise; getSite(siteId: string): Promise; } // --------------------------------------------------------------------------- // 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 { // Store specifiers in variables to prevent TypeScript static module resolution. // eslint-disable-next-line @typescript-eslint/no-explicit-any const dynImport = (s: string): Promise => 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 { // eslint-disable-next-line @typescript-eslint/no-explicit-any const dynImport = (s: string): Promise => 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 { // eslint-disable-next-line @typescript-eslint/no-explicit-any const dynImport = (s: string): Promise => 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 { // eslint-disable-next-line @typescript-eslint/no-explicit-any const dynImport = (s: string): Promise => 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; } }