Skip to main content
Glama
postgres.ts11.1 kB
/** * PostgreSQL Storage Service * * Implementation of IStorageService using PostgreSQL with Prisma ORM. * Supports vector search for semantic similarity matching. */ import { PrismaClient, Prisma } from '@prisma/client'; import { IStorageService, QuoteSearchCriteria, QuoteWithSimilarity, AuditLogEntry } from './interface'; import { HistoricalQuote, RFPEvaluation } from '../types'; export class PostgresStorageService implements IStorageService { private prisma: PrismaClient; private static instance: PostgresStorageService; private constructor() { this.prisma = new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], }); } public static getInstance(): PostgresStorageService { if (!PostgresStorageService.instance) { PostgresStorageService.instance = new PostgresStorageService(); } return PostgresStorageService.instance; } // ===== QUOTES ===== async getAllQuotes(): Promise<HistoricalQuote[]> { const quotes = await this.prisma.quote.findMany({ orderBy: { quoteDate: 'desc' } }); return quotes.map(this.mapQuoteFromDb); } async getQuoteById(id: string): Promise<HistoricalQuote | null> { const quote = await this.prisma.quote.findUnique({ where: { id } }); return quote ? this.mapQuoteFromDb(quote) : null; } async addQuote(quote: HistoricalQuote): Promise<void> { await this.prisma.quote.create({ data: this.mapQuoteToDb(quote) }); } async updateQuote(id: string, quote: Partial<HistoricalQuote>): Promise<void> { await this.prisma.quote.update({ where: { id }, data: this.mapQuoteToDb(quote as HistoricalQuote) }); } async deleteQuote(id: string): Promise<void> { await this.prisma.quote.delete({ where: { id } }); } async searchQuotes(criteria: QuoteSearchCriteria): Promise<HistoricalQuote[]> { const where: Prisma.QuoteWhereInput = {}; if (criteria.material) { where.material = { contains: criteria.material, mode: 'insensitive' }; } if (criteria.processes && criteria.processes.length > 0) { where.processes = { hasSome: criteria.processes }; } if (criteria.qtyMin !== undefined) { where.qtyMin = { gte: criteria.qtyMin }; } if (criteria.qtyMax !== undefined) { where.qtyMax = { lte: criteria.qtyMax }; } if (criteria.customerName) { where.customerName = { contains: criteria.customerName, mode: 'insensitive' }; } if (criteria.dateFrom || criteria.dateTo) { where.quoteDate = {}; if (criteria.dateFrom) where.quoteDate.gte = criteria.dateFrom; if (criteria.dateTo) where.quoteDate.lte = criteria.dateTo; } if (criteria.approved !== undefined) { where.approved = criteria.approved; } const quotes = await this.prisma.quote.findMany({ where, orderBy: { quoteDate: 'desc' } }); return quotes.map(this.mapQuoteFromDb); } // ===== VECTOR SEARCH ===== async searchQuotesByVector(embedding: number[], limit: number = 5): Promise<QuoteWithSimilarity[]> { // Using pgvector extension for cosine similarity // Raw SQL query because Prisma doesn't fully support vector operations yet const embeddingStr = `[${embedding.join(',')}]`; const result = await this.prisma.$queryRaw<any[]>` SELECT id, quote_date, customer_name, contact_email, material, processes, qty_min, qty_max, tolerances, finish, cost_per_unit, total_cost, lead_days, actual_lead_days, approved, won, notes, raw_rfp, created_at, updated_at, created_by, 1 - (embedding <=> ${embeddingStr}::vector) as similarity FROM quotes WHERE embedding IS NOT NULL ORDER BY embedding <=> ${embeddingStr}::vector LIMIT ${limit} `; return result.map(row => ({ ...this.mapQuoteFromDbRaw(row), similarity: parseFloat(row.similarity) || 0 })); } async updateQuoteEmbedding(id: string, embedding: number[]): Promise<void> { const embeddingStr = `[${embedding.join(',')}]`; await this.prisma.$executeRaw` UPDATE quotes SET embedding = ${embeddingStr}::vector WHERE id = ${id} `; } // ===== EVALUATIONS ===== async getAllEvaluations(): Promise<RFPEvaluation[]> { const evaluations = await this.prisma.evaluation.findMany({ orderBy: { createdAt: 'desc' }, take: 100 // Limit to recent 100 }); return evaluations.map(this.mapEvaluationFromDb); } async getEvaluationById(id: string): Promise<RFPEvaluation | null> { const evaluation = await this.prisma.evaluation.findUnique({ where: { id } }); return evaluation ? this.mapEvaluationFromDb(evaluation) : null; } async getEvaluationByIdempotencyKey(key: string): Promise<RFPEvaluation | null> { const evaluation = await this.prisma.evaluation.findUnique({ where: { idempotencyKey: key } }); return evaluation ? this.mapEvaluationFromDb(evaluation) : null; } async addEvaluation(evaluation: RFPEvaluation): Promise<void> { await this.prisma.evaluation.create({ data: this.mapEvaluationToDb(evaluation) }); } async updateEvaluation(id: string, evaluation: Partial<RFPEvaluation>): Promise<void> { const updateData: any = {}; if (evaluation.status) updateData.status = evaluation.status; if (evaluation.approvedBy) updateData.approvedBy = evaluation.approvedBy; if (evaluation.approvedAt) updateData.approvedAt = evaluation.approvedAt; if (evaluation.sentAt) updateData.sentAt = evaluation.sentAt; if (evaluation.parsedRfp) updateData.parsedRfp = evaluation.parsedRfp; if (evaluation.matches) updateData.matches = evaluation.matches; if (evaluation.estimate) updateData.estimate = evaluation.estimate; if (evaluation.quoteDoc) updateData.quoteDoc = evaluation.quoteDoc; if (evaluation.confidence) updateData.confidence = evaluation.confidence; await this.prisma.evaluation.update({ where: { id }, data: updateData }); } // ===== AUDIT LOGGING ===== async logAudit(entry: AuditLogEntry): Promise<void> { await this.prisma.auditLog.create({ data: { action: entry.action, resourceType: entry.resourceType, resourceId: entry.resourceId, details: entry.details, userId: entry.userId, ipAddress: entry.ipAddress, userAgent: entry.userAgent } }); } // ===== HEALTH CHECK ===== async healthCheck(): Promise<boolean> { try { await this.prisma.$queryRaw`SELECT 1`; return true; } catch (error) { console.error('Database health check failed:', error); return false; } } // ===== CLEANUP ===== async disconnect(): Promise<void> { await this.prisma.$disconnect(); } // ===== MAPPING FUNCTIONS ===== private mapQuoteFromDb(dbQuote: any): HistoricalQuote { return { id: dbQuote.id, quoteDate: dbQuote.quoteDate.toISOString(), customerName: dbQuote.customerName, contactEmail: dbQuote.contactEmail || undefined, normalized: { material: dbQuote.material || undefined, processes: dbQuote.processes || [], qtyRange: [dbQuote.qtyMin || 0, dbQuote.qtyMax || 0], tolerances: dbQuote.tolerances || undefined, finish: dbQuote.finish || undefined }, costPerUnit: parseFloat(dbQuote.costPerUnit.toString()), totalCost: parseFloat(dbQuote.totalCost.toString()), leadDays: dbQuote.leadDays, actualLeadDays: dbQuote.actualLeadDays || undefined, approved: dbQuote.approved, won: dbQuote.won || undefined, notes: dbQuote.notes || undefined, rawRfp: dbQuote.rawRfp || undefined }; } private mapQuoteFromDbRaw(row: any): HistoricalQuote { return { id: row.id, quoteDate: row.quote_date.toISOString(), customerName: row.customer_name, contactEmail: row.contact_email || undefined, normalized: { material: row.material || undefined, processes: row.processes || [], qtyRange: [row.qty_min || 0, row.qty_max || 0], tolerances: row.tolerances || undefined, finish: row.finish || undefined }, costPerUnit: parseFloat(row.cost_per_unit), totalCost: parseFloat(row.total_cost), leadDays: row.lead_days, actualLeadDays: row.actual_lead_days || undefined, approved: row.approved, won: row.won || undefined, notes: row.notes || undefined, rawRfp: row.raw_rfp || undefined }; } private mapQuoteToDb(quote: HistoricalQuote): any { return { id: quote.id, quoteDate: new Date(quote.quoteDate), customerName: quote.customerName, contactEmail: quote.contactEmail, material: quote.normalized.material, processes: quote.normalized.processes || [], qtyMin: quote.normalized.qtyRange?.[0], qtyMax: quote.normalized.qtyRange?.[1], tolerances: quote.normalized.tolerances, finish: quote.normalized.finish, costPerUnit: new Prisma.Decimal(quote.costPerUnit), totalCost: new Prisma.Decimal(quote.totalCost), leadDays: quote.leadDays, actualLeadDays: quote.actualLeadDays, approved: quote.approved, won: quote.won, notes: quote.notes, rawRfp: quote.rawRfp }; } private mapEvaluationFromDb(dbEval: any): RFPEvaluation { return { id: dbEval.id, idempotencyKey: dbEval.idempotencyKey, rawText: dbEval.rawText, qty: dbEval.qty || undefined, contactEmail: dbEval.contactEmail || undefined, customerName: dbEval.customerName || undefined, parsedRfp: dbEval.parsedRfp as any, matches: dbEval.matches as any, estimate: dbEval.estimate as any, quoteDoc: dbEval.quoteDoc as any, confidence: dbEval.confidence as any, status: dbEval.status, approvedBy: dbEval.approvedBy || undefined, approvedAt: dbEval.approvedAt?.toISOString(), sentAt: dbEval.sentAt?.toISOString(), createdAt: dbEval.createdAt.toISOString(), createdBy: dbEval.createdBy || undefined }; } private mapEvaluationToDb(evaluation: RFPEvaluation): any { return { id: evaluation.id, idempotencyKey: evaluation.idempotencyKey, rawText: evaluation.rawText, qty: evaluation.qty, contactEmail: evaluation.contactEmail, customerName: evaluation.customerName, parsedRfp: evaluation.parsedRfp, matches: evaluation.matches, estimate: evaluation.estimate, quoteDoc: evaluation.quoteDoc, confidence: evaluation.confidence, status: evaluation.status || 'draft', approvedBy: evaluation.approvedBy, approvedAt: evaluation.approvedAt ? new Date(evaluation.approvedAt) : undefined, sentAt: evaluation.sentAt ? new Date(evaluation.sentAt) : undefined, createdBy: evaluation.createdBy }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/r-long/mcp-quoting-system'

If you have feedback or need assistance with the MCP directory API, please join our Discord server