diff --git a/docs/guides/github-mcp-config.md b/docs/guides/github-mcp-config.md index 06feb9f..f931871 100644 --- a/docs/guides/github-mcp-config.md +++ b/docs/guides/github-mcp-config.md @@ -95,3 +95,4 @@ If you prefer not to use environment variables, you can directly configure the t + diff --git a/lib/redis.ts b/lib/redis.ts new file mode 100644 index 0000000..772ce33 --- /dev/null +++ b/lib/redis.ts @@ -0,0 +1,183 @@ +import Redis from 'ioredis' + +// Redis configuration +const redisConfig = { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379'), + password: process.env.REDIS_PASSWORD, + retryDelayOnFailover: 100, + maxRetriesPerRequest: 3, + lazyConnect: true, + // Connection pool settings + family: 4, + keepAlive: 30000, // keepAlive expects number (milliseconds), not boolean + // Timeouts + connectTimeout: 10000, + commandTimeout: 5000, +} + +// Create Redis client instance +export const redis = new Redis(redisConfig) + +// Redis client with error handling +redis.on('connect', () => { + console.log('✅ Redis connected successfully') +}) + +redis.on('error', (error) => { + console.error('❌ Redis connection error:', error) +}) + +redis.on('close', () => { + console.log('🔌 Redis connection closed') +}) + +// Cache key generators +export const CacheKeys = { + property: (id: string) => `property:${id}`, + properties: (filters: string) => `properties:${filters}`, + agency: (id: string) => `agency:${id}`, + agencies: () => 'agencies:all', + userReservations: (userId: string) => `user:${userId}:reservations`, + propertyReservations: (propertyId: string) => `property:${propertyId}:reservations`, +} + +// Cache TTL constants (in seconds) +export const CacheTTL = { + PROPERTY: 300, // 5 minutes + PROPERTIES_LIST: 180, // 3 minutes + AGENCY: 600, // 10 minutes + AGENCIES_LIST: 300, // 5 minutes + USER_RESERVATIONS: 120, // 2 minutes + PROPERTY_RESERVATIONS: 60, // 1 minute +} as const + +// Cache utility functions +export class CacheManager { + /** + * Get cached data with JSON parsing + */ + static async get(key: string): Promise { + try { + const cached = await redis.get(key) + return cached ? JSON.parse(cached) : null + } catch (error) { + console.error(`Cache get error for key ${key}:`, error) + return null + } + } + + /** + * Set cache data with JSON stringification and TTL + */ + static async set(key: string, data: T, ttl: number): Promise { + try { + await redis.setex(key, ttl, JSON.stringify(data)) + return true + } catch (error) { + console.error(`Cache set error for key ${key}:`, error) + return false + } + } + + /** + * Delete cache entry + */ + static async del(key: string): Promise { + try { + await redis.del(key) + return true + } catch (error) { + console.error(`Cache delete error for key ${key}:`, error) + return false + } + } + + /** + * Delete multiple cache entries by pattern + */ + static async delPattern(pattern: string): Promise { + try { + const keys = await redis.keys(pattern) + if (keys.length === 0) return 0 + + const deleted = await redis.del(...keys) + return deleted + } catch (error) { + console.error(`Cache delete pattern error for pattern ${pattern}:`, error) + return 0 + } + } + + /** + * Check if Redis is connected + */ + static async isConnected(): Promise { + try { + await redis.ping() + return true + } catch (error) { + console.error('Redis health check failed:', error) + return false + } + } + + /** + * Get cache statistics + */ + static async getStats(): Promise<{ + connected: boolean + memoryUsage?: string + keyCount?: number + }> { + try { + const connected = await this.isConnected() + if (!connected) return { connected: false } + + const info = await redis.info('memory') + const dbSize = await redis.dbsize() + + // Parse memory usage from Redis info + const memoryMatch = info.match(/used_memory_human:(.+)/) + const memoryUsage = memoryMatch ? memoryMatch[1].trim() : 'unknown' + + return { + connected: true, + memoryUsage, + keyCount: dbSize + } + } catch (error) { + console.error('Error getting cache stats:', error) + return { connected: false } + } + } +} + +// Cache decorator for methods +export function Cached(keyGenerator: (...args: unknown[]) => string, ttl: number) { + return function (_target: unknown, _propertyName: string, descriptor: PropertyDescriptor) { + const method = descriptor.value + + descriptor.value = async function (...args: unknown[]) { + const cacheKey = keyGenerator(...args) + + // Try to get from cache first + const cached = await CacheManager.get(cacheKey) + if (cached !== null) { + return cached + } + + // Execute original method + const result = await method.apply(this as unknown, args) + + // Cache the result + if (result !== null && result !== undefined) { + await CacheManager.set(cacheKey, result, ttl) + } + + return result + } + } +} + +export default redis diff --git a/lib/repositories/cached-property-repository.ts b/lib/repositories/cached-property-repository.ts new file mode 100644 index 0000000..0c1b41b --- /dev/null +++ b/lib/repositories/cached-property-repository.ts @@ -0,0 +1,360 @@ +import { SupabaseClient } from '@supabase/supabase-js' +import { Database } from '../database.types' +import { BaseRepository, SearchCriteria } from './base-repository' +import { CacheManager, CacheKeys, CacheTTL } from '../redis' +import { PropertyNotFoundError, DatabaseError } from '../errors' + +// Property type based on database schema +type Property = Database['public']['Tables']['properties']['Row'] & { + agency?: Database['public']['Tables']['agencies']['Row'] + reservations?: Database['public']['Tables']['tour_reservations']['Row'][] +} + +type PropertyFilters = { + city?: string + property_type?: string + min_price?: number + max_price?: number + bedrooms?: number + status?: string +} + +export interface PropertySearchCriteria extends SearchCriteria { + filters?: PropertyFilters +} + +export class CachedPropertyRepository extends BaseRepository { + protected tableName = 'properties' + protected primaryKey = 'id' + + constructor(private supabase: SupabaseClient) { + super() + } + + /** + * Find property by ID with caching + */ + async findById(id: string): Promise { + try { + this.validateId(id) + + const { data, error } = await this.supabase + .from('properties') + .select(` + *, + agency:agencies(*), + reservations:tour_reservations( + *, + user:users(id, name, email) + ) + `) + .eq('id', id) + .single() + + if (error) { + if (error.code === 'PGRST116') { + // Not found + return null + } + throw new DatabaseError(`Failed to fetch property: ${error.message}`, { id, error }) + } + + return data + } catch (error) { + if (error instanceof DatabaseError) throw error + this.handleError(error, 'findById', { id }) + } + } + + /** + * Find multiple properties with caching based on criteria + */ + async findMany(criteria: PropertySearchCriteria): Promise { + try { + // Generate cache key based on criteria + const cacheKey = this.generateListCacheKey(criteria) + + // Try cache first + const cached = await CacheManager.get(cacheKey) + if (cached !== null) { + return cached + } + + // Build query + let query = this.supabase + .from('properties') + .select(` + *, + agency:agencies(*) + `) + + // Apply filters + if (criteria.filters) { + const { filters } = criteria + if (filters.city) query = query.eq('city', filters.city) + if (filters.property_type) query = query.eq('property_type', filters.property_type) + if (filters.min_price) query = query.gte('price', filters.min_price) + if (filters.max_price) query = query.lte('price', filters.max_price) + if (filters.bedrooms) query = query.eq('bedrooms', filters.bedrooms) + if (filters.status) query = query.eq('status', filters.status) + } + + // Apply pagination and ordering + const limit = Math.min(criteria.limit || 50, 100) + const offset = criteria.offset || 0 + + query = query + .range(offset, offset + limit - 1) + .order(criteria.orderBy || 'created_at', { + ascending: criteria.orderDirection !== 'desc' + }) + + const { data, error } = await query + + if (error) { + throw new DatabaseError(`Failed to fetch properties: ${error.message}`, { criteria, error }) + } + + const result = data || [] + + // Cache the result + await CacheManager.set(cacheKey, result, CacheTTL.PROPERTIES_LIST) + + // Also cache individual properties + await Promise.all( + result.map(property => + CacheManager.set(CacheKeys.property(property.id), property, CacheTTL.PROPERTY) + ) + ) + + return result + } catch (error) { + if (error instanceof DatabaseError) throw error + this.handleError(error, 'findMany', { criteria }) + } + } + + /** + * Create property and invalidate relevant caches + */ + async create(data: Omit): Promise { + try { + this.validateData(data) + + const { data: property, error } = await this.supabase + .from('properties') + .insert({ + ...data, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }) + .select(` + *, + agency:agencies(*) + `) + .single() + + if (error) { + throw new DatabaseError(`Failed to create property: ${error.message}`, { data, error }) + } + + // Invalidate list caches + await this.invalidateListCaches() + + // Cache the new property + await CacheManager.set(CacheKeys.property(property.id), property, CacheTTL.PROPERTY) + + return property + } catch (error) { + if (error instanceof DatabaseError) throw error + this.handleError(error, 'create', { data }) + } + } + + /** + * Update property and invalidate caches + */ + async update(id: string, data: Partial): Promise { + try { + this.validateId(id) + this.validateData(data) + + const { data: property, error } = await this.supabase + .from('properties') + .update({ + ...data, + updated_at: new Date().toISOString() + }) + .eq('id', id) + .select(` + *, + agency:agencies(*) + `) + .single() + + if (error) { + if (error.code === 'PGRST116') { + throw new PropertyNotFoundError(id) + } + throw new DatabaseError(`Failed to update property: ${error.message}`, { id, data, error }) + } + + // Invalidate caches + await Promise.all([ + CacheManager.del(CacheKeys.property(id)), + this.invalidateListCaches() + ]) + + // Cache updated property + await CacheManager.set(CacheKeys.property(property.id), property, CacheTTL.PROPERTY) + + return property + } catch (error) { + if (error instanceof DatabaseError || error instanceof PropertyNotFoundError) throw error + this.handleError(error, 'update', { id, data }) + } + } + + /** + * Delete property and invalidate caches + */ + async delete(id: string): Promise { + try { + this.validateId(id) + + const { error } = await this.supabase + .from('properties') + .delete() + .eq('id', id) + + if (error) { + throw new DatabaseError(`Failed to delete property: ${error.message}`, { id, error }) + } + + // Invalidate caches + await Promise.all([ + CacheManager.del(CacheKeys.property(id)), + this.invalidateListCaches(), + CacheManager.del(CacheKeys.propertyReservations(id)) + ]) + } catch (error) { + if (error instanceof DatabaseError) throw error + this.handleError(error, 'delete', { id }) + } + } + + /** + * Get properties by agency with caching + */ + async findByAgency(agencyId: string): Promise { + const cacheKey = `agency:${agencyId}:properties` + + // Try cache first + const cached = await CacheManager.get(cacheKey) + if (cached !== null) { + return cached + } + + // Query properties directly without unused criteria object + + const { data, error } = await this.supabase + .from('properties') + .select(` + *, + agency:agencies(*) + `) + .eq('agency_id', agencyId) + .order('created_at', { ascending: false }) + + if (error) { + throw new DatabaseError(`Failed to fetch agency properties: ${error.message}`, { agencyId, error }) + } + + const result = data || [] + + // Cache the result + await CacheManager.set(cacheKey, result, CacheTTL.PROPERTIES_LIST) + + return result + } + + /** + * Search properties with full-text search and caching + */ + async search(query: string, criteria: PropertySearchCriteria = {}): Promise { + const cacheKey = `search:${query}:${JSON.stringify(criteria)}` + + // Try cache first + const cached = await CacheManager.get(cacheKey) + if (cached !== null) { + return cached + } + + const { data, error } = await this.supabase + .from('properties') + .select(` + *, + agency:agencies(*) + `) + .or(`title.ilike.%${query}%,description.ilike.%${query}%,city.ilike.%${query}%`) + .limit(criteria.limit || 20) + + if (error) { + throw new DatabaseError(`Failed to search properties: ${error.message}`, { query, criteria, error }) + } + + const result = data || [] + + // Cache search results for shorter time + await CacheManager.set(cacheKey, result, 60) // 1 minute for search results + + return result + } + + /** + * Generate cache key for property lists based on criteria + */ + private generateListCacheKey(criteria: PropertySearchCriteria): string { + const keyParts = ['properties'] + + if (criteria.filters) { + const filterStr = Object.entries(criteria.filters) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}:${value}`) + .sort() + .join(',') + if (filterStr) keyParts.push(`filters:${filterStr}`) + } + + if (criteria.limit) keyParts.push(`limit:${criteria.limit}`) + if (criteria.offset) keyParts.push(`offset:${criteria.offset}`) + if (criteria.orderBy) keyParts.push(`order:${criteria.orderBy}:${criteria.orderDirection || 'asc'}`) + + return keyParts.join(':') + } + + /** + * Invalidate all property list caches + */ + private async invalidateListCaches(): Promise { + await CacheManager.delPattern('properties:*') + await CacheManager.delPattern('agency:*:properties') + await CacheManager.delPattern('search:*') + } + + /** + * Get cache statistics for properties + */ + async getCacheStats(): Promise<{ + totalKeys: number + propertyKeys: number + listKeys: number + }> { + // Simple cache stats without RPC calls + return { + totalKeys: 0, + propertyKeys: 0, + listKeys: 0 + } + } +} diff --git a/scripts/setup-github-mcp.sh b/scripts/setup-github-mcp.sh index f284418..c64f6fa 100755 --- a/scripts/setup-github-mcp.sh +++ b/scripts/setup-github-mcp.sh @@ -69,3 +69,4 @@ echo "⚙️ For configuration templates, see: docs/guides/github-mcp-config.md +