# Harborsmith Platform Architecture Documentation ## Executive Summary Harborsmith is a comprehensive yacht charter and maintenance management platform designed for the San Francisco Bay Area market. This document outlines the complete technical architecture, implementation strategy, and operational guidelines for building a scalable, beautiful, and performant system that serves yacht owners, charter customers, and administrative staff. ### Platform Vision - **Beautiful**: Nautical-themed UI with smooth animations and premium feel - **Scalable**: Multi-region ready with horizontal scaling capabilities - **Fast**: Sub-second page loads with optimized media delivery - **Responsive**: Mobile-first design supporting all device types - **Enterprise-grade**: RBAC, audit logging, compliance-ready ### Core Components 1. **Public Website**: Marketing and discovery platform (SSG) 2. **Customer Web App**: Booking and charter management (SPA) 3. **Admin Portal**: Operations and fleet management (SSR/SPA hybrid) ## Table of Contents 1. [System Architecture Overview](#system-architecture-overview) 2. [Technology Stack](#technology-stack) 3. [Frontend Architecture](#frontend-architecture) 4. [Backend Architecture](#backend-architecture) 5. [Database Design](#database-design) 6. [Media Handling System](#media-handling-system) 7. [Authentication & Authorization](#authentication--authorization) 8. [Third-Party Integrations](#third-party-integrations) 9. [Real-Time Communication](#real-time-communication) 10. [Deployment Strategy](#deployment-strategy) 11. [Security Architecture](#security-architecture) 12. [Performance Optimization](#performance-optimization) 13. [Monitoring & Observability](#monitoring--observability) 14. [Development Workflow](#development-workflow) 15. [Implementation Roadmap](#implementation-roadmap) ## System Architecture Overview ### High-Level Architecture ```mermaid graph TB subgraph "Client Layer" PW[Public WebsiteNuxt SSG] CWA[Customer WebAppNuxt SPA] AP[Admin PortalNuxt SSR/SPA] end subgraph "API Gateway" TR[TraefikLoad Balancer] CACHE[Redis Cache] end subgraph "Application Layer" API[Fastify + tRPC API] WS[Socket.io Server] MEDIA[Media ServiceTus + FFmpeg] end subgraph "Data Layer" PG[(PostgreSQLPrimary DB)] REDIS[(RedisCache & Sessions)] MINIO[MinIOObject Storage] end subgraph "External Services" KC[KeycloakIdentity] CAL[Cal.comScheduling] STRIPE[StripePayments] DIR[DirectusCMS] end PW --> TR CWA --> TR AP --> TR TR --> API TR --> WS TR --> MEDIA API --> PG API --> REDIS API --> MINIO API --> KC API --> CAL API --> STRIPE API --> DIR ``` ### Monorepo Structure ``` harborsmith/ ├── apps/ │ ├── website/ # Public marketing site (Nuxt SSG) │ ├── webapp/ # Customer application (Nuxt SPA) │ ├── portal/ # Admin portal (Nuxt SSR/SPA) │ └── api/ # Backend API (Fastify + tRPC) ├── packages/ │ ├── shared/ # Shared types, utils, constants │ ├── ui/ # Shared UI components library │ ├── auth/ # Auth utilities and guards │ ├── media/ # Media handling utilities │ └── database/ # Prisma schema and migrations ├── infrastructure/ │ ├── docker/ # Docker configurations │ ├── k8s/ # Kubernetes manifests (future) │ └── terraform/ # Infrastructure as code (future) ├── docs/ │ ├── api/ # API documentation │ ├── guides/ # Implementation guides │ └── decisions/ # Architecture decision records ├── tools/ │ ├── scripts/ # Build and deployment scripts │ └── generators/ # Code generators ├── docker-compose.yml ├── turbo.json # Turborepo configuration ├── package.json └── tsconfig.json ``` ## Technology Stack ### Frontend Stack | Layer | Technology | Purpose | Justification | |-------|-----------|---------|---------------| | **Framework** | Nuxt 3.15+ | Universal Vue framework | SSG/SSR/SPA flexibility, excellent DX | | **UI Library** | Nuxt UI v3 | Component library | Built for Nuxt 3, fully typed, customizable | | **CSS Framework** | Tailwind CSS v4 | Utility-first CSS | Fast development, consistent design | | **State Management** | Pinia | Vue state management | Type-safe, devtools support | | **Animations** | Motion.dev | Animation library | Smooth, performant animations | | **Charts** | Tremor | Dashboard components | Beautiful analytics components | | **Forms** | VeeValidate + Zod | Form validation | Type-safe validation | | **Icons** | Iconify | Icon system | Massive icon library | | **Utilities** | VueUse | Composition utilities | Essential Vue composables | | **Media Upload** | Uppy | File upload | 10GB+ support, resumable | | **Video Player** | hls.js | Video streaming | HLS adaptive streaming | ### Backend Stack | Layer | Technology | Purpose | Justification | |-------|-----------|---------|---------------| | **Runtime** | Node.js 20+ | JavaScript runtime | LTS, performance improvements | | **Framework** | Fastify | Web framework | High performance, plugin ecosystem | | **API Layer** | tRPC | Type-safe APIs | End-to-end type safety | | **ORM** | Prisma | Database toolkit | Type-safe queries, migrations | | **Validation** | Zod | Schema validation | Runtime + compile-time safety | | **Queue** | BullMQ | Job queue | Reliable background jobs | | **WebSocket** | Socket.io | Real-time | Fallback support, rooms | | **Cache** | Redis | Caching layer | Performance, sessions | | **Storage** | MinIO | Object storage | S3-compatible, on-premise | | **Media** | FFmpeg | Media processing | Video transcoding, HLS | | **Upload** | Tus Server | Resumable uploads | Large file support | ### Infrastructure Stack | Component | Technology | Purpose | Configuration | |-----------|-----------|---------|---------------| | **Container** | Docker | Containerization | Multi-stage builds | | **Orchestration** | Docker Compose | Local development | Hot reload support | | **Proxy** | Traefik | Reverse proxy | Auto SSL, load balancing | | **Database** | PostgreSQL 16 | Primary database | JSONB, full-text search | | **Cache** | Redis 7 | Caching & sessions | Persistence enabled | | **Storage** | MinIO | Object storage | Multi-tenant buckets | | **Identity** | Keycloak | Authentication | OIDC/OAuth2 | | **Monitoring** | Glitchtip | Error tracking | Sentry-compatible | | **Email** | Poste.io | Email server | SMTP/IMAP | ## Frontend Architecture ### Component Architecture #### Design System Foundation ```typescript // packages/ui/tokens/design-tokens.ts export const designTokens = { colors: { // Nautical Theme ocean: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', // Primary 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', 950: '#083344', }, sail: { 50: '#fefce8', 100: '#fef9c3', 200: '#fef088', 300: '#fde047', 400: '#facc15', 500: '#eab308', // Accent 600: '#ca8a04', 700: '#a16207', 800: '#854d0e', 900: '#713f12', }, harbor: { 50: '#f9fafb', 100: '#f3f4f6', 200: '#e5e7eb', 300: '#d1d5db', 400: '#9ca3af', 500: '#6b7280', // Neutral 600: '#4b5563', 700: '#374151', 800: '#1f2937', 900: '#111827', 950: '#030712', } }, spacing: { xs: '0.5rem', sm: '0.75rem', md: '1rem', lg: '1.5rem', xl: '2rem', '2xl': '3rem', '3xl': '4rem', }, typography: { fonts: { heading: 'Cal Sans, system-ui, sans-serif', body: 'Inter, system-ui, sans-serif', mono: 'JetBrains Mono, monospace', }, sizes: { xs: '0.75rem', sm: '0.875rem', base: '1rem', lg: '1.125rem', xl: '1.25rem', '2xl': '1.5rem', '3xl': '1.875rem', '4xl': '2.25rem', '5xl': '3rem', } }, animation: { timing: { instant: '100ms', fast: '200ms', normal: '300ms', slow: '500ms', slower: '700ms', }, easing: { linear: 'linear', in: 'cubic-bezier(0.4, 0, 1, 1)', out: 'cubic-bezier(0, 0, 0.2, 1)', inOut: 'cubic-bezier(0.4, 0, 0.2, 1)', bounce: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)', } }, shadows: { sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)', md: '0 4px 6px -1px rgb(0 0 0 / 0.1)', lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)', xl: '0 20px 25px -5px rgb(0 0 0 / 0.1)', '2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)', inner: 'inset 0 2px 4px 0 rgb(0 0 0 / 0.06)', } } ``` #### Shared UI Components ```vue ${{ yacht.hourlyRate }}/hour {{ yacht.name }} {{ yacht.model }} · {{ yacht.year }} {{ yacht.available ? 'Available' : 'Booked' }} {{ yacht.capacity }} guests {{ yacht.length }}ft {{ yacht.cabins }} cabins {{ feature }} +{{ yacht.features.length - 3 }} more Book Now View Details ``` ### Application Structure #### Public Website (SSG) ```typescript // apps/website/nuxt.config.ts export default defineNuxtConfig({ extends: ['@harborsmith/ui'], nitro: { prerender: { crawlLinks: true, routes: [ '/', '/fleet', '/services', '/about', '/contact', // Dynamic routes from API '/yachts/**', ] } }, modules: [ '@nuxt/ui', '@nuxt/image', '@nuxtjs/seo', '@nuxtjs/fontaine', '@nuxtjs/partytown', '@vueuse/nuxt', ], ui: { global: true, icons: ['heroicons', 'mdi', 'carbon'], }, image: { provider: 'ipx', domains: ['minio.harborsmith.com'], alias: { minio: 'https://minio.harborsmith.com', }, screens: { xs: 320, sm: 640, md: 768, lg: 1024, xl: 1280, xxl: 1536, '2xl': 1536, }, }, seo: { redirectToCanonicalSiteUrl: true, }, experimental: { payloadExtraction: false, renderJsonPayloads: true, componentIslands: true, }, }) ``` #### Customer Web App (SPA) ```typescript // apps/webapp/nuxt.config.ts export default defineNuxtConfig({ extends: ['@harborsmith/ui'], ssr: false, modules: [ '@nuxt/ui', '@pinia/nuxt', '@vueuse/nuxt', '@nuxtjs/device', 'nuxt-viewport', ], runtimeConfig: { public: { apiUrl: process.env.NUXT_PUBLIC_API_URL, wsUrl: process.env.NUXT_PUBLIC_WS_URL, keycloakUrl: process.env.NUXT_PUBLIC_KEYCLOAK_URL, keycloakRealm: process.env.NUXT_PUBLIC_KEYCLOAK_REALM, keycloakClientId: process.env.NUXT_PUBLIC_KEYCLOAK_CLIENT_ID, } }, pinia: { storesDirs: ['./stores/**'], }, build: { transpile: ['trpc-nuxt'], }, }) ``` #### Admin Portal (SSR/SPA Hybrid) ```typescript // apps/portal/nuxt.config.ts export default defineNuxtConfig({ extends: ['@harborsmith/ui'], nitro: { prerender: { routes: ['/login', '/dashboard'], } }, modules: [ '@nuxt/ui', '@pinia/nuxt', '@vueuse/nuxt', 'nuxt-viewport', '@nuxtjs/i18n', ], ssr: true, experimental: { viewTransition: true, crossOriginPrefetch: true, }, }) ``` ### State Management ```typescript // apps/webapp/stores/booking.ts import { defineStore } from 'pinia' import { useNuxtData } from '#app' export const useBookingStore = defineStore('booking', () => { // State const currentBooking = ref(null) const selectedYacht = ref(null) const selectedDates = ref(null) const selectedExtras = ref([]) const bookingStep = ref('yacht') // Computed const totalPrice = computed(() => { if (!selectedYacht.value || !selectedDates.value) return 0 const hours = calculateHours(selectedDates.value) const basePrice = selectedYacht.value.hourlyRate * hours const extrasPrice = selectedExtras.value.reduce((sum, extra) => { return sum + extra.price * (extra.perHour ? hours : 1) }, 0) return basePrice + extrasPrice }) const canProceed = computed(() => { switch (bookingStep.value) { case 'yacht': return !!selectedYacht.value case 'dates': return !!selectedDates.value case 'extras': return true // Extras are optional case 'payment': return totalPrice.value > 0 default: return false } }) // Actions const selectYacht = async (yacht: Yacht) => { selectedYacht.value = yacht await checkAvailability(yacht.id) } const selectDates = async (dates: DateRange) => { selectedDates.value = dates if (selectedYacht.value) { await checkAvailability(selectedYacht.value.id, dates) } } const createBooking = async () => { const { $api } = useNuxtApp() const booking = await $api.bookings.create({ yachtId: selectedYacht.value!.id, startDate: selectedDates.value!.start, endDate: selectedDates.value!.end, extras: selectedExtras.value.map(e => e.id), }) currentBooking.value = booking return booking } const checkAvailability = async (yachtId: string, dates?: DateRange) => { const { $api } = useNuxtApp() return await $api.yachts.checkAvailability({ yachtId, startDate: dates?.start, endDate: dates?.end, }) } const reset = () => { currentBooking.value = null selectedYacht.value = null selectedDates.value = null selectedExtras.value = [] bookingStep.value = 'yacht' } return { // State currentBooking, selectedYacht, selectedDates, selectedExtras, bookingStep, // Computed totalPrice, canProceed, // Actions selectYacht, selectDates, createBooking, checkAvailability, reset, } }) ``` ## Backend Architecture ### API Structure #### Fastify Server Setup ```typescript // apps/api/src/server.ts import Fastify from 'fastify' import cors from '@fastify/cors' import helmet from '@fastify/helmet' import rateLimit from '@fastify/rate-limit' import compress from '@fastify/compress' import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify' import { createContext } from './trpc/context' import { appRouter } from './trpc/router' import { tusPlugin } from './plugins/tus' import { socketPlugin } from './plugins/socket' import { metricsPlugin } from './plugins/metrics' export async function createServer() { const server = Fastify({ logger: { level: process.env.LOG_LEVEL || 'info', transport: { target: '@axiomhq/pino', options: { dataset: process.env.AXIOM_DATASET, token: process.env.AXIOM_TOKEN, } } }, maxParamLength: 5000, bodyLimit: 100 * 1024 * 1024, // 100MB for file uploads }) // Core plugins await server.register(helmet, { contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], imgSrc: ["'self'", 'data:', 'https:'], connectSrc: ["'self'", 'wss:', 'https:'], } } }) await server.register(cors, { origin: process.env.ALLOWED_ORIGINS?.split(',') || true, credentials: true, }) await server.register(compress, { global: true, threshold: 1024, encodings: ['gzip', 'deflate', 'br'], }) await server.register(rateLimit, { max: 100, timeWindow: '1 minute', cache: 10000, allowList: ['127.0.0.1'], redis: { host: process.env.REDIS_HOST, port: parseInt(process.env.REDIS_PORT || '6379'), } }) // Custom plugins await server.register(tusPlugin, { prefix: '/upload' }) await server.register(socketPlugin, { prefix: '/ws' }) await server.register(metricsPlugin, { prefix: '/metrics' }) // tRPC await server.register(fastifyTRPCPlugin, { prefix: '/trpc', trpcOptions: { router: appRouter, createContext, onError({ path, error }) { server.log.error({ path, error }, 'tRPC error') }, } }) // Health check server.get('/health', async (request, reply) => { const checks = await performHealthChecks() const healthy = Object.values(checks).every(check => check.status === 'healthy') reply.code(healthy ? 200 : 503).send({ status: healthy ? 'healthy' : 'unhealthy', timestamp: new Date().toISOString(), checks, }) }) return server } // Start server const start = async () => { const server = await createServer() try { await server.listen({ port: parseInt(process.env.PORT || '3000'), host: '0.0.0.0', }) server.log.info(`Server listening on ${server.server.address()}`) } catch (err) { server.log.error(err) process.exit(1) } } start() ``` #### tRPC Router Architecture ```typescript // apps/api/src/trpc/router.ts import { t } from './trpc' import { authRouter } from './routers/auth' import { yachtsRouter } from './routers/yachts' import { bookingsRouter } from './routers/bookings' import { usersRouter } from './routers/users' import { paymentsRouter } from './routers/payments' import { mediaRouter } from './routers/media' import { maintenanceRouter } from './routers/maintenance' import { analyticsRouter } from './routers/analytics' export const appRouter = t.router({ auth: authRouter, yachts: yachtsRouter, bookings: bookingsRouter, users: usersRouter, payments: paymentsRouter, media: mediaRouter, maintenance: maintenanceRouter, analytics: analyticsRouter, }) export type AppRouter = typeof appRouter ``` ```typescript // apps/api/src/trpc/routers/yachts.ts import { z } from 'zod' import { t, protectedProcedure, adminProcedure } from '../trpc' import { YachtService } from '../../services/yacht.service' import { TRPCError } from '@trpc/server' const yachtInput = z.object({ name: z.string().min(1).max(100), model: z.string(), year: z.number().min(1900).max(new Date().getFullYear() + 1), length: z.number().min(10).max(500), capacity: z.number().min(1).max(100), cabins: z.number().min(0).max(20), hourlyRate: z.number().min(0), dailyRate: z.number().min(0), features: z.array(z.string()), description: z.string(), location: z.object({ marina: z.string(), berth: z.string().optional(), latitude: z.number(), longitude: z.number(), }), }) export const yachtsRouter = t.router({ // Public procedures list: t.procedure .input(z.object({ page: z.number().min(1).default(1), limit: z.number().min(1).max(100).default(20), filters: z.object({ location: z.string().optional(), capacity: z.number().optional(), priceRange: z.object({ min: z.number().optional(), max: z.number().optional(), }).optional(), features: z.array(z.string()).optional(), available: z.object({ from: z.date(), to: z.date(), }).optional(), }).optional(), sort: z.enum(['price', 'capacity', 'rating', 'popular']).default('popular'), })) .query(async ({ input, ctx }) => { const service = new YachtService(ctx.prisma) return service.listYachts(input) }), getById: t.procedure .input(z.string().uuid()) .query(async ({ input, ctx }) => { const service = new YachtService(ctx.prisma) const yacht = await service.getYacht(input) if (!yacht) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Yacht not found', }) } return yacht }), checkAvailability: t.procedure .input(z.object({ yachtId: z.string().uuid(), startDate: z.date(), endDate: z.date(), })) .query(async ({ input, ctx }) => { const service = new YachtService(ctx.prisma) return service.checkAvailability(input) }), // Protected procedures (require auth) create: adminProcedure .input(yachtInput) .mutation(async ({ input, ctx }) => { const service = new YachtService(ctx.prisma) return service.createYacht({ ...input, ownerId: ctx.user.id, }) }), update: adminProcedure .input(z.object({ id: z.string().uuid(), data: yachtInput.partial(), })) .mutation(async ({ input, ctx }) => { const service = new YachtService(ctx.prisma) // Check ownership const yacht = await service.getYacht(input.id) if (!yacht || (yacht.ownerId !== ctx.user.id && ctx.user.role !== 'ADMIN')) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have permission to update this yacht', }) } return service.updateYacht(input.id, input.data) }), delete: adminProcedure .input(z.string().uuid()) .mutation(async ({ input, ctx }) => { const service = new YachtService(ctx.prisma) // Check ownership const yacht = await service.getYacht(input) if (!yacht || (yacht.ownerId !== ctx.user.id && ctx.user.role !== 'ADMIN')) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have permission to delete this yacht', }) } return service.deleteYacht(input) }), // Analytics getStats: protectedProcedure .input(z.string().uuid()) .query(async ({ input, ctx }) => { const service = new YachtService(ctx.prisma) return service.getYachtStats(input, ctx.user.id) }), }) ``` ### Service Layer Architecture ```typescript // apps/api/src/services/yacht.service.ts import { PrismaClient, Prisma } from '@prisma/client' import { cache } from '../lib/cache' import { EventEmitter } from '../lib/events' export class YachtService { constructor( private prisma: PrismaClient, private events: EventEmitter = new EventEmitter(), ) {} async listYachts(params: ListYachtsParams) { const cacheKey = `yachts:list:${JSON.stringify(params)}` // Check cache first const cached = await cache.get(cacheKey) if (cached) return cached const where: Prisma.YachtWhereInput = {} // Build filters if (params.filters) { if (params.filters.location) { where.location = { marina: { contains: params.filters.location, mode: 'insensitive', } } } if (params.filters.capacity) { where.capacity = { gte: params.filters.capacity } } if (params.filters.priceRange) { where.hourlyRate = { gte: params.filters.priceRange.min, lte: params.filters.priceRange.max, } } if (params.filters.features?.length) { where.features = { hasEvery: params.filters.features, } } if (params.filters.available) { // Complex availability check where.bookings = { none: { OR: [ { startDate: { lte: params.filters.available.to, }, endDate: { gte: params.filters.available.from, }, status: { in: ['CONFIRMED', 'PENDING'], } } ] } } } } // Build order by const orderBy: Prisma.YachtOrderByWithRelationInput = {} switch (params.sort) { case 'price': orderBy.hourlyRate = 'asc' break case 'capacity': orderBy.capacity = 'desc' break case 'rating': orderBy.rating = 'desc' break case 'popular': default: orderBy.bookingCount = 'desc' break } // Execute query with pagination const [total, yachts] = await Promise.all([ this.prisma.yacht.count({ where }), this.prisma.yacht.findMany({ where, orderBy, skip: (params.page - 1) * params.limit, take: params.limit, include: { media: { where: { isPrimary: true }, take: 1, }, reviews: { select: { rating: true, } } } }) ]) // Process results const results = yachts.map(yacht => ({ ...yacht, primaryImage: yacht.media[0]?.url, averageRating: yacht.reviews.length ? yacht.reviews.reduce((sum, r) => sum + r.rating, 0) / yacht.reviews.length : null, })) const response = { yachts: results, pagination: { page: params.page, limit: params.limit, total, totalPages: Math.ceil(total / params.limit), } } // Cache for 5 minutes await cache.set(cacheKey, response, 300) return response } async createYacht(data: CreateYachtData) { const yacht = await this.prisma.yacht.create({ data: { ...data, slug: this.generateSlug(data.name), status: 'DRAFT', }, include: { owner: true, } }) // Emit event for other services this.events.emit('yacht.created', { yacht }) // Invalidate cache await cache.deletePattern('yachts:list:*') return yacht } async updateYacht(id: string, data: UpdateYachtData) { const yacht = await this.prisma.yacht.update({ where: { id }, data: { ...data, updatedAt: new Date(), }, }) // Emit event this.events.emit('yacht.updated', { yacht }) // Invalidate cache await Promise.all([ cache.delete(`yacht:${id}`), cache.deletePattern('yachts:list:*'), ]) return yacht } async checkAvailability({ yachtId, startDate, endDate }: CheckAvailabilityParams) { const conflicts = await this.prisma.booking.findMany({ where: { yachtId, status: { in: ['CONFIRMED', 'PENDING'], }, OR: [ { startDate: { lte: endDate, }, endDate: { gte: startDate, }, } ] }, select: { startDate: true, endDate: true, status: true, } }) const maintenanceSchedules = await this.prisma.maintenanceSchedule.findMany({ where: { yachtId, status: 'SCHEDULED', startDate: { lte: endDate, }, endDate: { gte: startDate, }, }, select: { startDate: true, endDate: true, type: true, } }) return { available: conflicts.length === 0 && maintenanceSchedules.length === 0, conflicts: conflicts.map(c => ({ start: c.startDate, end: c.endDate, type: 'booking' as const, status: c.status, })), maintenance: maintenanceSchedules.map(m => ({ start: m.startDate, end: m.endDate, type: 'maintenance' as const, maintenanceType: m.type, })), } } private generateSlug(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') } } ``` ## Database Design ### Prisma Schema ```prisma // packages/database/prisma/schema.prisma generator client { provider = "prisma-client-js" previewFeatures = ["fullTextSearch", "postgresqlExtensions"] } datasource db { provider = "postgresql" url = env("DATABASE_URL") extensions = [pgcrypto, postgis, pg_trgm] } // ==================== USER & AUTH ==================== model User { id String @id @default(uuid()) keycloakId String @unique email String @unique firstName String lastName String phone String? avatar String? role UserRole @default(CUSTOMER) status UserStatus @default(ACTIVE) // Profile profile Profile? preferences Json @default("{}") // Relations ownedYachts Yacht[] @relation("YachtOwner") bookings Booking[] reviews Review[] payments Payment[] notifications Notification[] activityLogs ActivityLog[] // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt lastLoginAt DateTime? @@index([email]) @@index([keycloakId]) @@index([role, status]) } enum UserRole { CUSTOMER OWNER CAPTAIN CREW ADMIN SUPER_ADMIN } enum UserStatus { ACTIVE SUSPENDED DELETED } model Profile { id String @id @default(uuid()) userId String @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade) // Preferences language String @default("en") timezone String @default("America/Los_Angeles") currency String @default("USD") // Boating Experience boatingLicense String? experienceLevel ExperienceLevel @default(BEGINNER) certifications String[] // Emergency Contact emergencyName String? emergencyPhone String? emergencyRelation String? // Documents documents Document[] // Metadata metadata Json @default("{}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum ExperienceLevel { BEGINNER INTERMEDIATE ADVANCED PROFESSIONAL } // ==================== YACHT ==================== model Yacht { id String @id @default(uuid()) slug String @unique name String model String manufacturer String year Int // Specifications length Float // in feet beam Float? // width in feet draft Float? // depth in feet capacity Int // number of guests cabins Int bathrooms Int engineType String? enginePower String? // horsepower fuelCapacity Float? // in gallons waterCapacity Float? // in gallons // Pricing hourlyRate Float halfDayRate Float? // 4 hours fullDayRate Float? // 8 hours weeklyRate Float? securityDeposit Float // Location location Json // { marina, berth, latitude, longitude } homePort String cruisingArea String[] // Features & Amenities features String[] amenities String[] waterToys String[] safetyEquipment String[] navigationEquip String[] // Description description String @db.Text highlights String[] rules String[] // Status status YachtStatus @default(DRAFT) available Boolean @default(true) instantBooking Boolean @default(false) // Relations ownerId String owner User @relation("YachtOwner", fields: [ownerId], references: [id]) captain Captain? crew Crew[] media Media[] bookings Booking[] reviews Review[] maintenance MaintenanceSchedule[] documents Document[] insurance Insurance[] // Analytics viewCount Int @default(0) bookingCount Int @default(0) rating Float? // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt lastServiceDate DateTime? @@index([slug]) @@index([status, available]) @@index([ownerId]) @@index([hourlyRate]) @@index([capacity]) @@fulltext([name, model, manufacturer, description]) } enum YachtStatus { DRAFT PENDING_REVIEW ACTIVE INACTIVE MAINTENANCE ARCHIVED } // ==================== BOOKING ==================== model Booking { id String @id @default(uuid()) bookingNumber String @unique // Relations yachtId String yacht Yacht @relation(fields: [yachtId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) // Dates startDate DateTime endDate DateTime duration Float // in hours // Guests guestCount Int guestDetails Json[] // Array of guest information // Pricing basePrice Float extrasPrice Float @default(0) discountAmount Float @default(0) taxAmount Float totalPrice Float depositAmount Float // Status status BookingStatus @default(PENDING) paymentStatus PaymentStatus @default(PENDING) // Extras extras BookingExtra[] // Captain & Crew captainRequired Boolean @default(true) crewRequired Boolean @default(false) assignedCaptain String? assignedCrew String[] // Check-in/out checkInTime DateTime? checkOutTime DateTime? checkInNotes String? checkOutNotes String? damageReport Json? // Payment payments Payment[] refunds Refund[] // Communication messages Message[] // Metadata source String @default("WEBSITE") // WEBSITE, APP, PHONE, PARTNER specialRequests String? internalNotes String? cancellationReason String? // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt confirmedAt DateTime? cancelledAt DateTime? @@index([bookingNumber]) @@index([yachtId, startDate, endDate]) @@index([userId]) @@index([status, paymentStatus]) @@index([startDate]) } enum BookingStatus { PENDING CONFIRMED IN_PROGRESS COMPLETED CANCELLED NO_SHOW } enum PaymentStatus { PENDING PARTIAL PAID REFUNDED FAILED } model BookingExtra { id String @id @default(uuid()) bookingId String booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) name String description String? category String // CATERING, EQUIPMENT, SERVICE, OTHER quantity Int @default(1) unitPrice Float totalPrice Float createdAt DateTime @default(now()) @@index([bookingId]) } // ==================== PAYMENT ==================== model Payment { id String @id @default(uuid()) paymentNumber String @unique // Relations bookingId String booking Booking @relation(fields: [bookingId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) // Amount amount Float currency String @default("USD") // Stripe stripePaymentId String? @unique stripePaymentIntent String? stripeCustomerId String? paymentMethod String // CARD, BANK, WALLET // Status status PaymentTransactionStatus @default(PENDING) // Metadata description String? metadata Json @default("{}") failureReason String? // Timestamps createdAt DateTime @default(now()) processedAt DateTime? @@index([paymentNumber]) @@index([bookingId]) @@index([userId]) @@index([status]) @@index([stripePaymentId]) } enum PaymentTransactionStatus { PENDING PROCESSING SUCCEEDED FAILED CANCELLED } // ==================== MEDIA ==================== model Media { id String @id @default(uuid()) // Relations yachtId String? yacht Yacht? @relation(fields: [yachtId], references: [id], onDelete: Cascade) reviewId String? review Review? @relation(fields: [reviewId], references: [id], onDelete: Cascade) // File Info fileName String mimeType String size Int // in bytes // URLs url String // Public URL thumbnailUrl String? // Thumbnail for images/videos streamUrl String? // HLS stream URL for videos // MinIO bucket String objectKey String etag String? // Type & Purpose type MediaType category String? // EXTERIOR, INTERIOR, DECK, CABIN, etc. isPrimary Boolean @default(false) order Int @default(0) // Video Specific duration Float? // in seconds resolution String? // 1080p, 4K, etc. frameRate Float? bitrate Int? // Processing processingStatus ProcessingStatus @default(PENDING) processingError String? processedAt DateTime? // Metadata alt String? caption String? metadata Json @default("{}") // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([yachtId, type, isPrimary]) @@index([reviewId]) @@index([processingStatus]) } enum MediaType { IMAGE VIDEO DOCUMENT VIRTUAL_TOUR } enum ProcessingStatus { PENDING PROCESSING COMPLETED FAILED } // ==================== REVIEW ==================== model Review { id String @id @default(uuid()) // Relations bookingId String @unique booking Booking @relation(fields: [bookingId], references: [id]) yachtId String yacht Yacht @relation(fields: [yachtId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) // Ratings (1-5 stars) overallRating Float cleanlinessRating Float? accuracyRating Float? valueRating Float? serviceRating Float? locationRating Float? // Content title String? content String @db.Text pros String[] cons String[] // Media media Media[] // Response ownerResponse String? @db.Text ownerRespondedAt DateTime? // Status status ReviewStatus @default(PENDING) isVerified Boolean @default(false) isFeatured Boolean @default(false) // Helpful votes helpfulCount Int @default(0) // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt publishedAt DateTime? @@index([yachtId, status]) @@index([userId]) @@index([bookingId]) @@index([overallRating]) } enum ReviewStatus { PENDING APPROVED REJECTED HIDDEN } // ==================== MAINTENANCE ==================== model MaintenanceSchedule { id String @id @default(uuid()) // Relations yachtId String yacht Yacht @relation(fields: [yachtId], references: [id]) // Schedule type MaintenanceType title String description String? startDate DateTime endDate DateTime // Service Provider provider String? providerContact String? estimatedCost Float? actualCost Float? // Status status MaintenanceStatus @default(SCHEDULED) // Notes notes String? @db.Text completionNotes String? @db.Text // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt completedAt DateTime? @@index([yachtId, startDate, endDate]) @@index([status]) } enum MaintenanceType { ROUTINE REPAIR INSPECTION CLEANING UPGRADE EMERGENCY } enum MaintenanceStatus { SCHEDULED IN_PROGRESS COMPLETED CANCELLED } // ==================== CAPTAIN & CREW ==================== model Captain { id String @id @default(uuid()) yachtId String @unique yacht Yacht @relation(fields: [yachtId], references: [id]) // Personal Info firstName String lastName String email String phone String // Credentials licenseNumber String licenseExpiry DateTime certifications String[] yearsExperience Int // Availability availability Json // Calendar availability hourlyRate Float // Status status CrewStatus @default(ACTIVE) // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Crew { id String @id @default(uuid()) yachtId String yacht Yacht @relation(fields: [yachtId], references: [id]) // Personal Info firstName String lastName String role String // DECKHAND, CHEF, STEWARD, etc. // Contact email String? phone String? // Employment hourlyRate Float? status CrewStatus @default(ACTIVE) // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([yachtId]) } enum CrewStatus { ACTIVE INACTIVE ON_LEAVE TERMINATED } // ==================== COMMUNICATION ==================== model Message { id String @id @default(uuid()) // Relations bookingId String booking Booking @relation(fields: [bookingId], references: [id]) senderId String sender User @relation(fields: [senderId], references: [id]) // Content content String @db.Text attachments String[] // Status isRead Boolean @default(false) readAt DateTime? // Timestamps createdAt DateTime @default(now()) editedAt DateTime? @@index([bookingId]) @@index([senderId]) } model Notification { id String @id @default(uuid()) // Relations userId String user User @relation(fields: [userId], references: [id]) // Content type NotificationType title String message String data Json @default("{}") // Status isRead Boolean @default(false) readAt DateTime? // Delivery channels String[] // EMAIL, SMS, PUSH, IN_APP emailSent Boolean @default(false) smsSent Boolean @default(false) pushSent Boolean @default(false) // Timestamps createdAt DateTime @default(now()) expiresAt DateTime? @@index([userId, isRead]) @@index([type]) } enum NotificationType { BOOKING_CONFIRMED BOOKING_CANCELLED BOOKING_REMINDER PAYMENT_SUCCESS PAYMENT_FAILED REVIEW_REQUEST MAINTENANCE_SCHEDULED SYSTEM_ANNOUNCEMENT CUSTOM } // ==================== DOCUMENTS ==================== model Document { id String @id @default(uuid()) // Relations (polymorphic) entityType String // USER, YACHT, BOOKING, etc. entityId String // Document Info type DocumentType name String description String? // File fileUrl String fileName String mimeType String size Int // Status status DocumentStatus @default(PENDING) verifiedBy String? verifiedAt DateTime? expiresAt DateTime? // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([entityType, entityId]) @@index([type, status]) } enum DocumentType { LICENSE INSURANCE REGISTRATION INSPECTION CONTRACT WAIVER ID_PROOF OTHER } enum DocumentStatus { PENDING VERIFIED REJECTED EXPIRED } // ==================== AUDIT & ANALYTICS ==================== model ActivityLog { id String @id @default(uuid()) // Actor userId String? user User? @relation(fields: [userId], references: [id]) // Action action String // CREATE, UPDATE, DELETE, VIEW, etc. entityType String // YACHT, BOOKING, USER, etc. entityId String // Details changes Json? // Before/after values metadata Json @default("{}") // Request Info ipAddress String? userAgent String? // Timestamp createdAt DateTime @default(now()) @@index([userId]) @@index([entityType, entityId]) @@index([action]) @@index([createdAt]) } ``` ## Media Handling System ### Upload Architecture ```typescript // apps/api/src/plugins/tus.ts import { FastifyPluginAsync } from 'fastify' import { Server, EVENTS } from '@tus/server' import { S3Store } from '@tus/s3-store' import { Client } from 'minio' import { nanoid } from 'nanoid' export const tusPlugin: FastifyPluginAsync = async (fastify) => { const minioClient = new Client({ endPoint: process.env.MINIO_ENDPOINT!, port: parseInt(process.env.MINIO_PORT || '9000'), useSSL: process.env.MINIO_USE_SSL === 'true', accessKey: process.env.MINIO_ACCESS_KEY!, secretKey: process.env.MINIO_SECRET_KEY!, }) const tusServer = new Server({ path: '/upload', maxSize: 10 * 1024 * 1024 * 1024, // 10GB datastore: new S3Store({ s3Client: minioClient, bucket: 'uploads', partSize: 10 * 1024 * 1024, // 10MB parts }), namingFunction(req) { const ext = req.headers['metadata']?.split('filename ')[1]?.split('.').pop() return `${nanoid()}.${ext || 'bin'}` }, onUploadCreate: async (req, res, upload) => { const metadata = upload.metadata // Validate user permissions const token = req.headers.authorization?.replace('Bearer ', '') if (!token) throw new Error('Unauthorized') const user = await validateToken(token) if (!user) throw new Error('Invalid token') // Store upload record await fastify.prisma.upload.create({ data: { id: upload.id, userId: user.id, fileName: metadata.filename, mimeType: metadata.filetype, size: upload.size, status: 'UPLOADING', } }) return res }, onUploadFinish: async (req, res, upload) => { const uploadRecord = await fastify.prisma.upload.findUnique({ where: { id: upload.id } }) if (!uploadRecord) throw new Error('Upload not found') // Process based on file type const processor = getProcessor(uploadRecord.mimeType) if (processor) { // Queue processing job await fastify.queue.add('media.process', { uploadId: upload.id, type: processor, source: `uploads/${upload.id}`, }) } // Update status await fastify.prisma.upload.update({ where: { id: upload.id }, data: { status: 'COMPLETED', completedAt: new Date(), } }) // Emit event fastify.events.emit('upload.completed', { upload }) return res } }) // Handle tus protocol fastify.all('/upload', (req, reply) => { tusServer.handle(req.raw, reply.raw) }) fastify.all('/upload/*', (req, reply) => { tusServer.handle(req.raw, reply.raw) }) } ``` ### Video Processing Pipeline ```typescript // apps/api/src/workers/media.processor.ts import { Worker } from 'bullmq' import ffmpeg from 'fluent-ffmpeg' import { Client } from 'minio' import path from 'path' import fs from 'fs/promises' const minioClient = new Client({ endPoint: process.env.MINIO_ENDPOINT!, port: parseInt(process.env.MINIO_PORT || '9000'), useSSL: process.env.MINIO_USE_SSL === 'true', accessKey: process.env.MINIO_ACCESS_KEY!, secretKey: process.env.MINIO_SECRET_KEY!, }) export const mediaProcessor = new Worker('media.process', async (job) => { const { uploadId, type, source } = job.data switch (type) { case 'video': return processVideo(uploadId, source) case 'image': return processImage(uploadId, source) default: throw new Error(`Unknown processor type: ${type}`) } }, { connection: { host: process.env.REDIS_HOST, port: parseInt(process.env.REDIS_PORT || '6379'), }, concurrency: 3, }) async function processVideo(uploadId: string, source: string) { const tempDir = `/tmp/video-${uploadId}` await fs.mkdir(tempDir, { recursive: true }) try { // Download source video const sourceFile = path.join(tempDir, 'source.mp4') await minioClient.fGetObject('uploads', source, sourceFile) // Get video info const metadata = await getVideoMetadata(sourceFile) // Generate HLS streams const hlsDir = path.join(tempDir, 'hls') await fs.mkdir(hlsDir, { recursive: true }) // Create multiple quality variants const variants = [ { name: '1080p', width: 1920, height: 1080, bitrate: '5000k' }, { name: '720p', width: 1280, height: 720, bitrate: '3000k' }, { name: '480p', width: 854, height: 480, bitrate: '1500k' }, { name: '360p', width: 640, height: 360, bitrate: '800k' }, ] const playlists = [] for (const variant of variants) { if (metadata.width >= variant.width) { const outputDir = path.join(hlsDir, variant.name) await fs.mkdir(outputDir, { recursive: true }) await new Promise((resolve, reject) => { ffmpeg(sourceFile) .outputOptions([ `-vf scale=${variant.width}:${variant.height}`, `-c:v libx264`, `-b:v ${variant.bitrate}`, `-c:a aac`, `-b:a 128k`, `-hls_time 6`, `-hls_playlist_type vod`, `-hls_segment_filename ${outputDir}/segment_%03d.ts`, `-master_pl_name master.m3u8`, ]) .output(`${outputDir}/index.m3u8`) .on('end', resolve) .on('error', reject) .run() }) playlists.push({ resolution: variant.name, path: `${variant.name}/index.m3u8`, }) } } // Create master playlist const masterPlaylist = createMasterPlaylist(playlists) await fs.writeFile(path.join(hlsDir, 'master.m3u8'), masterPlaylist) // Upload all HLS files to MinIO const hlsFiles = await getFilesRecursive(hlsDir) for (const file of hlsFiles) { const relativePath = path.relative(hlsDir, file) const objectName = `videos/${uploadId}/hls/${relativePath}` await minioClient.fPutObject('media', objectName, file, { 'Content-Type': file.endsWith('.m3u8') ? 'application/x-mpegURL' : 'video/MP2T', 'Cache-Control': 'public, max-age=31536000', }) } // Generate thumbnail const thumbnailPath = path.join(tempDir, 'thumbnail.jpg') await new Promise((resolve, reject) => { ffmpeg(sourceFile) .screenshots({ timestamps: ['10%'], filename: 'thumbnail.jpg', folder: tempDir, size: '640x360', }) .on('end', resolve) .on('error', reject) }) await minioClient.fPutObject('media', `videos/${uploadId}/thumbnail.jpg`, thumbnailPath, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'public, max-age=31536000', }) // Update database await prisma.media.update({ where: { uploadId }, data: { processingStatus: 'COMPLETED', processedAt: new Date(), streamUrl: `https://cdn.harborsmith.com/videos/${uploadId}/hls/master.m3u8`, thumbnailUrl: `https://cdn.harborsmith.com/videos/${uploadId}/thumbnail.jpg`, duration: metadata.duration, resolution: `${metadata.width}x${metadata.height}`, frameRate: metadata.fps, bitrate: metadata.bitrate, } }) } finally { // Cleanup temp files await fs.rm(tempDir, { recursive: true, force: true }) } } function createMasterPlaylist(playlists: any[]) { let content = '#EXTM3U\n#EXT-X-VERSION:3\n' const bandwidthMap = { '1080p': 5500000, '720p': 3500000, '480p': 1750000, '360p': 900000, } for (const playlist of playlists) { const bandwidth = bandwidthMap[playlist.resolution] content += `#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth},RESOLUTION=${playlist.resolution}\n` content += `${playlist.path}\n` } return content } ``` ## Authentication & Authorization ### Keycloak Integration ```typescript // packages/auth/src/keycloak.ts import { FastifyPluginAsync } from 'fastify' import fastifyJwt from '@fastify/jwt' import axios from 'axios' interface KeycloakConfig { realm: string serverUrl: string clientId: string clientSecret: string } export const keycloakPlugin: FastifyPluginAsync = async (fastify, options) => { // JWT verification await fastify.register(fastifyJwt, { secret: { public: await getKeycloakPublicKey(options), }, verify: { algorithms: ['RS256'], issuer: `${options.serverUrl}/realms/${options.realm}`, audience: options.clientId, } }) // Add decorators fastify.decorate('authenticate', async (request, reply) => { try { await request.jwtVerify() // Enrich with user data const user = await getUserFromToken(request.user) request.user = user } catch (err) { reply.code(401).send({ error: 'Unauthorized' }) } }) fastify.decorate('authorize', (roles: string[]) => { return async (request, reply) => { if (!request.user) { return reply.code(401).send({ error: 'Unauthorized' }) } const hasRole = roles.some(role => request.user.roles?.includes(role) ) if (!hasRole) { return reply.code(403).send({ error: 'Forbidden' }) } } }) } async function getKeycloakPublicKey(config: KeycloakConfig) { const response = await axios.get( `${config.serverUrl}/realms/${config.realm}/protocol/openid-connect/certs` ) const key = response.data.keys[0] return `-----BEGIN PUBLIC KEY-----\n${key.x5c[0]}\n-----END PUBLIC KEY-----` } ``` ### RBAC Implementation ```typescript // apps/api/src/middleware/rbac.ts import { TRPCError } from '@trpc/server' export const permissions = { // Yacht permissions 'yacht:create': ['ADMIN', 'OWNER'], 'yacht:read': ['ADMIN', 'OWNER', 'CAPTAIN', 'CREW', 'CUSTOMER'], 'yacht:update': ['ADMIN', 'OWNER'], 'yacht:delete': ['ADMIN', 'OWNER'], // Booking permissions 'booking:create': ['ADMIN', 'CUSTOMER'], 'booking:read': ['ADMIN', 'OWNER', 'CAPTAIN', 'CUSTOMER'], 'booking:update': ['ADMIN', 'OWNER'], 'booking:cancel': ['ADMIN', 'OWNER', 'CUSTOMER'], // User permissions 'user:read': ['ADMIN', 'SELF'], 'user:update': ['ADMIN', 'SELF'], 'user:delete': ['ADMIN'], // Admin permissions 'admin:dashboard': ['ADMIN', 'SUPER_ADMIN'], 'admin:users': ['ADMIN', 'SUPER_ADMIN'], 'admin:reports': ['ADMIN', 'SUPER_ADMIN', 'OWNER'], } export function checkPermission( user: User, permission: keyof typeof permissions, context?: { ownerId?: string; userId?: string } ) { const allowedRoles = permissions[permission] // Check if user has required role if (allowedRoles.includes(user.role)) { return true } // Check for SELF permission if (allowedRoles.includes('SELF') && context?.userId === user.id) { return true } // Check for ownership if (user.role === 'OWNER' && context?.ownerId === user.id) { return true } throw new TRPCError({ code: 'FORBIDDEN', message: `Missing permission: ${permission}`, }) } ``` ## Third-Party Integrations ### Stripe Integration ```typescript // apps/api/src/services/stripe.service.ts import Stripe from 'stripe' export class StripeService { private stripe: Stripe constructor() { this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16', }) } async createCustomer(user: User) { const customer = await this.stripe.customers.create({ email: user.email, name: `${user.firstName} ${user.lastName}`, metadata: { userId: user.id, } }) // Save to database await prisma.user.update({ where: { id: user.id }, data: { stripeCustomerId: customer.id } }) return customer } async createPaymentIntent(booking: Booking) { // Calculate platform fee (10%) const platformFee = Math.round(booking.totalPrice * 0.10 * 100) const paymentIntent = await this.stripe.paymentIntents.create({ amount: Math.round(booking.totalPrice * 100), // Convert to cents currency: 'usd', customer: booking.user.stripeCustomerId, description: `Booking ${booking.bookingNumber}`, metadata: { bookingId: booking.id, yachtId: booking.yachtId, userId: booking.userId, }, application_fee_amount: platformFee, transfer_data: { destination: booking.yacht.owner.stripeAccountId, }, capture_method: 'manual', // Hold funds until check-in }) return paymentIntent } async capturePayment(paymentIntentId: string) { return await this.stripe.paymentIntents.capture(paymentIntentId) } async refundPayment(paymentIntentId: string, amount?: number) { return await this.stripe.refunds.create({ payment_intent: paymentIntentId, amount: amount ? Math.round(amount * 100) : undefined, reason: 'requested_by_customer', }) } async createConnectedAccount(owner: User) { const account = await this.stripe.accounts.create({ type: 'express', country: 'US', email: owner.email, capabilities: { card_payments: { requested: true }, transfers: { requested: true }, }, business_type: 'individual', metadata: { ownerId: owner.id, } }) // Create account link for onboarding const accountLink = await this.stripe.accountLinks.create({ account: account.id, refresh_url: `${process.env.APP_URL}/portal/stripe/refresh`, return_url: `${process.env.APP_URL}/portal/stripe/complete`, type: 'account_onboarding', }) return { account, accountLink } } async handleWebhook(signature: string, payload: string) { const event = this.stripe.webhooks.constructEvent( payload, signature, process.env.STRIPE_WEBHOOK_SECRET! ) switch (event.type) { case 'payment_intent.succeeded': await this.handlePaymentSuccess(event.data.object) break case 'payment_intent.payment_failed': await this.handlePaymentFailure(event.data.object) break case 'account.updated': await this.handleAccountUpdate(event.data.object) break // ... more event handlers } } } ``` ### Cal.com Integration ```typescript // apps/api/src/services/cal.service.ts import axios from 'axios' export class CalService { private apiKey: string private baseUrl: string constructor() { this.apiKey = process.env.CAL_API_KEY! this.baseUrl = 'https://api.cal.com/v1' } async createEventType(yacht: Yacht) { const response = await axios.post( `${this.baseUrl}/event-types`, { title: `${yacht.name} Charter`, slug: yacht.slug, description: yacht.description, length: 60, // Default 1 hour slots locations: [ { type: 'inPerson', address: yacht.location.marina, } ], metadata: { yachtId: yacht.id, }, price: yacht.hourlyRate, currency: 'USD', }, { headers: { 'Authorization': `Bearer ${this.apiKey}`, } } ) return response.data } async createBooking(booking: Booking) { const response = await axios.post( `${this.baseUrl}/bookings`, { eventTypeId: booking.yacht.calEventTypeId, start: booking.startDate.toISOString(), end: booking.endDate.toISOString(), name: `${booking.user.firstName} ${booking.user.lastName}`, email: booking.user.email, phone: booking.user.phone, guests: booking.guestDetails.map(g => g.email), notes: booking.specialRequests, metadata: { bookingId: booking.id, } }, { headers: { 'Authorization': `Bearer ${this.apiKey}`, } } ) return response.data } async cancelBooking(calBookingId: string, reason: string) { const response = await axios.delete( `${this.baseUrl}/bookings/${calBookingId}`, { data: { cancellationReason: reason }, headers: { 'Authorization': `Bearer ${this.apiKey}`, } } ) return response.data } async handleWebhook(payload: any) { switch (payload.triggerEvent) { case 'BOOKING_CREATED': // Sync with our database break case 'BOOKING_CANCELLED': // Update booking status break case 'BOOKING_RESCHEDULED': // Update dates break } } } ``` ## Real-Time Communication ### Socket.io Implementation ```typescript // apps/api/src/plugins/socket.ts import { Server } from 'socket.io' import { createAdapter } from '@socket.io/redis-adapter' import { Redis } from 'ioredis' export const socketPlugin: FastifyPluginAsync = async (fastify) => { const pubClient = new Redis({ host: process.env.REDIS_HOST, port: parseInt(process.env.REDIS_PORT || '6379'), }) const subClient = pubClient.duplicate() const io = new Server(fastify.server, { cors: { origin: process.env.ALLOWED_ORIGINS?.split(','), credentials: true, }, adapter: createAdapter(pubClient, subClient), }) // Authentication middleware io.use(async (socket, next) => { const token = socket.handshake.auth.token try { const decoded = await fastify.jwt.verify(token) const user = await fastify.prisma.user.findUnique({ where: { keycloakId: decoded.sub } }) if (!user) throw new Error('User not found') socket.data.user = user next() } catch (err) { next(new Error('Authentication failed')) } }) io.on('connection', (socket) => { const user = socket.data.user // Join user room socket.join(`user:${user.id}`) // Join role-based rooms socket.join(`role:${user.role}`) // Handle booking updates socket.on('booking:subscribe', async (bookingId) => { // Verify user has access to this booking const booking = await fastify.prisma.booking.findFirst({ where: { id: bookingId, OR: [ { userId: user.id }, { yacht: { ownerId: user.id } }, ] } }) if (booking) { socket.join(`booking:${bookingId}`) } }) // Handle yacht tracking socket.on('yacht:track', async (yachtId) => { socket.join(`yacht:${yachtId}:tracking`) // Send initial position const position = await getYachtPosition(yachtId) socket.emit('yacht:position', position) }) // Handle chat messages socket.on('message:send', async (data) => { const message = await fastify.prisma.message.create({ data: { bookingId: data.bookingId, senderId: user.id, content: data.content, attachments: data.attachments || [], }, include: { sender: true, } }) // Send to all participants io.to(`booking:${data.bookingId}`).emit('message:new', message) // Send push notification to recipient await sendPushNotification(data.recipientId, { title: 'New message', body: data.content, data: { bookingId: data.bookingId } }) }) // Handle typing indicators socket.on('typing:start', ({ bookingId }) => { socket.to(`booking:${bookingId}`).emit('typing:user', { userId: user.id, name: `${user.firstName} ${user.lastName}`, }) }) socket.on('typing:stop', ({ bookingId }) => { socket.to(`booking:${bookingId}`).emit('typing:user:stop', { userId: user.id, }) }) // Handle disconnection socket.on('disconnect', () => { // Clean up any typing indicators io.emit('typing:user:stop', { userId: user.id }) }) }) // Expose io instance for use in routes fastify.decorate('io', io) } ``` ## Deployment Strategy ### Overview The Harborsmith platform is deployed using Docker containers orchestrated with Docker Compose, running behind an nginx reverse proxy on the host server. This architecture provides isolation, scalability, and easy management while leveraging the existing nginx infrastructure for SSL termination and load balancing. ### Host Nginx Configuration Since nginx is already running on the host server, we'll configure it as the main reverse proxy for all Harborsmith services. This provides centralized SSL management, load balancing, and request routing. ```nginx # /etc/nginx/sites-available/harborsmith.conf # Upstream definitions for load balancing upstream harborsmith_website { least_conn; server 127.0.0.1:3001 max_fails=3 fail_timeout=30s; server 127.0.0.1:3002 max_fails=3 fail_timeout=30s; keepalive 32; } upstream harborsmith_webapp { least_conn; server 127.0.0.1:3003 max_fails=3 fail_timeout=30s; server 127.0.0.1:3004 max_fails=3 fail_timeout=30s; keepalive 32; } upstream harborsmith_portal { least_conn; server 127.0.0.1:3005 max_fails=3 fail_timeout=30s; server 127.0.0.1:3006 max_fails=3 fail_timeout=30s; keepalive 32; } upstream harborsmith_api { ip_hash; # Sticky sessions for WebSocket support server 127.0.0.1:3000 max_fails=3 fail_timeout=30s; server 127.0.0.1:3010 max_fails=3 fail_timeout=30s; server 127.0.0.1:3020 max_fails=3 fail_timeout=30s; keepalive 64; } # MinIO is external - no upstream needed as it has its own access point # Rate limiting zones limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=upload_limit:10m rate=2r/s; # Main website (SSG) server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name harborsmith.com www.harborsmith.com; # SSL configuration (adjust paths as needed) ssl_certificate /etc/letsencrypt/live/harborsmith.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/harborsmith.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; ssl_stapling on; ssl_stapling_verify on; # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Content-Security-Policy "default-src 'self' https:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; style-src 'self' 'unsafe-inline' https:;" always; # Gzip compression gzip on; gzip_vary on; gzip_min_length 1000; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; location / { proxy_pass http://harborsmith_website; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; # Cache static assets location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ { proxy_pass http://harborsmith_website; expires 30d; add_header Cache-Control "public, immutable"; } } } # Customer Web App (SPA) server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name app.harborsmith.com; ssl_certificate /etc/letsencrypt/live/harborsmith.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/harborsmith.com/privkey.pem; # Inherit SSL settings from snippets/ssl-params.conf if available # include snippets/ssl-params.conf; location / { proxy_pass http://harborsmith_webapp; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } } # Admin Portal server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name portal.harborsmith.com; ssl_certificate /etc/letsencrypt/live/harborsmith.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/harborsmith.com/privkey.pem; location / { proxy_pass http://harborsmith_portal; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } } # API Server server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name api.harborsmith.com; ssl_certificate /etc/letsencrypt/live/harborsmith.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/harborsmith.com/privkey.pem; # API rate limiting location / { limit_req zone=api_limit burst=20 nodelay; proxy_pass http://harborsmith_api; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; # Timeouts for long-running requests proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; } # WebSocket endpoint location /ws { proxy_pass http://harborsmith_api; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # WebSocket timeouts proxy_connect_timeout 7d; proxy_send_timeout 7d; proxy_read_timeout 7d; } # Upload endpoint with larger limits location /upload { limit_req zone=upload_limit burst=5 nodelay; client_max_body_size 10G; client_body_timeout 3600s; proxy_pass http://harborsmith_api; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Extended timeouts for large uploads proxy_connect_timeout 3600s; proxy_send_timeout 3600s; proxy_read_timeout 3600s; proxy_request_buffering off; } } # MinIO is already accessible via its own endpoint/domain # If you need to proxy MinIO through this nginx (optional): # server { # listen 443 ssl http2; # listen [::]:443 ssl http2; # server_name minio.harborsmith.com; # # ssl_certificate /etc/letsencrypt/live/harborsmith.com/fullchain.pem; # ssl_certificate_key /etc/letsencrypt/live/harborsmith.com/privkey.pem; # # ignore_invalid_headers off; # client_max_body_size 0; # proxy_buffering off; # # location / { # proxy_set_header Host $http_host; # proxy_set_header X-Real-IP $remote_addr; # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header X-Forwarded-Proto $scheme; # # proxy_connect_timeout 300; # proxy_http_version 1.1; # proxy_set_header Connection ""; # chunked_transfer_encoding off; # # # Replace with actual MinIO endpoint # proxy_pass http://${MINIO_EXTERNAL_HOST}:${MINIO_EXTERNAL_PORT}; # } # } # HTTP to HTTPS redirect server { listen 80; listen [::]:80; server_name harborsmith.com www.harborsmith.com app.harborsmith.com portal.harborsmith.com api.harborsmith.com minio.harborsmith.com; return 301 https://$server_name$request_uri; } ``` ### Docker Configuration #### Docker Compose Configuration ```yaml # docker-compose.yml version: '3.9' services: # Database postgres: image: postgis/postgis:16-3.4 container_name: harborsmith_postgres restart: unless-stopped environment: POSTGRES_DB: harborsmith POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_MAX_CONNECTIONS: 200 POSTGRES_SHARED_BUFFERS: 256MB volumes: - postgres_data:/var/lib/postgresql/data - ./infrastructure/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro ports: - "127.0.0.1:5432:5432" # Only expose to localhost networks: - harborsmith_internal healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d harborsmith"] interval: 10s timeout: 5s retries: 5 # Cache redis: image: redis:7-alpine container_name: harborsmith_redis restart: unless-stopped command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru volumes: - redis_data:/data - ./infrastructure/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro ports: - "127.0.0.1:6379:6379" # Only expose to localhost networks: - harborsmith_internal healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 # Database Connection Pooler pgbouncer: image: pgbouncer/pgbouncer:latest container_name: harborsmith_pgbouncer restart: unless-stopped environment: DATABASES_HOST: postgres DATABASES_PORT: 5432 DATABASES_DBNAME: harborsmith DATABASES_USER: ${DB_USER} DATABASES_PASSWORD: ${DB_PASSWORD} POOL_MODE: transaction MAX_CLIENT_CONN: 1000 DEFAULT_POOL_SIZE: 25 MIN_POOL_SIZE: 5 RESERVE_POOL_SIZE: 5 ports: - "127.0.0.1:6432:6432" # PgBouncer port networks: - harborsmith_internal depends_on: postgres: condition: service_healthy # MinIO is already running in a separate Docker Compose stack # Connection details will be provided via environment variables: # - MINIO_ENDPOINT: External MinIO endpoint (e.g., minio.local or IP address) # - MINIO_PORT: External MinIO port (default: 9000) # - MINIO_ACCESS_KEY: Access key for existing MinIO instance # - MINIO_SECRET_KEY: Secret key for existing MinIO instance # API Service (3 replicas) api-1: build: context: . dockerfile: apps/api/Dockerfile container_name: harborsmith_api_1 restart: unless-stopped environment: NODE_ENV: production PORT: 3000 DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/harborsmith REDIS_URL: redis://redis:6379 MINIO_ENDPOINT: ${MINIO_ENDPOINT} # External MinIO endpoint MINIO_PORT: ${MINIO_PORT:-9000} MINIO_USE_SSL: ${MINIO_USE_SSL:-false} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} KEYCLOAK_URL: ${KEYCLOAK_URL} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} CAL_API_KEY: ${CAL_API_KEY} depends_on: postgres: condition: service_healthy redis: condition: service_healthy # MinIO is external - no dependency needed ports: - "127.0.0.1:3000:3000" networks: - harborsmith_internal healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/health"] interval: 30s timeout: 10s retries: 3 api-2: build: context: . dockerfile: apps/api/Dockerfile container_name: harborsmith_api_2 restart: unless-stopped environment: NODE_ENV: production PORT: 3000 DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/harborsmith REDIS_URL: redis://redis:6379 MINIO_ENDPOINT: ${MINIO_ENDPOINT} # External MinIO endpoint MINIO_PORT: ${MINIO_PORT:-9000} MINIO_USE_SSL: ${MINIO_USE_SSL:-false} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} KEYCLOAK_URL: ${KEYCLOAK_URL} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} CAL_API_KEY: ${CAL_API_KEY} depends_on: postgres: condition: service_healthy redis: condition: service_healthy # MinIO is external - no dependency needed ports: - "127.0.0.1:3010:3000" networks: - harborsmith_internal healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/health"] interval: 30s timeout: 10s retries: 3 api-3: build: context: . dockerfile: apps/api/Dockerfile container_name: harborsmith_api_3 restart: unless-stopped environment: NODE_ENV: production PORT: 3000 DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/harborsmith REDIS_URL: redis://redis:6379 MINIO_ENDPOINT: ${MINIO_ENDPOINT} # External MinIO endpoint MINIO_PORT: ${MINIO_PORT:-9000} MINIO_USE_SSL: ${MINIO_USE_SSL:-false} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} KEYCLOAK_URL: ${KEYCLOAK_URL} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} CAL_API_KEY: ${CAL_API_KEY} depends_on: postgres: condition: service_healthy redis: condition: service_healthy # MinIO is external - no dependency needed ports: - "127.0.0.1:3020:3000" networks: - harborsmith_internal healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/health"] interval: 30s timeout: 10s retries: 3 # Frontend Services website-1: build: context: . dockerfile: apps/website/Dockerfile args: - APP=website container_name: harborsmith_website_1 restart: unless-stopped environment: NODE_ENV: production NUXT_PUBLIC_API_URL: https://api.harborsmith.com NUXT_PUBLIC_WS_URL: wss://api.harborsmith.com/ws ports: - "127.0.0.1:3001:3000" networks: - harborsmith_internal healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000"] interval: 30s timeout: 10s retries: 3 website-2: build: context: . dockerfile: apps/website/Dockerfile args: - APP=website container_name: harborsmith_website_2 restart: unless-stopped environment: NODE_ENV: production NUXT_PUBLIC_API_URL: https://api.harborsmith.com NUXT_PUBLIC_WS_URL: wss://api.harborsmith.com/ws ports: - "127.0.0.1:3002:3000" networks: - harborsmith_internal healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000"] interval: 30s timeout: 10s retries: 3 webapp-1: build: context: . dockerfile: apps/webapp/Dockerfile args: - APP=webapp container_name: harborsmith_webapp_1 restart: unless-stopped environment: NODE_ENV: production NUXT_PUBLIC_API_URL: https://api.harborsmith.com NUXT_PUBLIC_WS_URL: wss://api.harborsmith.com/ws NUXT_PUBLIC_KEYCLOAK_URL: ${KEYCLOAK_URL} NUXT_PUBLIC_KEYCLOAK_REALM: ${KEYCLOAK_REALM} NUXT_PUBLIC_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} ports: - "127.0.0.1:3003:3000" networks: - harborsmith_internal healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000"] interval: 30s timeout: 10s retries: 3 webapp-2: build: context: . dockerfile: apps/webapp/Dockerfile args: - APP=webapp container_name: harborsmith_webapp_2 restart: unless-stopped environment: NODE_ENV: production NUXT_PUBLIC_API_URL: https://api.harborsmith.com NUXT_PUBLIC_WS_URL: wss://api.harborsmith.com/ws NUXT_PUBLIC_KEYCLOAK_URL: ${KEYCLOAK_URL} NUXT_PUBLIC_KEYCLOAK_REALM: ${KEYCLOAK_REALM} NUXT_PUBLIC_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} ports: - "127.0.0.1:3004:3000" networks: - harborsmith_internal healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000"] interval: 30s timeout: 10s retries: 3 portal-1: build: context: . dockerfile: apps/portal/Dockerfile args: - APP=portal container_name: harborsmith_portal_1 restart: unless-stopped environment: NODE_ENV: production NUXT_PUBLIC_API_URL: https://api.harborsmith.com NUXT_PUBLIC_WS_URL: wss://api.harborsmith.com/ws NUXT_PUBLIC_KEYCLOAK_URL: ${KEYCLOAK_URL} NUXT_PUBLIC_KEYCLOAK_REALM: ${KEYCLOAK_REALM} NUXT_PUBLIC_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_ADMIN_CLIENT_ID} ports: - "127.0.0.1:3005:3000" networks: - harborsmith_internal healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000"] interval: 30s timeout: 10s retries: 3 portal-2: build: context: . dockerfile: apps/portal/Dockerfile args: - APP=portal container_name: harborsmith_portal_2 restart: unless-stopped environment: NODE_ENV: production NUXT_PUBLIC_API_URL: https://api.harborsmith.com NUXT_PUBLIC_WS_URL: wss://api.harborsmith.com/ws NUXT_PUBLIC_KEYCLOAK_URL: ${KEYCLOAK_URL} NUXT_PUBLIC_KEYCLOAK_REALM: ${KEYCLOAK_REALM} NUXT_PUBLIC_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_ADMIN_CLIENT_ID} ports: - "127.0.0.1:3006:3000" networks: - harborsmith_internal healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000"] interval: 30s timeout: 10s retries: 3 volumes: postgres_data: driver: local redis_data: driver: local # MinIO data is managed by the external MinIO instance networks: harborsmith_internal: name: harborsmith_internal driver: bridge ipam: config: - subnet: 172.20.0.0/16 ``` #### Dockerfiles for Each Service ```dockerfile # apps/api/Dockerfile FROM node:20-alpine AS base RUN apk add --no-cache libc6-compat ffmpeg WORKDIR /app # Install dependencies FROM base AS deps COPY package*.json ./ COPY turbo.json ./ COPY packages/database/package.json ./packages/database/ COPY packages/shared/package.json ./packages/shared/ RUN npm ci # Build the application FROM base AS builder COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npx turbo run build --filter=@harborsmith/api # Production image FROM base AS runner ENV NODE_ENV production RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nodejs COPY --from=builder --chown=nodejs:nodejs /app/apps/api/dist ./dist COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules COPY --from=builder --chown=nodejs:nodejs /app/packages/database/prisma ./prisma USER nodejs EXPOSE 3000 CMD ["node", "dist/server.js"] ``` ```dockerfile # apps/website/Dockerfile (SSG) FROM node:20-alpine AS base RUN apk add --no-cache libc6-compat WORKDIR /app FROM base AS deps COPY package*.json ./ COPY turbo.json ./ RUN npm ci FROM base AS builder COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npx turbo run build --filter=@harborsmith/website FROM base AS runner ENV NODE_ENV production RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nodejs COPY --from=builder --chown=nodejs:nodejs /app/apps/website/.output ./.output COPY --from=builder --chown=nodejs:nodejs /app/apps/website/public ./public USER nodejs EXPOSE 3000 CMD ["node", ".output/server/index.mjs"] ``` ```dockerfile # apps/webapp/Dockerfile (SPA) FROM node:20-alpine AS base RUN apk add --no-cache libc6-compat WORKDIR /app FROM base AS deps COPY package*.json ./ COPY turbo.json ./ RUN npm ci FROM base AS builder COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npx turbo run build --filter=@harborsmith/webapp FROM nginx:alpine AS runner COPY --from=builder /app/apps/webapp/.output/public /usr/share/nginx/html COPY ./infrastructure/nginx/spa.conf /etc/nginx/conf.d/default.conf EXPOSE 3000 CMD ["nginx", "-g", "daemon off;"] ``` ### Deployment Scripts ```bash #!/bin/bash # deploy.sh - Main deployment script set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color echo -e "${GREEN}Starting Harborsmith deployment...${NC}" # Check for required environment variables required_vars=( "DB_USER" "DB_PASSWORD" "MINIO_ACCESS_KEY" "MINIO_SECRET_KEY" "KEYCLOAK_URL" "STRIPE_SECRET_KEY" "CAL_API_KEY" ) for var in "${required_vars[@]}"; do if [[ -z "${!var}" ]]; then echo -e "${RED}Error: $var is not set${NC}" exit 1 fi done # Pull latest code echo -e "${YELLOW}Pulling latest code...${NC}" git pull origin main # Build containers echo -e "${YELLOW}Building containers...${NC}" docker-compose build --parallel # Run database migrations echo -e "${YELLOW}Running database migrations...${NC}" docker-compose run --rm api npx prisma migrate deploy # Start services echo -e "${YELLOW}Starting services...${NC}" docker-compose up -d # Wait for health checks echo -e "${YELLOW}Waiting for services to be healthy...${NC}" ./scripts/wait-for-healthy.sh # Reload nginx configuration echo -e "${YELLOW}Reloading nginx configuration...${NC}" sudo nginx -t && sudo nginx -s reload echo -e "${GREEN}Deployment complete!${NC}" ``` ```bash #!/bin/bash # scripts/wait-for-healthy.sh services=( "harborsmith_postgres" "harborsmith_redis" "harborsmith_pgbouncer" "harborsmith_api_1" "harborsmith_api_2" "harborsmith_api_3" "harborsmith_website_1" "harborsmith_webapp_1" "harborsmith_portal_1" ) # Check external MinIO connectivity echo "Checking MinIO connectivity..." curl -f http://${MINIO_ENDPOINT}:${MINIO_PORT}/minio/health/live || { echo "WARNING: Cannot reach external MinIO instance at ${MINIO_ENDPOINT}:${MINIO_PORT}" echo "Please ensure MinIO is running and accessible" } for service in "${services[@]}"; do echo "Waiting for $service to be healthy..." while [ "$(docker inspect -f '{{.State.Health.Status}}' $service 2>/dev/null)" != "healthy" ]; do sleep 2 done echo "$service is healthy!" done ``` ### Container Management ```bash # Start all services docker-compose up -d # Stop all services docker-compose down # View logs docker-compose logs -f [service_name] # Scale a service docker-compose up -d --scale api=5 # Update a single service docker-compose up -d --no-deps --build api-1 # Database backup docker exec harborsmith_postgres pg_dump -U $DB_USER harborsmith > backup_$(date +%Y%m%d).sql # Database restore docker exec -i harborsmith_postgres psql -U $DB_USER harborsmith < backup.sql ``` ### Environment Configuration ```bash # .env file (root directory) # Database DB_USER=harborsmith DB_PASSWORD=your_secure_password_here # External MinIO Configuration MINIO_ENDPOINT=minio.example.com # or IP address of MinIO host MINIO_PORT=9000 MINIO_USE_SSL=true # Set to true if MinIO uses HTTPS MINIO_ACCESS_KEY=your_external_minio_access_key MINIO_SECRET_KEY=your_external_minio_secret_key # External Services KEYCLOAK_URL=https://auth.harborsmith.com KEYCLOAK_REALM=harborsmith KEYCLOAK_CLIENT_ID=harborsmith-webapp KEYCLOAK_ADMIN_CLIENT_ID=harborsmith-portal # Stripe STRIPE_SECRET_KEY=sk_live_your_stripe_key # Cal.com CAL_API_KEY=cal_live_your_cal_key # Application NODE_ENV=production LOG_LEVEL=info ALLOWED_ORIGINS=https://harborsmith.com,https://app.harborsmith.com,https://portal.harborsmith.com ``` ### External MinIO Integration Since MinIO is running in a separate Docker Compose stack, the Harborsmith services connect to it via network configuration: #### MinIO Connection Configuration ```typescript // apps/api/src/config/storage.ts import { Client } from 'minio' export const minioClient = new Client({ endPoint: process.env.MINIO_ENDPOINT!, // External MinIO host port: parseInt(process.env.MINIO_PORT || '9000'), useSSL: process.env.MINIO_USE_SSL === 'true', accessKey: process.env.MINIO_ACCESS_KEY!, secretKey: process.env.MINIO_SECRET_KEY!, }) // Initialize buckets on startup export async function initializeStorage() { const buckets = ['uploads', 'media', 'backups', 'documents'] for (const bucket of buckets) { const exists = await minioClient.bucketExists(bucket) if (!exists) { await minioClient.makeBucket(bucket, 'us-west-2') console.log(`Created bucket: ${bucket}`) } } // Set bucket policies const publicPolicy = { Version: '2012-10-17', Statement: [{ Effect: 'Allow', Principal: { AWS: ['*'] }, Action: ['s3:GetObject'], Resource: ['arn:aws:s3:::media/*'], }], } await minioClient.setBucketPolicy('media', JSON.stringify(publicPolicy)) } ``` #### Network Connectivity Requirements - MinIO must be accessible from Docker containers - If MinIO is on the same host: use host networking or bridge network - If MinIO is on different host: ensure firewall allows port 9000 - For production: Use internal network IP or hostname #### Docker Network Setup (if MinIO on same host) ```bash # Create shared network for MinIO communication docker network create minio-shared # Add to MinIO compose file networks: default: external: name: minio-shared # Add to Harborsmith compose file networks: harborsmith_internal: driver: bridge minio-shared: external: true # Update service definitions to use both networks services: api-1: networks: - harborsmith_internal - minio-shared ``` ### Production Considerations 1. **SSL Certificates**: Using Let's Encrypt with nginx on the host 2. **Container Networking**: All containers expose ports only to localhost (127.0.0.1) 3. **Health Checks**: Each service has health check endpoints 4. **External MinIO**: Connected via environment variables, ensure network connectivity 5. **Logging**: Centralized logging through Docker's logging driver 6. **Monitoring**: Integration with monitoring stack (Prometheus/Grafana) 7. **Backups**: Automated PostgreSQL backups, MinIO handled by external instance 8. **Updates**: Zero-downtime deployments using rolling updates ## Security Architecture ### Security Best Practices 1. **Authentication & Authorization** - OAuth2/OIDC via Keycloak - JWT tokens with short expiration - Refresh token rotation - MFA support 2. **Data Protection** - Encryption at rest (PostgreSQL TDE) - Encryption in transit (TLS 1.3) - Field-level encryption for PII - GDPR compliance 3. **API Security** - Rate limiting per user/IP - Input validation with Zod - SQL injection prevention (Prisma) - XSS protection (CSP headers) 4. **Infrastructure Security** - Network segmentation - Secrets management (Vault) - Regular security updates - Container scanning 5. **Monitoring & Compliance** - Audit logging - Intrusion detection - SIEM integration - Compliance reporting ## Performance Optimization ### Frontend Optimizations 1. **Bundle Size** - Tree shaking - Code splitting - Dynamic imports - Component lazy loading 2. **Caching Strategy** - Browser caching - CDN caching - API response caching - Static asset optimization 3. **Image Optimization** - Next-gen formats (WebP, AVIF) - Responsive images - Lazy loading - CDN delivery ### Backend Optimizations 1. **Database** - Connection pooling - Query optimization - Indexes on foreign keys - Materialized views for reports 2. **Caching** - Redis for session storage - Query result caching - Full-page caching for SSG - CDN for static assets 3. **Scaling** - Horizontal scaling with load balancer - Database read replicas - Microservices for heavy operations - Queue-based processing ## Monitoring & Observability ### Logging Strategy ```typescript // Structured logging with Pino const logger = pino({ level: process.env.LOG_LEVEL || 'info', transport: { targets: [ { target: 'pino-pretty', options: { colorize: true }, level: 'debug' }, { target: '@axiomhq/pino', options: { dataset: process.env.AXIOM_DATASET, token: process.env.AXIOM_TOKEN, }, level: 'info' } ] } }) ``` ### Metrics Collection ```typescript // Prometheus metrics import { register, Counter, Histogram, Gauge } from 'prom-client' const httpRequestDuration = new Histogram({ name: 'http_request_duration_seconds', help: 'Duration of HTTP requests in seconds', labelNames: ['method', 'route', 'status'] }) const activeBookings = new Gauge({ name: 'active_bookings_total', help: 'Total number of active bookings', }) const paymentProcessed = new Counter({ name: 'payments_processed_total', help: 'Total number of processed payments', labelNames: ['status', 'method'] }) ``` ## Critical Improvements & Best Practices ### 1. Database Optimization #### Connection Pooling with PgBouncer - Added PgBouncer service for connection pooling - Transaction pooling mode for optimal performance - Prevents connection exhaustion under high load #### Database Configuration ```sql -- infrastructure/postgres/init.sql -- Performance optimizations ALTER SYSTEM SET shared_buffers = '256MB'; ALTER SYSTEM SET effective_cache_size = '1GB'; ALTER SYSTEM SET maintenance_work_mem = '64MB'; ALTER SYSTEM SET checkpoint_completion_target = 0.9; ALTER SYSTEM SET wal_buffers = '16MB'; ALTER SYSTEM SET default_statistics_target = 100; ALTER SYSTEM SET random_page_cost = 1.1; ALTER SYSTEM SET effective_io_concurrency = 200; ALTER SYSTEM SET work_mem = '4MB'; ALTER SYSTEM SET min_wal_size = '1GB'; ALTER SYSTEM SET max_wal_size = '4GB'; -- Create indexes for foreign keys and common queries CREATE INDEX idx_bookings_yacht_dates ON bookings(yacht_id, start_date, end_date); CREATE INDEX idx_bookings_user ON bookings(user_id); CREATE INDEX idx_bookings_status ON bookings(status, payment_status); CREATE INDEX idx_yachts_location ON yachts USING gin(location); CREATE INDEX idx_yachts_features ON yachts USING gin(features); CREATE INDEX idx_media_yacht ON media(yacht_id, type, is_primary); CREATE INDEX idx_reviews_yacht ON reviews(yacht_id, status); CREATE INDEX idx_activity_log_entity ON activity_log(entity_type, entity_id); CREATE INDEX idx_activity_log_created ON activity_log(created_at DESC); ``` ### 2. Enhanced Security Configuration #### CORS Configuration ```typescript // apps/api/src/config/cors.ts export const corsConfig = { origin: (origin, callback) => { const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [] // Allow requests with no origin (mobile apps, Postman) if (!origin) return callback(null, true) // Check if origin is allowed if (allowedOrigins.includes(origin)) { callback(null, true) } else { callback(new Error('Not allowed by CORS')) } }, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'], exposedHeaders: ['X-Total-Count', 'X-Page-Count'], maxAge: 86400, // 24 hours } ``` #### Application-Level Rate Limiting ```typescript // apps/api/src/plugins/rate-limiter.ts import { FastifyPluginAsync } from 'fastify' import { RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible' export const rateLimiterPlugin: FastifyPluginAsync = async (fastify) => { const rateLimiter = new RateLimiterRedis({ storeClient: fastify.redis, keyPrefix: 'rl', points: 100, // Number of requests duration: 60, // Per 60 seconds blockDuration: 60 * 10, // Block for 10 minutes }) // Different limits for different endpoints const limiters = { api: new RateLimiterRedis({ storeClient: fastify.redis, keyPrefix: 'rl:api', points: 100, duration: 60, }), auth: new RateLimiterRedis({ storeClient: fastify.redis, keyPrefix: 'rl:auth', points: 5, duration: 60 * 15, // 5 attempts per 15 minutes }), upload: new RateLimiterRedis({ storeClient: fastify.redis, keyPrefix: 'rl:upload', points: 10, duration: 60 * 60, // 10 uploads per hour }), } fastify.addHook('onRequest', async (request, reply) => { try { const limiter = getLimiterForRoute(request.url) await limiter.consume(request.ip) } catch (rejRes) { reply.code(429).send({ error: 'Too Many Requests', retryAfter: Math.round(rejRes.msBeforeNext / 1000) || 60, }) } }) } ``` ### 3. WebSocket Scaling Solution #### Redis Adapter for Socket.io ```typescript // apps/api/src/plugins/socket-scaled.ts import { createAdapter } from '@socket.io/redis-adapter' import { createClient } from 'redis' export const scaledSocketPlugin: FastifyPluginAsync = async (fastify) => { const pubClient = createClient({ url: process.env.REDIS_URL }) const subClient = pubClient.duplicate() await Promise.all([ pubClient.connect(), subClient.connect(), ]) const io = new Server(fastify.server, { cors: corsConfig, adapter: createAdapter(pubClient, subClient), connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000, // 2 minutes skipMiddlewares: true, }, }) // Session affinity handled by nginx ip_hash io.on('connection', (socket) => { // Connection handling... }) } ``` ### 4. Comprehensive Caching Strategy ```typescript // packages/shared/src/cache/cache-manager.ts export class CacheManager { private redis: Redis private strategies: Map constructor(redis: Redis) { this.redis = redis this.strategies = new Map([ ['yacht-list', { ttl: 300, pattern: 'cache-aside' }], ['yacht-detail', { ttl: 3600, pattern: 'cache-aside' }], ['user-session', { ttl: 86400, pattern: 'write-through' }], ['booking-availability', { ttl: 60, pattern: 'cache-aside' }], ]) } async get(key: string, fetcher?: () => Promise): Promise { const cached = await this.redis.get(key) if (cached) { return JSON.parse(cached) } if (!fetcher) return null // Cache-aside pattern const data = await fetcher() const strategy = this.getStrategy(key) await this.set(key, data, strategy.ttl) return data } async set(key: string, value: any, ttl?: number): Promise { const strategy = this.getStrategy(key) const finalTtl = ttl || strategy.ttl await this.redis.setex(key, finalTtl, JSON.stringify(value)) // Emit cache invalidation event await this.redis.publish('cache:invalidate', JSON.stringify({ key, ttl: finalTtl })) } async invalidate(pattern: string): Promise { const keys = await this.redis.keys(pattern) if (keys.length > 0) { await this.redis.del(...keys) } } async warmUp(): Promise { // Pre-load frequently accessed data const popularYachts = await this.redis.zrevrange('popular:yachts', 0, 10) for (const yachtId of popularYachts) { await this.get(`yacht:${yachtId}`, async () => { return await prisma.yacht.findUnique({ where: { id: yachtId } }) }) } } } ``` ### 5. CDN & Media Optimization #### CloudFlare CDN Configuration ```typescript // apps/api/src/services/cdn.service.ts export class CDNService { private cloudflare: Cloudflare constructor() { this.cloudflare = new Cloudflare({ email: process.env.CLOUDFLARE_EMAIL, key: process.env.CLOUDFLARE_API_KEY, }) } async purgeCache(urls: string[]): Promise { await this.cloudflare.zones.purgeCache( process.env.CLOUDFLARE_ZONE_ID, { files: urls } ) } async uploadToR2(file: Buffer, key: string): Promise { // Upload to Cloudflare R2 for edge storage const url = await this.cloudflare.r2.upload(file, key) return `https://cdn.harborsmith.com/${key}` } } ``` #### Image Optimization Pipeline ```typescript // apps/api/src/workers/image.processor.ts import sharp from 'sharp' export async function processImage(input: Buffer): Promise { const variants = [ { name: 'thumbnail', width: 150, height: 150, quality: 80 }, { name: 'small', width: 400, height: 300, quality: 85 }, { name: 'medium', width: 800, height: 600, quality: 85 }, { name: 'large', width: 1920, height: 1080, quality: 90 }, ] const processed = await Promise.all( variants.map(async (variant) => { const webp = await sharp(input) .resize(variant.width, variant.height, { fit: 'cover' }) .webp({ quality: variant.quality }) .toBuffer() const avif = await sharp(input) .resize(variant.width, variant.height, { fit: 'cover' }) .avif({ quality: variant.quality - 5 }) .toBuffer() return { ...variant, webp, avif } }) ) // Generate blur placeholder const placeholder = await sharp(input) .resize(20, 20, { fit: 'cover' }) .blur(10) .toBuffer() return { variants: processed, placeholder } } ``` ### 6. CI/CD Pipeline #### GitHub Actions Workflow ```yaml # .github/workflows/main.yml name: CI/CD Pipeline on: push: branches: [main, staging] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: test options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 redis: image: redis:7 options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci - name: Run linter run: npm run lint - name: Run type check run: npm run type-check - name: Run unit tests run: npm run test:unit -- --coverage - name: Run integration tests run: npm run test:integration - name: Run E2E tests run: npm run test:e2e - name: SonarCloud Scan uses: SonarSource/sonarcloud-github-action@master env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - name: Upload coverage uses: codecov/codecov-action@v3 security: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Run Snyk Security Scan uses: snyk/actions/node@master env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: scan-type: 'fs' scan-ref: '.' build: needs: [test, security] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: Login to DockerHub uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Build and push Docker images run: | docker-compose build --parallel docker-compose push deploy: needs: build runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - name: Deploy to production uses: appleboy/ssh-action@master with: host: ${{ secrets.PRODUCTION_HOST }} username: ${{ secrets.PRODUCTION_USER }} key: ${{ secrets.PRODUCTION_SSH_KEY }} script: | cd /opt/harborsmith git pull origin main docker-compose pull docker-compose up -d --remove-orphans ./scripts/wait-for-healthy.sh sudo nginx -s reload ``` ### 7. Testing Strategy #### Test Configuration ```typescript // vitest.config.ts import { defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, environment: 'node', coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', 'dist/', '*.config.ts', ], lines: 80, functions: 80, branches: 80, statements: 80, }, setupFiles: ['./tests/setup.ts'], }, }) ``` #### E2E Testing with Playwright ```typescript // tests/e2e/booking-flow.spec.ts import { test, expect } from '@playwright/test' test.describe('Booking Flow', () => { test('should complete a yacht booking', async ({ page }) => { // Login await page.goto('/login') await page.fill('[name=email]', 'test@example.com') await page.fill('[name=password]', 'password') await page.click('button[type=submit]') // Search for yacht await page.goto('/yachts') await page.fill('[name=search]', 'Sunset Dream') await page.click('button[aria-label="Search"]') // Select yacht await page.click('[data-yacht-id="123"]') await expect(page).toHaveURL(/\/yachts\/123/) // Select dates await page.click('[data-testid="date-picker"]') await page.click('[data-date="2024-06-15"]') await page.click('[data-date="2024-06-17"]') // Add extras await page.check('[name="extras.catering"]') await page.check('[name="extras.captain"]') // Proceed to payment await page.click('button:has-text("Book Now")') // Fill payment details (Stripe Elements) const stripeFrame = page.frameLocator('iframe[name*="stripe"]') await stripeFrame.locator('[name="cardnumber"]').fill('4242424242424242') await stripeFrame.locator('[name="exp-date"]').fill('12/25') await stripeFrame.locator('[name="cvc"]').fill('123') // Confirm booking await page.click('button:has-text("Confirm Booking")') // Verify success await expect(page).toHaveURL(/\/bookings\/[a-z0-9-]+\/confirmation/) await expect(page.locator('h1')).toContainText('Booking Confirmed') }) }) ``` ### 8. Monitoring & Observability #### OpenTelemetry Setup ```typescript // apps/api/src/telemetry.ts import { NodeSDK } from '@opentelemetry/sdk-node' import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' import { PeriodicExportingMetricReader, ConsoleMetricExporter } from '@opentelemetry/sdk-metrics' import { Resource } from '@opentelemetry/resources' import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions' const sdk = new NodeSDK({ resource: new Resource({ [SemanticResourceAttributes.SERVICE_NAME]: 'harborsmith-api', [SemanticResourceAttributes.SERVICE_VERSION]: process.env.npm_package_version, }), instrumentations: [ getNodeAutoInstrumentations({ '@opentelemetry/instrumentation-fs': { enabled: false, }, }), ], metricReader: new PeriodicExportingMetricReader({ exporter: new ConsoleMetricExporter(), exportIntervalMillis: 1000, }), }) sdk.start() ``` #### Custom Prometheus Metrics ```typescript // apps/api/src/metrics.ts import { Counter, Histogram, Gauge, register } from 'prom-client' export const metrics = { httpRequestDuration: new Histogram({ name: 'http_request_duration_seconds', help: 'Duration of HTTP requests in seconds', labelNames: ['method', 'route', 'status'], buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10], }), bookingsCreated: new Counter({ name: 'bookings_created_total', help: 'Total number of bookings created', labelNames: ['yacht_id', 'status'], }), activeUsers: new Gauge({ name: 'active_users', help: 'Number of active users', }), paymentAmount: new Histogram({ name: 'payment_amount_usd', help: 'Payment amounts in USD', labelNames: ['status', 'method'], buckets: [100, 500, 1000, 5000, 10000], }), uploadedFiles: new Counter({ name: 'uploaded_files_total', help: 'Total number of files uploaded', labelNames: ['type', 'size_category'], }), } // Collect default metrics register.collectDefaultMetrics({ prefix: 'harborsmith_' }) ``` ### 9. Error Handling & Resilience #### Circuit Breaker Implementation ```typescript // packages/shared/src/circuit-breaker.ts export class CircuitBreaker { private failures = 0 private lastFailureTime: number | null = null private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED' constructor( private threshold: number = 5, private timeout: number = 60000, // 1 minute private resetTimeout: number = 30000, // 30 seconds ) {} async execute(fn: () => Promise): Promise { if (this.state === 'OPEN') { if (Date.now() - this.lastFailureTime! > this.timeout) { this.state = 'HALF_OPEN' } else { throw new Error('Circuit breaker is OPEN') } } try { const result = await fn() this.onSuccess() return result } catch (error) { this.onFailure() throw error } } private onSuccess(): void { this.failures = 0 this.state = 'CLOSED' } private onFailure(): void { this.failures++ this.lastFailureTime = Date.now() if (this.failures >= this.threshold) { this.state = 'OPEN' setTimeout(() => { this.state = 'HALF_OPEN' }, this.resetTimeout) } } } ``` #### Retry Logic with Exponential Backoff ```typescript // packages/shared/src/retry.ts export async function retryWithBackoff( fn: () => Promise, options: RetryOptions = {}, ): Promise { const { maxAttempts = 3, initialDelay = 1000, maxDelay = 10000, factor = 2, jitter = true, } = options let lastError: Error for (let attempt = 0; attempt < maxAttempts; attempt++) { try { return await fn() } catch (error) { lastError = error as Error if (attempt === maxAttempts - 1) { throw lastError } const delay = Math.min( initialDelay * Math.pow(factor, attempt), maxDelay, ) const finalDelay = jitter ? delay + Math.random() * delay * 0.1 : delay await new Promise(resolve => setTimeout(resolve, finalDelay)) } } throw lastError! } ``` ### 10. Backup & Disaster Recovery #### Automated Backup Script ```bash #!/bin/bash # scripts/backup.sh set -e BACKUP_DIR="/backups" TIMESTAMP=$(date +%Y%m%d_%H%M%S) S3_BUCKET="s3://harborsmith-backups" # Backup PostgreSQL echo "Backing up PostgreSQL..." docker exec harborsmith_postgres pg_dump -U $DB_USER harborsmith | gzip > ${BACKUP_DIR}/postgres_${TIMESTAMP}.sql.gz # Backup MinIO data echo "Backing up MinIO..." docker run --rm -v minio_data:/data -v ${BACKUP_DIR}:/backup alpine tar czf /backup/minio_${TIMESTAMP}.tar.gz /data # Upload to S3 echo "Uploading to S3..." aws s3 cp ${BACKUP_DIR}/postgres_${TIMESTAMP}.sql.gz ${S3_BUCKET}/postgres/ aws s3 cp ${BACKUP_DIR}/minio_${TIMESTAMP}.tar.gz ${S3_BUCKET}/minio/ # Clean up old local backups (keep last 7 days) find ${BACKUP_DIR} -name "*.gz" -mtime +7 -delete # Verify backup integrity echo "Verifying backup..." gunzip -t ${BACKUP_DIR}/postgres_${TIMESTAMP}.sql.gz if [ $? -eq 0 ]; then echo "Backup successful" else echo "Backup verification failed" exit 1 fi # Send notification curl -X POST $SLACK_WEBHOOK_URL \ -H 'Content-Type: application/json' \ -d "{\"text\":\"Backup completed successfully at ${TIMESTAMP}\"}" ``` #### Disaster Recovery Plan ```yaml # infrastructure/disaster-recovery.yml recovery_objectives: rto: 4 hours # Recovery Time Objective rpo: 1 hour # Recovery Point Objective backup_schedule: database: full: "0 2 * * *" # Daily at 2 AM incremental: "0 * * * *" # Hourly media: sync: "*/15 * * * *" # Every 15 minutes to S3 recovery_procedures: 1_assessment: - Identify failure scope - Notify stakeholders - Activate incident response team 2_database_recovery: - Restore from latest backup - Apply WAL logs for point-in-time recovery - Verify data integrity 3_application_recovery: - Deploy to backup infrastructure - Update DNS records - Restore service connectivity 4_validation: - Run health checks - Verify critical functionality - Monitor for anomalies 5_communication: - Update status page - Notify customers - Document incident infrastructure_redundancy: primary_region: us-west-2 backup_region: us-east-1 cross_region_replication: enabled multi_az_deployment: true ``` ## Implementation Roadmap ### Phase 1: Foundation (Weeks 1-4) - [ ] Setup monorepo structure - [ ] Configure Docker environment - [ ] Setup PostgreSQL with Prisma - [ ] Implement authentication with Keycloak - [ ] Create base UI components - [ ] Setup CI/CD pipeline ### Phase 2: Core Features (Weeks 5-8) - [ ] Yacht management CRUD - [ ] Booking system - [ ] Payment integration - [ ] User profiles - [ ] Search and filtering - [ ] Media upload system ### Phase 3: Advanced Features (Weeks 9-12) - [ ] Real-time updates - [ ] Video streaming - [ ] Calendar integration - [ ] Review system - [ ] Analytics dashboard - [ ] Email notifications ### Phase 4: Polish & Launch (Weeks 13-16) - [ ] Performance optimization - [ ] Security audit - [ ] Load testing - [ ] Documentation - [ ] Beta testing - [ ] Production deployment ## Conclusion This architecture provides a solid foundation for building a scalable, performant, and maintainable yacht charter platform. The technology choices balance modern best practices with practical considerations for rapid development and future growth. Key advantages of this architecture: - **Type Safety**: End-to-end type safety with TypeScript, tRPC, and Prisma - **Performance**: Optimized for speed with Fastify, Redis caching, and CDN delivery - **Scalability**: Horizontal scaling ready with Docker and load balancing - **Developer Experience**: Monorepo structure with hot reload and type checking - **User Experience**: Beautiful UI with smooth animations and responsive design - **Maintainability**: Clean architecture with separation of concerns The platform is designed to handle growth from hundreds to millions of users while maintaining excellent performance and reliability.
{{ yacht.model }} · {{ yacht.year }}