온라인 예약 시스템 (Reservation) Cloudflare 기반 구축 기획서
문서: prego-reservation-system-cloudflare-plan.md
버전: Draft v1.0
작성일: 2026-04-14
상태: Planning
English {#english}
1. Executive Summary
This document outlines a plan to build a multi-tenant online reservation system on Cloudflare infrastructure. The system will support resource booking (meeting rooms, appointments, equipment, services) with real-time slot locking via Durable Objects, D1 persistence, and integration with Frappe/ERPNext for customer and event management.
2. Current State Analysis
2.1 Existing “Reservation” Concepts
Currently, PREGO uses “reservation” terminology for usage/billing patterns only:
| Concept | Location | Purpose |
|---|---|---|
| KV Token Reservation | prego_ai/workers/src/lib/kv-reservation.ts | Estimate → Reserve → Commit billing credits for chat |
| DO Wallet Reserve | prego_ai/workers/src/routes/chat.ts | Credit pre-authorization for AI token consumption |
There is no business-facing reservation/booking product for calendars, appointments, or resources.
2.2 Related Frappe Capabilities
Frappe/ERPNext has existing DocTypes that could integrate:
| DocType | Purpose | Integration Potential |
|---|---|---|
| Event | Calendar events | Sync confirmed bookings |
| Appointment | Scheduled meetings | Customer-facing appointments |
| Customer | Customer records | Link reservations to customers |
| Employee | Staff records | Resource availability (person-type) |
3. Proposed Architecture
3.1 System Architecture
flowchart TB
subgraph clients["Client Applications"]
CW["client-web<br/>(Next.js)"]
AW["admin-web<br/>(Management)"]
Embed["Embeddable Widget<br/>(iframe/Web Component)"]
API["Third-party API"]
end
subgraph gateway["Edge Layer"]
Zuplo["Zuplo Gateway<br/>/{companyId}/reservations/*"]
end
subgraph workers["Cloudflare Workers"]
RW["Reservation Worker<br/>(Hono)"]
DO["Durable Objects<br/>(SlotLock per resource-time)"]
Queue["Cloudflare Queue<br/>(notifications, sync)"]
end
subgraph storage["Storage"]
D1["D1: resources<br/>slots, reservations<br/>availability_rules"]
R2["R2: Attachments<br/>(confirmation PDFs)"]
KV["KV: Session cache<br/>availability cache"]
end
subgraph integrations["Integrations"]
Frappe["Frappe/ERPNext<br/>(Customer, Event)"]
Calendar["Google/Outlook Calendar"]
Payment["Stripe<br/>(prepayment)"]
Notify["Email/SMS<br/>(confirmations)"]
end
CW --> Zuplo
AW --> Zuplo
Embed --> Zuplo
API --> Zuplo
Zuplo --> RW
RW --> DO
RW --> D1
RW --> KV
RW --> Queue
Queue --> Frappe
Queue --> Calendar
Queue --> Notify
RW --> Payment
3.2 Core Concepts
| Concept | Description |
|---|---|
| Resource | Bookable entity (room, person, equipment, service) |
| Slot | Available time window for a resource |
| Reservation | Customer’s claim on a slot |
| Availability Rule | Recurring availability patterns (business hours, exceptions) |
| Lock | Temporary exclusive hold during checkout flow |
4. Data Model
4.1 D1 Schema
-- Migration: 0032_reservation_system.sql
-- Bookable resourcesCREATE TABLE resources ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL, -- room | person | equipment | service description TEXT, capacity INTEGER DEFAULT 1, duration_minutes INTEGER DEFAULT 60, -- Default slot duration buffer_minutes INTEGER DEFAULT 0, -- Buffer between bookings timezone TEXT DEFAULT 'UTC', metadata TEXT, -- JSON: custom fields active INTEGER DEFAULT 1, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP);
-- Availability rules (recurring patterns)CREATE TABLE availability_rules ( id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, rule_type TEXT NOT NULL, -- weekly | specific_date | exception day_of_week INTEGER, -- 0-6 for weekly rules specific_date TEXT, -- YYYY-MM-DD for date rules start_time TEXT NOT NULL, -- HH:MM (local time) end_time TEXT NOT NULL, -- HH:MM (local time) is_available INTEGER DEFAULT 1, -- 1=available, 0=blocked priority INTEGER DEFAULT 0, -- Higher priority overrides valid_from TEXT, -- Rule validity start valid_until TEXT, -- Rule validity end created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (resource_id) REFERENCES resources(id));
-- Individual time slots (generated or custom)CREATE TABLE slots ( id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, start_time TEXT NOT NULL, -- ISO8601 UTC end_time TEXT NOT NULL, -- ISO8601 UTC status TEXT DEFAULT 'available', -- available | reserved | confirmed | blocked price REAL, -- Optional pricing max_attendees INTEGER DEFAULT 1, current_attendees INTEGER DEFAULT 0, metadata TEXT, -- JSON: custom slot data created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (resource_id) REFERENCES resources(id));
-- Reservations (bookings)CREATE TABLE reservations ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, resource_id TEXT NOT NULL, slot_id TEXT NOT NULL, customer_id TEXT, -- Frappe Customer reference customer_name TEXT NOT NULL, customer_email TEXT NOT NULL, customer_phone TEXT, attendee_count INTEGER DEFAULT 1, status TEXT DEFAULT 'pending', -- pending | confirmed | cancelled | completed | no_show payment_status TEXT, -- pending | paid | refunded payment_intent_id TEXT, -- Stripe payment intent total_amount REAL, currency TEXT DEFAULT 'USD', notes TEXT, internal_notes TEXT, confirmation_code TEXT UNIQUE, -- e.g., "PRG-ABC123" confirmed_at TEXT, cancelled_at TEXT, cancellation_reason TEXT, reminder_sent INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, trace_id TEXT, -- Funnel correlation FOREIGN KEY (resource_id) REFERENCES resources(id), FOREIGN KEY (slot_id) REFERENCES slots(id));
-- Temporary locks (for DO coordination, optional D1 backup)CREATE TABLE slot_locks ( slot_id TEXT PRIMARY KEY, reservation_id TEXT NOT NULL, locked_at TEXT NOT NULL, expires_at TEXT NOT NULL, FOREIGN KEY (slot_id) REFERENCES slots(id));
-- Cancellation policiesCREATE TABLE cancellation_policies ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, name TEXT NOT NULL, hours_before INTEGER NOT NULL, -- Hours before start time refund_percentage INTEGER NOT NULL, -- 0-100 active INTEGER DEFAULT 1, created_at TEXT DEFAULT CURRENT_TIMESTAMP);
-- Audit logCREATE TABLE reservation_audit_log ( id TEXT PRIMARY KEY, reservation_id TEXT NOT NULL, action TEXT NOT NULL, -- created | confirmed | cancelled | modified | reminder_sent actor_type TEXT NOT NULL, -- customer | admin | system actor_id TEXT, old_values TEXT, -- JSON new_values TEXT, -- JSON ip_address TEXT, user_agent TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (reservation_id) REFERENCES reservations(id));
-- IndexesCREATE INDEX idx_resources_tenant ON resources(tenant_id);CREATE INDEX idx_resources_type ON resources(type);CREATE INDEX idx_slots_resource ON slots(resource_id);CREATE INDEX idx_slots_time ON slots(start_time, end_time);CREATE INDEX idx_slots_status ON slots(status);CREATE INDEX idx_reservations_tenant ON reservations(tenant_id);CREATE INDEX idx_reservations_customer ON reservations(customer_email);CREATE INDEX idx_reservations_status ON reservations(status);CREATE INDEX idx_reservations_slot ON reservations(slot_id);CREATE INDEX idx_reservations_confirmation ON reservations(confirmation_code);CREATE INDEX idx_availability_resource ON availability_rules(resource_id);4.2 Zod Schemas (@platform/contracts)
import { z } from 'zod';
// --- Enums ---
export const resourceTypeSchema = z.enum(['room', 'person', 'equipment', 'service']);export const slotStatusSchema = z.enum(['available', 'reserved', 'confirmed', 'blocked']);export const reservationStatusSchema = z.enum(['pending', 'confirmed', 'cancelled', 'completed', 'no_show']);export const paymentStatusSchema = z.enum(['pending', 'paid', 'refunded']);
// --- Resource ---
export const resourceSchema = z.object({ id: z.string().uuid(), tenantId: z.string(), name: z.string().min(1).max(200), type: resourceTypeSchema, description: z.string().max(2000).optional(), capacity: z.number().int().min(1).default(1), durationMinutes: z.number().int().min(5).max(1440).default(60), bufferMinutes: z.number().int().min(0).max(120).default(0), timezone: z.string().default('UTC'), metadata: z.record(z.unknown()).optional(), active: z.boolean().default(true),});
export const createResourceSchema = resourceSchema.omit({ id: true, tenantId: true });export const updateResourceSchema = createResourceSchema.partial();
// --- Availability Rule ---
export const availabilityRuleSchema = z.object({ id: z.string().uuid(), resourceId: z.string().uuid(), ruleType: z.enum(['weekly', 'specific_date', 'exception']), dayOfWeek: z.number().int().min(0).max(6).optional(), // 0=Sunday specificDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), startTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM endTime: z.string().regex(/^\d{2}:\d{2}$/), isAvailable: z.boolean().default(true), priority: z.number().int().default(0), validFrom: z.string().datetime().optional(), validUntil: z.string().datetime().optional(),});
export const createAvailabilityRuleSchema = availabilityRuleSchema.omit({ id: true });
// --- Slot ---
export const slotSchema = z.object({ id: z.string().uuid(), resourceId: z.string().uuid(), startTime: z.string().datetime(), endTime: z.string().datetime(), status: slotStatusSchema, price: z.number().min(0).optional(), maxAttendees: z.number().int().min(1).default(1), currentAttendees: z.number().int().min(0).default(0), metadata: z.record(z.unknown()).optional(),});
export const slotQuerySchema = z.object({ resourceId: z.string().uuid(), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), timezone: z.string().optional(), status: slotStatusSchema.optional(),});
// --- Reservation ---
export const reservationSchema = z.object({ id: z.string().uuid(), tenantId: z.string(), resourceId: z.string().uuid(), slotId: z.string().uuid(), customerId: z.string().optional(), customerName: z.string().min(1).max(200), customerEmail: z.string().email(), customerPhone: z.string().max(50).optional(), attendeeCount: z.number().int().min(1).default(1), status: reservationStatusSchema, paymentStatus: paymentStatusSchema.optional(), paymentIntentId: z.string().optional(), totalAmount: z.number().min(0).optional(), currency: z.string().length(3).default('USD'), notes: z.string().max(2000).optional(), internalNotes: z.string().max(2000).optional(), confirmationCode: z.string(), confirmedAt: z.string().datetime().optional(), cancelledAt: z.string().datetime().optional(), cancellationReason: z.string().max(500).optional(), createdAt: z.string().datetime(), traceId: z.string().max(64).optional(),});
export const createReservationSchema = z.object({ resourceId: z.string().uuid(), slotId: z.string().uuid(), customerName: z.string().min(1).max(200), customerEmail: z.string().email(), customerPhone: z.string().max(50).optional(), attendeeCount: z.number().int().min(1).default(1), notes: z.string().max(2000).optional(), traceId: z.string().max(64).optional(),});
export const cancelReservationSchema = z.object({ reason: z.string().max(500).optional(),});
// --- Lock ---
export const acquireLockRequestSchema = z.object({ slotId: z.string().uuid(), reservationId: z.string().uuid(), ttlSeconds: z.number().int().min(60).max(900).default(300),});
export const acquireLockResponseSchema = z.object({ success: z.boolean(), expiresAt: z.string().datetime().optional(), holder: z.string().optional(), // If lock failed, who holds it});5. API Design
5.1 OpenAPI Routes (prego-zuplo)
# config/37-reservation-system.oas.json (new file)
openapi: 3.0.3info: title: Prego Reservation API version: 1.0.0
paths: # --- Public APIs (customer-facing) ---
/{companyId}/reservations/resources: get: operationId: listPublicResources summary: List available resources for booking tags: [Reservation - Public] parameters: - $ref: '#/components/parameters/companyId' - name: type in: query schema: $ref: '#/components/schemas/ResourceType' responses: '200': description: List of bookable resources content: application/json: schema: type: array items: $ref: '#/components/schemas/PublicResource'
/{companyId}/reservations/resources/{resourceId}/slots: get: operationId: getAvailableSlots summary: Get available slots for a resource tags: [Reservation - Public] parameters: - $ref: '#/components/parameters/companyId' - $ref: '#/components/parameters/resourceId' - name: startDate in: query required: true schema: type: string format: date - name: endDate in: query required: true schema: type: string format: date - name: timezone in: query schema: type: string default: UTC responses: '200': description: Available slots content: application/json: schema: type: array items: $ref: '#/components/schemas/Slot'
/{companyId}/reservations: post: operationId: createReservation summary: Create a new reservation tags: [Reservation - Public] parameters: - $ref: '#/components/parameters/companyId' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateReservationRequest' responses: '201': description: Reservation created content: application/json: schema: $ref: '#/components/schemas/Reservation' '409': description: Slot no longer available '422': description: Validation error
/{companyId}/reservations/{confirmationCode}: get: operationId: getReservationByCode summary: Get reservation by confirmation code tags: [Reservation - Public] parameters: - $ref: '#/components/parameters/companyId' - name: confirmationCode in: path required: true schema: type: string - name: email in: query required: true schema: type: string format: email responses: '200': description: Reservation details content: application/json: schema: $ref: '#/components/schemas/Reservation' '404': description: Reservation not found
/{companyId}/reservations/{confirmationCode}/cancel: post: operationId: cancelReservation summary: Cancel a reservation tags: [Reservation - Public] parameters: - $ref: '#/components/parameters/companyId' - name: confirmationCode in: path required: true schema: type: string requestBody: content: application/json: schema: $ref: '#/components/schemas/CancelReservationRequest' responses: '200': description: Reservation cancelled '400': description: Cannot cancel (policy violation)
# --- Admin APIs ---
/v1/admin/reservations/resources: get: operationId: adminListResources summary: List all resources (admin) tags: [Reservation - Admin] security: - apiKey: [] responses: '200': description: All resources for tenant post: operationId: adminCreateResource summary: Create a new resource tags: [Reservation - Admin] security: - apiKey: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateResourceRequest' responses: '201': description: Resource created
/v1/admin/reservations/resources/{resourceId}: get: operationId: adminGetResource summary: Get resource details tags: [Reservation - Admin] security: - apiKey: [] patch: operationId: adminUpdateResource summary: Update resource tags: [Reservation - Admin] security: - apiKey: [] delete: operationId: adminDeleteResource summary: Delete resource tags: [Reservation - Admin] security: - apiKey: []
/v1/admin/reservations/resources/{resourceId}/availability: get: operationId: adminListAvailabilityRules summary: List availability rules tags: [Reservation - Admin] security: - apiKey: [] post: operationId: adminCreateAvailabilityRule summary: Create availability rule tags: [Reservation - Admin] security: - apiKey: []
/v1/admin/reservations: get: operationId: adminListReservations summary: List all reservations tags: [Reservation - Admin] security: - apiKey: [] parameters: - name: status in: query schema: $ref: '#/components/schemas/ReservationStatus' - name: resourceId in: query schema: type: string - name: startDate in: query schema: type: string format: date - name: endDate in: query schema: type: string format: date
/v1/admin/reservations/{reservationId}: get: operationId: adminGetReservation summary: Get reservation details tags: [Reservation - Admin] security: - apiKey: [] patch: operationId: adminUpdateReservation summary: Update reservation tags: [Reservation - Admin] security: - apiKey: []
/v1/admin/reservations/{reservationId}/confirm: post: operationId: adminConfirmReservation summary: Manually confirm reservation tags: [Reservation - Admin] security: - apiKey: []
/v1/admin/reservations/{reservationId}/cancel: post: operationId: adminCancelReservation summary: Cancel reservation (admin) tags: [Reservation - Admin] security: - apiKey: []6. Durable Objects Implementation
6.1 SlotLock Durable Object
import { DurableObject } from 'cloudflare:workers';
interface LockState { locked: boolean; holderId?: string; expiresAt?: number;}
export class SlotLock extends DurableObject { private state: LockState = { locked: false };
constructor(state: DurableObjectState, env: Env) { super(state, env); }
async fetch(request: Request): Promise<Response> { const url = new URL(request.url);
switch (url.pathname) { case '/acquire': return this.acquireLock(request); case '/release': return this.releaseLock(request); case '/status': return this.getStatus(); default: return new Response('Not found', { status: 404 }); } }
private async acquireLock(request: Request): Promise<Response> { const { reservationId, ttlSeconds = 300 } = await request.json() as { reservationId: string; ttlSeconds?: number; };
// Clean up expired lock if (this.state.locked && this.state.expiresAt && Date.now() > this.state.expiresAt) { this.state = { locked: false }; }
// Check if already locked by someone else if (this.state.locked && this.state.holderId !== reservationId) { return Response.json( { success: false, holder: this.state.holderId, message: 'Slot is currently held by another reservation' }, { status: 409 } ); }
// Acquire or extend lock const expiresAt = Date.now() + ttlSeconds * 1000; this.state = { locked: true, holderId: reservationId, expiresAt, };
// Set alarm for automatic cleanup await this.ctx.storage.setAlarm(expiresAt);
return Response.json({ success: true, expiresAt: new Date(expiresAt).toISOString(), }); }
private async releaseLock(request: Request): Promise<Response> { const { reservationId } = await request.json() as { reservationId: string };
if (!this.state.locked) { return Response.json({ success: true, message: 'Lock was not held' }); }
if (this.state.holderId !== reservationId) { return Response.json( { success: false, message: 'Not lock holder' }, { status: 403 } ); }
this.state = { locked: false }; await this.ctx.storage.deleteAlarm();
return Response.json({ success: true }); }
private getStatus(): Response { return Response.json({ locked: this.state.locked, holderId: this.state.holderId, expiresAt: this.state.expiresAt ? new Date(this.state.expiresAt).toISOString() : null, }); }
async alarm(): Promise<void> { // Auto-release on TTL expiry this.state = { locked: false }; }}6.2 Wrangler Configuration
name = "prego-reservation"main = "src/index.ts"compatibility_date = "2024-01-01"
[[d1_databases]]binding = "DB"database_name = "prego-reservation"database_id = "xxx"
[[kv_namespaces]]binding = "CACHE"id = "xxx"
[[r2_buckets]]binding = "ATTACHMENTS"bucket_name = "prego-reservation-attachments"
[[queues.producers]]queue = "reservation-notifications"binding = "NOTIFICATION_QUEUE"
[[queues.producers]]queue = "reservation-sync"binding = "SYNC_QUEUE"
[[durable_objects.bindings]]name = "SLOT_LOCK"class_name = "SlotLock"
[[migrations]]tag = "v1"new_classes = ["SlotLock"]7. Implementation Phases
Phase 1: MVP (3 sprints)
Scope:
- D1 schema (resources, slots, reservations)
- Reservation Worker basic CRUD
- Public APIs (list resources, get slots, create reservation)
- Simple slot generation (no rules engine yet)
- Email confirmation via existing Email platform
Deliverables:
- D1 migration
0032_reservation_system.sql - Reservation Worker (
workers/reservation/) @platform/contractsreservation schemas- client-web
/reservationspage - Basic confirmation email template
Success Criteria:
- Customer can view available slots
- Customer can make a reservation
- Confirmation email sent
- Reservation visible in admin console
Phase 2: Concurrency & Locking (2 sprints)
Scope:
- Durable Object SlotLock implementation
- Lock acquire/release in reservation flow
- Optimistic D1 fallback
- Admin reservation management
Deliverables:
- SlotLock Durable Object
- Lock integration in
POST /reservations - Admin pages:
/cp/reservations/* - Cancellation flow with refund calculation
Success Criteria:
- No double bookings under concurrent load
- 100 concurrent requests for same slot result in exactly 1 reservation
- Lock auto-releases after 5 minutes
Phase 3: Availability Rules (2 sprints)
Scope:
- Availability rules engine (weekly, specific date, exceptions)
- On-demand slot generation
- Rolling window optimization (30 days)
- Timezone-aware slot display
Deliverables:
- Availability rules CRUD APIs
- Slot generation service
- Admin UI for availability management
- Timezone selector in public UI
Success Criteria:
- Weekly recurring availability works correctly
- Holiday exceptions override regular hours
- Slots generated on-demand for requested date range
Phase 4: Integrations (2 sprints)
Scope:
- Frappe Event sync (Queue-based)
- Google/Outlook calendar sync (read availability)
- iCal export
- SMS reminders
Deliverables:
- Queue consumer for Frappe sync
- Calendar OAuth integration
- iCal endpoint (
GET /reservations/{code}.ics) - Reminder cron job
Success Criteria:
- Confirmed reservation creates Frappe Event
- Google Calendar busy times blocked in slots
- Customer receives reminder 24h before
Phase 5: Advanced Features (2 sprints)
Scope:
- Recurring reservations (RRULE support)
- Stripe prepayment
- Embeddable widget
- Waitlist
Deliverables:
- RRULE parser and slot generator
- Stripe payment intent integration
- Widget as Web Component (
<prego-booking>) - Waitlist with auto-notification
8. Frappe Integration
8.1 Queue-Based Sync
interface FrappeSyncMessage { type: 'reservation_confirmed' | 'reservation_cancelled'; reservationId: string; tenantId: string; data: { resourceName: string; customerName: string; customerEmail: string; startTime: string; endTime: string; };}
export async function handleFrappeSync( message: FrappeSyncMessage, env: Env): Promise<void> { const { tenantId, type, data } = message;
// Get Frappe credentials const tenantConfig = await getTenantErpConfig(env.DB, tenantId); if (!tenantConfig?.erp_url) return;
const apiKey = await resolveErpApiKey(tenantConfig, env);
if (type === 'reservation_confirmed') { // Create Frappe Event await fetch(`${tenantConfig.erp_url}/api/resource/Event`, { method: 'POST', headers: { 'Authorization': `token ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ subject: `Reservation: ${data.resourceName}`, starts_on: data.startTime, ends_on: data.endTime, event_category: 'Reservation', description: `Customer: ${data.customerName} (${data.customerEmail})`, }), }); }
if (type === 'reservation_cancelled') { // Update or delete corresponding Event // ... implementation }}8.2 Customer Lookup
// Look up or create Frappe Customer during reservation
async function ensureFrappeCustomer( email: string, name: string, tenantConfig: TenantErpConfig, apiKey: string): Promise<string | null> { // Try to find existing customer const searchUrl = new URL(`${tenantConfig.erp_url}/api/resource/Customer`); searchUrl.searchParams.set('filters', JSON.stringify([['email_id', '=', email]]));
const response = await fetch(searchUrl.toString(), { headers: { 'Authorization': `token ${apiKey}` }, });
const data = await response.json();
if (data.data?.length > 0) { return data.data[0].name; }
// Create new customer const createResponse = await fetch(`${tenantConfig.erp_url}/api/resource/Customer`, { method: 'POST', headers: { 'Authorization': `token ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ customer_name: name, email_id: email, customer_type: 'Individual', }), });
const created = await createResponse.json(); return created.data?.name || null;}9. Embeddable Widget
9.1 Web Component
class PregoBookingWidget extends HTMLElement { private shadow: ShadowRoot; private config: { tenantId: string; resourceId?: string; apiUrl: string; primaryColor?: string; locale?: string; };
constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); }
connectedCallback() { this.config = { tenantId: this.getAttribute('tenant-id') || '', resourceId: this.getAttribute('resource-id') || undefined, apiUrl: this.getAttribute('api-url') || 'https://api.pregoi.com', primaryColor: this.getAttribute('primary-color') || '#0070f3', locale: this.getAttribute('locale') || 'en', };
this.render(); this.loadResources(); }
private render() { this.shadow.innerHTML = ` <style> :host { display: block; font-family: system-ui, sans-serif; --primary: ${this.config.primaryColor}; } .container { /* ... */ } .calendar { /* ... */ } .slot { /* ... */ } .slot.available { cursor: pointer; background: #e8f5e9; } .slot.available:hover { background: var(--primary); color: white; } .slot.booked { background: #ffebee; cursor: not-allowed; } </style> <div class="container"> <div class="loading">Loading...</div> </div> `; }
private async loadResources() { // Fetch and display resources/calendar // ... implementation }}
customElements.define('prego-booking', PregoBookingWidget);9.2 Usage
<!-- On external website --><script src="https://cdn.pregoi.com/widget/booking.js"></script>
<prego-booking tenant-id="tenant-abc123" resource-id="room-xyz" api-url="https://api.pregoi.com" primary-color="#ff6b00" locale="ko"></prego-booking>10. Monitoring and Analytics
10.1 Metrics
| Metric | Source | Alert Threshold |
|---|---|---|
| Reservation success rate | D1 aggregation | < 95% |
| Lock contention rate | DO metrics | > 20% |
| Average booking time | D1 timestamps | > 60 seconds |
| Cancellation rate | D1 aggregation | > 30% |
| No-show rate | D1 aggregation | > 20% |
10.2 Admin Dashboard Pages
| Page | Route | Features |
|---|---|---|
| Overview | /cp/reservations | Today’s bookings, stats |
| Calendar View | /cp/reservations/calendar | Visual calendar |
| Resources | /cp/reservations/resources | Resource CRUD |
| Settings | /cp/reservations/settings | Policies, notifications |
| Reports | /cp/reservations/reports | Utilization, revenue |
11. Security Considerations
11.1 API Security
- Public APIs: Rate limited (30/min per IP), no auth required
- Admin APIs: Tenant API key required
- Confirmation Lookup: Requires email + confirmation code
- CORS: Widget origin whitelist per tenant
11.2 Data Protection
- PII: Customer data scoped by tenant, encrypted at rest
- Audit Log: All modifications tracked with actor
- Retention: Completed reservations archived after 2 years
12. Cost Estimation
| Component | Free Tier | Estimated Monthly (1000 reservations/day) |
|---|---|---|
| Cloudflare Worker | 100K/day | ~$5 |
| Durable Objects | 1M requests | ~$0.50 |
| D1 Storage | 5GB | ~$0.75 |
| D1 Reads | 25B | ~$0.25 |
| D1 Writes | 50M | ~$1.00 |
| R2 (attachments) | 10GB | ~$0.15 |
| Queue | 1M ops | ~$0.40 |
| Total | - | ~$8-15/month |
한국어 {#korean}
1. 개요
이 문서는 Cloudflare 인프라(Workers, Durable Objects, D1)를 활용한 멀티테넌트 온라인 예약 시스템 구축을 위한 상세 기획서입니다. 시스템은 회의실, 예약, 장비, 서비스 등의 리소스 예약을 지원하며, Durable Objects를 통한 실시간 슬롯 잠금, D1 영속성, Frappe/ERPNext 통합을 제공합니다.
2. 현재 상태
현재 PREGO는 사용량/빌링 패턴에서만 “reservation” 용어를 사용합니다:
| 개념 | 위치 | 용도 |
|---|---|---|
| KV Token Reservation | prego_ai/workers/src/lib/kv-reservation.ts | 채팅 빌링 크레딧 |
| DO Wallet Reserve | prego_ai/workers/src/routes/chat.ts | AI 토큰 사전 승인 |
비즈니스 대면 예약/부킹 제품은 없습니다.
3. 제안 아키텍처
위 영어 섹션의 Mermaid 다이어그램 참조.
핵심 개념:
| 개념 | 설명 |
|---|---|
| Resource | 예약 가능한 엔티티 (회의실, 사람, 장비, 서비스) |
| Slot | 리소스의 사용 가능한 시간 창 |
| Reservation | 슬롯에 대한 고객의 청구권 |
| Availability Rule | 반복 가용성 패턴 (영업 시간, 예외) |
| Lock | 체크아웃 흐름 중 임시 독점 보유 |
4. 구현 단계
Phase 1: MVP (3 스프린트)
- D1 스키마 (resources, slots, reservations)
- Reservation Worker 기본 CRUD
- 공개 API (리소스 목록, 슬롯 조회, 예약 생성)
- 간단한 슬롯 생성 (규칙 엔진 없음)
- 기존 Email 플랫폼을 통한 이메일 확인
Phase 2: 동시성 및 잠금 (2 스프린트)
- Durable Object SlotLock 구현
- 예약 흐름에 잠금 획득/해제 통합
- 낙관적 D1 폴백
- 관리자 예약 관리
Phase 3: 가용성 규칙 (2 스프린트)
- 가용성 규칙 엔진 (주간, 특정 날짜, 예외)
- 온디맨드 슬롯 생성
- 롤링 윈도우 최적화 (30일)
- 타임존 인식 슬롯 표시
Phase 4: 통합 (2 스프린트)
- Frappe Event 동기화 (Queue 기반)
- Google/Outlook 캘린더 동기화
- iCal 내보내기
- SMS 리마인더
Phase 5: 고급 기능 (2 스프린트)
- 반복 예약 (RRULE 지원)
- Stripe 선결제
- 임베드 가능한 위젯
- 대기자 명단
5. Durable Object: SlotLock
슬롯별 잠금을 위한 Durable Object 구현:
/acquire: 잠금 획득 (TTL 기본 5분)/release: 잠금 해제/status: 현재 잠금 상태 조회alarm(): TTL 만료 시 자동 해제
동시성 보장:
- 동일 슬롯에 대한 100개 동시 요청 → 정확히 1개 예약만 성공
- 잠금 5분 후 자동 해제 (체크아웃 미완료 시)
6. Frappe 통합
Queue 기반 동기화:
- 예약 확정 시
SYNC_QUEUE에 메시지 발행 - Queue Consumer가 Frappe API 호출
- Frappe Event DocType 생성
- 실패 시 3회 재시도 + Dead Letter Queue
Customer 연동:
- 예약 시 이메일로 기존 Customer 조회
- 없으면 새 Customer 생성
reservations.customer_id에 Frappe Customer 참조 저장
7. 임베드 가능한 위젯
Web Component로 구현:
<prego-booking tenant-id="tenant-abc123" resource-id="room-xyz" api-url="https://api.pregoi.com" primary-color="#ff6b00" locale="ko"></prego-booking>기능:
- 리소스 선택
- 캘린더 뷰
- 슬롯 선택
- 예약 폼
- 확인 화면
8. 비용 추정
| 컴포넌트 | 무료 티어 | 예상 월간 (일 1000 예약) |
|---|---|---|
| Cloudflare Worker | 일 10만 | ~$5 |
| Durable Objects | 100만 요청 | ~$0.50 |
| D1 스토리지 | 5GB | ~$0.75 |
| D1 읽기 | 250억 | ~$0.25 |
| D1 쓰기 | 5000만 | ~$1.00 |
| R2 (첨부) | 10GB | ~$0.15 |
| Queue | 100만 ops | ~$0.40 |
| 합계 | - | ~$8-15/월 |
9. 성공 지표
| 지표 | 목표 |
|---|---|
| 예약 성공률 | > 99% |
| 평균 예약 완료 시간 | < 30초 |
| 더블 부킹 | 0% |
| 취소율 | < 20% |
| 위젯 로드 시간 | < 2초 |
Related Documents
- Platform overview
- Phase 2 — Corporate platform roadmap (Phase 2)
- prego_ai kv-reservation.ts — Existing billing reservation pattern
- Durable Objects documentation