Skip to content

온라인 예약 시스템 (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:

ConceptLocationPurpose
KV Token Reservationprego_ai/workers/src/lib/kv-reservation.tsEstimate → Reserve → Commit billing credits for chat
DO Wallet Reserveprego_ai/workers/src/routes/chat.tsCredit pre-authorization for AI token consumption

There is no business-facing reservation/booking product for calendars, appointments, or resources.

Frappe/ERPNext has existing DocTypes that could integrate:

DocTypePurposeIntegration Potential
EventCalendar eventsSync confirmed bookings
AppointmentScheduled meetingsCustomer-facing appointments
CustomerCustomer recordsLink reservations to customers
EmployeeStaff recordsResource 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

ConceptDescription
ResourceBookable entity (room, person, equipment, service)
SlotAvailable time window for a resource
ReservationCustomer’s claim on a slot
Availability RuleRecurring availability patterns (business hours, exceptions)
LockTemporary exclusive hold during checkout flow

4. Data Model

4.1 D1 Schema

-- Migration: 0032_reservation_system.sql
-- Bookable resources
CREATE 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 policies
CREATE 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 log
CREATE 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)
);
-- Indexes
CREATE 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)

packages/contracts/src/reservation.ts
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.3
info:
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

workers/reservation/src/durable-objects/slot-lock.ts
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

workers/reservation/wrangler.toml
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:

  1. D1 migration 0032_reservation_system.sql
  2. Reservation Worker (workers/reservation/)
  3. @platform/contracts reservation schemas
  4. client-web /reservations page
  5. 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:

  1. SlotLock Durable Object
  2. Lock integration in POST /reservations
  3. Admin pages: /cp/reservations/*
  4. 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:

  1. Availability rules CRUD APIs
  2. Slot generation service
  3. Admin UI for availability management
  4. 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:

  1. Queue consumer for Frappe sync
  2. Calendar OAuth integration
  3. iCal endpoint (GET /reservations/{code}.ics)
  4. 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:

  1. RRULE parser and slot generator
  2. Stripe payment intent integration
  3. Widget as Web Component (<prego-booking>)
  4. Waitlist with auto-notification

8. Frappe Integration

8.1 Queue-Based Sync

workers/reservation/src/queues/frappe-sync.ts
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

packages/prego-booking-widget/src/index.ts
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

MetricSourceAlert Threshold
Reservation success rateD1 aggregation< 95%
Lock contention rateDO metrics> 20%
Average booking timeD1 timestamps> 60 seconds
Cancellation rateD1 aggregation> 30%
No-show rateD1 aggregation> 20%

10.2 Admin Dashboard Pages

PageRouteFeatures
Overview/cp/reservationsToday’s bookings, stats
Calendar View/cp/reservations/calendarVisual calendar
Resources/cp/reservations/resourcesResource CRUD
Settings/cp/reservations/settingsPolicies, notifications
Reports/cp/reservations/reportsUtilization, 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

ComponentFree TierEstimated Monthly (1000 reservations/day)
Cloudflare Worker100K/day~$5
Durable Objects1M requests~$0.50
D1 Storage5GB~$0.75
D1 Reads25B~$0.25
D1 Writes50M~$1.00
R2 (attachments)10GB~$0.15
Queue1M ops~$0.40
Total-~$8-15/month

한국어 {#korean}

1. 개요

이 문서는 Cloudflare 인프라(Workers, Durable Objects, D1)를 활용한 멀티테넌트 온라인 예약 시스템 구축을 위한 상세 기획서입니다. 시스템은 회의실, 예약, 장비, 서비스 등의 리소스 예약을 지원하며, Durable Objects를 통한 실시간 슬롯 잠금, D1 영속성, Frappe/ERPNext 통합을 제공합니다.

2. 현재 상태

현재 PREGO는 사용량/빌링 패턴에서만 “reservation” 용어를 사용합니다:

개념위치용도
KV Token Reservationprego_ai/workers/src/lib/kv-reservation.ts채팅 빌링 크레딧
DO Wallet Reserveprego_ai/workers/src/routes/chat.tsAI 토큰 사전 승인

비즈니스 대면 예약/부킹 제품은 없습니다.

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 기반 동기화:

  1. 예약 확정 시 SYNC_QUEUE에 메시지 발행
  2. Queue Consumer가 Frappe API 호출
  3. Frappe Event DocType 생성
  4. 실패 시 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 Objects100만 요청~$0.50
D1 스토리지5GB~$0.75
D1 읽기250억~$0.25
D1 쓰기5000만~$1.00
R2 (첨부)10GB~$0.15
Queue100만 ops~$0.40
합계-~$8-15/월

9. 성공 지표

지표목표
예약 성공률> 99%
평균 예약 완료 시간< 30초
더블 부킹0%
취소율< 20%
위젯 로드 시간< 2초

Help