VisitRomagna — Agritourism booking platform for Romagna, Italy
VisitRomagna is a Next.js 16 App Router monolith using a layered architecture. The frontend (React 19 SSR/CSR pages) and backend (REST API route handlers) coexist in a single deployment. The data layer currently uses an in-memory store with seeded fixtures, backed by a Prisma 7 schema ready for SQLite/PostgreSQL migration.
graph TB
subgraph Client["Browser / PWA"]
Pages["React Pages (SSR + CSR)"]
Hooks["React Hooks (useApiQuery, useAuth)"]
SSEClient["SSE Client"]
end
subgraph NextJS["Next.js 16 App Router"]
subgraph APILayer["API Layer (60+ routes)"]
Auth["Auth Routes<br/>/api/auth/*"]
Core["Core Routes<br/>/api/experiences, /api/bookings"]
Host["Host Routes<br/>/api/pricing, /api/channels"]
Admin["Admin Routes<br/>/api/admin/*, /api/feature-flags"]
B2B["B2B Routes<br/>/api/agents, /api/b2b-proposals"]
RT["SSE Route<br/>/api/sse"]
end
subgraph Middleware["Middleware Layer"]
AuthMW["Auth Middleware<br/>(RBAC)"]
Validation["Zod Validation"]
RateLimit["Rate Limiter"]
end
subgraph Services["Domain Services"]
BookingSvc["BookingService"]
ExpSvc["ExperienceService"]
UserSvc["UserService"]
ReviewSvc["ReviewService"]
end
subgraph Infrastructure["Infrastructure"]
EventBus["Domain Event Bus"]
SSEMgr["SSE Manager"]
PayEngine["Payment Engine<br/>(Stripe + IVA)"]
I18N["i18n Engine<br/>(5 locales)"]
end
subgraph Data["Data Layer"]
InMemory["InMemoryStore<br/>(data.ts)"]
PrismaSchema["Prisma Schema<br/>(25+ models)"]
end
end
subgraph External["External Services"]
Stripe["Stripe<br/>(Payments)"]
Strava["Strava API<br/>(Cycling)"]
Redis["Redis<br/>(Pub/Sub)"]
end
Pages --> |fetch| APILayer
Hooks --> |HTTP| APILayer
SSEClient --> |EventSource| RT
APILayer --> AuthMW
APILayer --> Validation
APILayer --> RateLimit
APILayer --> Services
Services --> Data
Services --> EventBus
EventBus --> SSEMgr
PayEngine --> Stripe
BookingSvc --> PayEngine
InMemory -.->|future migration| PrismaSchema
SSEMgr -.->|future| Redis
style Client fill:#fef3c7,stroke:#d97706
style NextJS fill:#f0fdf4,stroke:#16a34a
style External fill:#eff6ff,stroke:#2563eb
The codebase follows a 4-layer architecture with clear dependency rules:
graph LR
A["API Routes<br/>(src/app/api)"] --> B["Domain Services<br/>(src/lib/domains)"]
B --> C["Data Layer<br/>(src/lib/data.ts)"]
C --> D["Storage<br/>(src/lib/db.ts)"]
A --> E["Middleware<br/>(auth, validation,<br/>rate-limiting)"]
B --> F["Event Bus<br/>(src/lib/events)"]
F --> G["Subscribers<br/>(cross-feature reactions)"]
style A fill:#dbeafe
style B fill:#fef9c3
style C fill:#dcfce7
style D fill:#f3e8ff
| Layer | Location | Responsibility |
|---|---|---|
| API Routes | src/app/api/ |
HTTP handlers, request/response mapping, middleware application |
| Domain Services | src/lib/domains/ |
Business logic, domain event emission, orchestration |
| Data Layer | src/lib/data.ts |
CRUD operations, seed data, in-memory queries |
| Storage | src/lib/db.ts |
Generic InMemoryStore<T> (future: Prisma Client) |
| Events | src/lib/events/ |
Domain event bus, cross-feature subscriber wiring |
| Middleware | src/lib/auth-middleware.ts, api-utils.ts |
RBAC, Zod validation, rate limiting, error formatting |
The Prisma schema defines 25+ models organized into 10 domains:
erDiagram
User ||--o| HostProfile : "has"
User ||--o{ Booking : "makes"
User ||--o{ Review : "writes"
User ||--o{ SavedItinerary : "creates"
User ||--o{ Wishlist : "saves"
User ||--o{ Session : "authenticates"
User ||--o| UserLoyalty : "earns"
User ||--o| ReferralCode : "owns"
User ||--o| StravaConnection : "connects"
HostProfile ||--o{ Experience : "offers"
Experience ||--o{ Booking : "receives"
Experience ||--o{ Review : "rated by"
Experience ||--o{ Availability : "has slots"
Experience ||--o{ PricingRule : "priced by"
Experience ||--o{ CyclingRouteExperience : "linked to"
Booking ||--o| Payment : "paid via"
Booking ||--o| Review : "reviewed by"
CyclingRoute ||--o{ CyclingRouteExperience : "includes"
CyclingRoute ||--o{ ActivityLog : "tracked by"
GiftCard ||--o{ GiftCardRedemption : "redeemed"
GroupBooking ||--o{ GroupQuote : "quoted"
ReferralCode ||--o{ ReferralRedemption : "redeemed"
Conversation ||--o{ Message : "contains"
| Domain | Models |
|---|---|
| Auth | User, Session |
| Hosting | HostProfile, Experience, Availability |
| Booking | Booking, Payment |
| Reviews | Review |
| Wishlists | Wishlist, SavedItinerary |
| Cycling | CyclingRoute, CyclingRouteExperience, ActivityLog, CyclingChallenge |
| Messaging | Conversation, Message, Notification |
| Financial | PricingRule, GiftCard, GiftCardRedemption |
| Marketing | Campaign, GroupBooking, GroupQuote |
| Loyalty | ReferralCode, ReferralRedemption, UserLoyalty |
| Media | MediaUpload |
| Strava | StravaConnection |
| Cart | ItineraryCart |
A typical API request flows through the following layers:
sequenceDiagram
participant Client
participant Route as API Route Handler
participant RateLimit as Rate Limiter
participant Auth as Auth Middleware
participant Validate as Zod Validator
participant Service as Domain Service
participant Data as Data Layer
participant EventBus as Event Bus
participant SSE as SSE Manager
Client->>Route: POST /api/bookings
Route->>RateLimit: checkRateLimit()
RateLimit-->>Route: ✓ (or 429)
Route->>Auth: withAuth(handler)
Auth-->>Route: ✓ user attached (or 401/403)
Route->>Validate: validateBody(schema)
Validate-->>Route: ✓ typed data (or 400)
Route->>Service: bookingService.create(input)
Service->>Data: createBooking(input)
Data-->>Service: booking record
Service->>EventBus: emit("booking.created", payload)
EventBus->>SSE: notifyBookingCreated(hostId)
SSE-->>Client: SSE event push
Service-->>Route: booking
Route-->>Client: 201 { booking }
Cookie-based session auth with RBAC:
graph TD
A[Request] --> B{Has vr_session cookie?}
B -->|No| C{Route requires auth?}
C -->|Yes| D[401 Unauthorized]
C -->|No| E[Allow — optional auth]
B -->|Yes| F[Lookup session by token]
F --> G{Session valid & not expired?}
G -->|No| D
G -->|Yes| H[Load User from session]
H --> I{Route has role requirement?}
I -->|No| J[✓ Allow]
I -->|Yes| K{User role in allowed roles?}
K -->|Yes| J
K -->|No| L[403 Forbidden]
| Role | Access Level |
|---|---|
tourist |
Public + own bookings, reviews, wishlists |
host |
Tourist + own listings, pricing, analytics, host forum |
agent |
B2B portal, proposals, API keys |
admin |
Full platform access, moderation, feature flags |
/api/admin/* → admin only
/api/financial/* → host, admin
/api/revenue-intelligence → host, admin
/api/observability/* → admin only
/api/demand-intelligence → admin only
/api/guest-crm/* → admin only
/api/experiments → admin only
/api/feature-flags → admin only
/api/agents → agent, admin
/api/b2b-proposals → agent, admin
Dev Bypass: In development mode, append
?dev=trueto any authenticated route to bypass auth with a mock admin user.
The event bus implements a publish-subscribe pattern that connects all features:
graph LR
subgraph Emitters
BS["BookingService"]
RS["ReviewService"]
US["UserService"]
QR["QR Scan Handler"]
FS["Festival Handler"]
end
subgraph EventBus["Event Bus (eventBus)"]
Dispatch["dispatch + persist"]
end
subgraph Subscribers
CRM["CRM Segment Update"]
Loyalty["Loyalty Points Credit"]
Trust["Trust Score Recalc"]
Demand["Demand Intelligence"]
Campaign["Campaign Auto-Create"]
Obs["Observability Logger"]
end
BS -->|booking.created<br/>booking.confirmed<br/>booking.cancelled<br/>booking.completed| Dispatch
RS -->|review.submitted<br/>review.responded| Dispatch
US -->|user.registered| Dispatch
QR -->|qr.scanned| Dispatch
FS -->|festival.started| Dispatch
Dispatch -->|booking.completed| CRM
Dispatch -->|booking.completed| Loyalty
Dispatch -->|review.submitted| Trust
Dispatch -->|qr.scanned| Demand
Dispatch -->|festival.started| Campaign
Dispatch -->|*| Obs
style EventBus fill:#fef3c7,stroke:#d97706
| Category | Events |
|---|---|
| Booking | booking.created, booking.confirmed, booking.cancelled, booking.completed |
| Review | review.submitted, review.approved, review.responded |
| Payment | payment.succeeded, payment.refunded |
| Messaging | message.sent, message.read |
| Listing | listing.published, listing.updated |
| Campaign | campaign.started, campaign.ended, campaign.activated |
| User | user.registered, user.verified |
| CRM | crm.segment_changed |
| Trust | trust.updated |
| Loyalty | loyalty.tier_changed |
| Demand | demand.signal_detected |
| QR | qr.scanned |
| Festival | festival.started, festival.ended |
graph TD
A[Guest selects experience] --> B[Calculate price]
B --> C{PricingRules apply?}
C -->|seasonal/early-bird/group| D[Apply multiplier]
C -->|none| E[Base price]
D --> F[Calculate IVA]
E --> F
F --> G[Calculate payment split]
G --> H["Gross Amount"]
G --> I["Stripe Fee (2.9% + €0.25)"]
G --> J["Platform Commission (15%)"]
G --> K["Host Payout"]
G --> L["IVA on Commission (22%)"]
H --> M{Stripe configured?}
M -->|Yes| N[Create Checkout Session]
M -->|No| O[Simulated Payment]
N --> P[Stripe Webhook → booking.confirmed]
O --> P
P --> Q[Generate FatturaPA XML]
style F fill:#fef3c7
style G fill:#dcfce7
| Category | Rate |
|---|---|
| Agriturismi (accommodation) | 10% reduced |
| Cultura (cultural services) | 10% reduced |
| Corsi di Cucina, Vino, Cicloturismo, Natura | 22% standard |
SSE-based server→client push, chosen over WebSocket for Vercel serverless compatibility:
sequenceDiagram
participant Client
participant SSERoute as GET /api/sse
participant SSEManager
participant EventBus
participant Service as Domain Service
Client->>SSERoute: EventSource(?channel=user:abc)
SSERoute->>SSEManager: addClient(userId, channel)
SSEManager-->>Client: event: connected
loop Heartbeat (30s)
SSEManager-->>Client: : heartbeat
end
Service->>EventBus: emit(booking.created)
EventBus->>SSEManager: notifyBookingCreated(hostId)
SSEManager-->>Client: event: notification
| Channel | Purpose |
|---|---|
user:<id> |
Per-user notifications (bookings, messages) |
host:<id> |
Host-specific alerts (new bookings, reviews) |
admin |
Platform-wide admin events |
conversation:<id> |
Live chat messages |
experience:<id> |
Availability updates |
5 supported locales with path-based routing:
/ (Italian — default)
/en/* (English)
/de/* (German)
/fr/* (French)
/es/* (Spanish)
- Server-side:
Intl.DateTimeFormatandIntl.NumberFormatfor locale-aware formatting - Client-side:
useLocale()hook reads URL path → localStorage → default fallback - SEO:
<link rel="alternate" hreflang="...">tags generated per page - Translation: Dictionary-based lookup via
t(key, locale)helper
graph TB
subgraph Production
LB["Load Balancer / CDN"]
subgraph Container["Docker Container"]
App["Next.js App<br/>(SSR + API)"]
end
DB["PostgreSQL 16"]
Cache["Redis 7"]
Pay["Stripe API"]
end
LB --> App
App --> DB
App --> Cache
App --> Pay
style Production fill:#f0fdf4
| Environment | Database | Payments | Real-time |
|---|---|---|---|
| Development | SQLite (in-memory) | Simulated | In-process SSE |
| Docker | PostgreSQL 16 | Configurable | Redis pub/sub ready |
| Production | PostgreSQL 16 | Stripe live | Redis pub/sub |
The codebase includes preparatory abstractions for these upgrades:
- Prisma Migration —
BaseRepository<T>interface +InMemoryStorecan be swapped to Prisma Client with no consumer changes - Redis Pub/Sub — SSE Manager designed for multi-instance broadcast via Redis adapter
- Moonshot Features — Six experimental systems (destination graph, host guilds, mobility cloud, agentic concierge, provenance passport, impact exchange)
- Multi-Region — Region model and domain-scoped routing for expanding beyond Romagna