# Node.js Developer Guide — Calendar Booking Platform

**Runtime:** Node.js 20 LTS  
**Language:** TypeScript 5.x  
**Architecture:** MVC (Model – View – Controller)  
**Framework:** Express.js  
**Database:** MySQL 8 via TypeORM  
**Cache / Queue:** Redis + BullMQ

---

## Table of Contents

1. [Overview](#1-overview)
2. [Project Structure (MVC)](#2-project-structure-mvc)
3. [Getting Started](#3-getting-started)
4. [Configuration](#4-configuration)
5. [MVC Layer Responsibilities](#5-mvc-layer-responsibilities)
6. [Models Layer](#6-models-layer)
7. [Controllers Layer](#7-controllers-layer)
8. [Views Layer (API Responses)](#8-views-layer-api-responses)
9. [Routes and Middleware](#9-routes-and-middleware)
10. [Services (Domain Logic)](#10-services-domain-logic)
11. [Calendar Integration Services](#11-calendar-integration-services)
12. [Embed API for Third-Party Sites](#12-embed-api-for-third-party-sites)
13. [Background Jobs](#13-background-jobs)
14. [Authentication and Security](#14-authentication-and-security)
15. [Error Handling](#15-error-handling)
16. [Testing](#16-testing)
17. [Deployment](#17-deployment)

---

## 1. Overview

The Node.js backend is the REST API and embed server for the calendar booking platform. It follows **MVC**:

| Layer | Role in this API |
|-------|------------------|
| **Model** | TypeORM entities, repositories, database access |
| **View** | JSON response formatters (DTOs / serializers) — no server-rendered HTML except embed script |
| **Controller** | HTTP handlers — parse request, call services, return view |

Business logic lives in **Services** (invoked by controllers). This keeps controllers thin and models focused on persistence.

```mermaid
flowchart TB
    REQ[HTTP Request] --> MW[Middleware]
    MW --> CTRL[Controller]
    CTRL --> SVC[Service]
    SVC --> MODEL[Model / Repository]
    SVC --> EXT[External APIs]
    MODEL --> MYSQL[(MySQL)]
    SVC --> REDIS[(Redis)]
    CTRL --> VIEW[View / DTO Serializer]
    VIEW --> RES[JSON Response]
```

---

## 2. Project Structure (MVC)

```
calendar-api/
├── src/
│   ├── app.ts                    # Express app bootstrap
│   ├── server.ts                 # HTTP server entry
│   ├── config/
│   │   ├── database.ts
│   │   ├── redis.ts
│   │   ├── env.ts
│   │   └── oauth.ts
│   ├── models/                   # MODEL layer
│   │   ├── entities/             # TypeORM entities
│   │   ├── repositories/         # Custom query methods
│   │   └── index.ts
│   ├── controllers/              # CONTROLLER layer
│   │   ├── AuthController.ts
│   │   ├── UserController.ts
│   │   ├── AvailabilityController.ts
│   │   ├── EventTypeController.ts
│   │   ├── BookingController.ts
│   │   ├── CalendarController.ts
│   │   ├── PublicController.ts
│   │   └── EmbedController.ts
│   ├── views/                    # VIEW layer (serializers)
│   │   ├── UserView.ts
│   │   ├── BookingView.ts
│   │   ├── EventTypeView.ts
│   │   ├── SlotView.ts
│   │   └── EmbedView.ts
│   ├── services/                 # Domain logic
│   │   ├── AuthService.ts
│   │   ├── AvailabilityService.ts
│   │   ├── BookingService.ts
│   │   ├── CalendarSyncService.ts
│   │   ├── NotificationService.ts
│   │   ├── EmbedService.ts
│   │   └── calendars/
│   │       ├── GoogleCalendarProvider.ts
│   │       ├── MicrosoftCalendarProvider.ts
│   │       └── AppleCalendarProvider.ts
│   ├── routes/
│   │   ├── index.ts
│   │   ├── auth.routes.ts
│   │   ├── me.routes.ts
│   │   ├── public.routes.ts
│   │   ├── embed.routes.ts
│   │   └── oauth.routes.ts
│   ├── middleware/
│   │   ├── auth.middleware.ts
│   │   ├── validate.middleware.ts
│   │   ├── rateLimit.middleware.ts
│   │   ├── embedOrigin.middleware.ts
│   │   └── error.middleware.ts
│   ├── jobs/
│   │   ├── syncFreeBusy.job.ts
│   │   ├── refreshTokens.job.ts
│   │   ├── pushCalendarEvent.job.ts
│   │   └── sendReminder.job.ts
│   ├── dto/                      # Request validation schemas (Zod)
│   │   ├── auth.dto.ts
│   │   ├── booking.dto.ts
│   │   └── availability.dto.ts
│   ├── utils/
│   │   ├── uuid.ts
│   │   ├── crypto.ts
│   │   ├── timezone.ts
│   │   └── icsGenerator.ts
│   ├── types/
│   │   └── express.d.ts          # Extended Request types
│   └── migrations/
├── public/
│   └── embed/
│       └── loader.js             # Third-party embed script
├── tests/
│   ├── unit/
│   └── integration/
├── .env.example
├── tsconfig.json
├── package.json
└── Dockerfile
```

---

## 3. Getting Started

### Prerequisites

- Node.js 20+
- MySQL 8.0+
- Redis 7+

### Install and Run

```bash
cd calendar-api
npm install
cp .env.example .env
# Edit .env with DB, Redis, OAuth credentials

npm run migration:run
npm run dev        # ts-node-dev / nodemon
npm run build
npm start          # Production
```

### Scripts

| Script | Description |
|--------|-------------|
| `npm run dev` | Development with hot reload |
| `npm run build` | Compile TypeScript to `dist/` |
| `npm start` | Run compiled app |
| `npm run migration:run` | Apply MySQL migrations |
| `npm test` | Jest unit + integration tests |
| `npm run lint` | ESLint |

---

## 4. Configuration

### `.env.example`

```env
NODE_ENV=development
PORT=3000
APP_URL=http://localhost:4200
API_URL=http://localhost:3000

# MySQL
DB_HOST=localhost
DB_PORT=3306
DB_USER=calendar_app
DB_PASSWORD=dev_password
DB_NAME=calendar_booking

# Redis
REDIS_URL=redis://localhost:6379

# JWT
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=3600

# OAuth — Google
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/v1/oauth/callback/google

# OAuth — Microsoft
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
MICROSOFT_REDIRECT_URI=http://localhost:3000/v1/oauth/callback/microsoft

# Encryption (32-byte hex for AES-256)
TOKEN_ENCRYPTION_KEY=

# Email
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
EMAIL_FROM=noreply@book.example.com

# Embed
EMBED_SCRIPT_URL=http://localhost:3000/embed/loader.js
CORS_ALLOWED_ORIGINS=http://localhost:4200
```

---

## 5. MVC Layer Responsibilities

### Model

- TypeORM entity definitions mapping to MySQL tables (see [mysql.md](./mysql.md))
- Repository classes with query methods
- No HTTP or business-rule logic

### View

- Pure functions that map domain objects → API JSON
- Consistent response shapes across endpoints
- Hides internal fields (password hashes, encrypted tokens)

### Controller

- Extract params, body, query from `Request`
- Validate input via DTO/Zod middleware
- Call one service method per action
- Pass result through View and send response
- No direct database queries

---

## 6. Models Layer

### Entity Registration

```typescript
// src/models/index.ts
import { DataSource } from 'typeorm';
import { User } from './entities/User.entity';
import { Booking } from './entities/Booking.entity';
import { EventType } from './entities/EventType.entity';
// ... other entities

export const AppDataSource = new DataSource({
  ...databaseConfig,
  entities: [User, Booking, EventType /* ... */],
  migrations: ['dist/migrations/*.js'],
});
```

### Repository Pattern

```typescript
// src/models/repositories/BookingRepository.ts
import { Repository } from 'typeorm';
import { AppDataSource } from '../index';
import { Booking } from '../entities/Booking.entity';

export class BookingRepository {
  private repo: Repository<Booking>;

  constructor() {
    this.repo = AppDataSource.getRepository(Booking);
  }

  async findOverlapping(
    userId: string,
    start: Date,
    end: Date,
  ): Promise<Booking[]> {
    return this.repo
      .createQueryBuilder('b')
      .where('b.user_id = :userId', { userId })
      .andWhere('b.status IN (:...statuses)', { statuses: ['pending', 'confirmed'] })
      .andWhere('b.start_time < :end', { end })
      .andWhere('b.end_time > :start', { start })
      .setLock('pessimistic_write')
      .getMany();
  }

  async create(data: Partial<Booking>): Promise<Booking> {
    const booking = this.repo.create(data);
    return this.repo.save(booking);
  }
}
```

---

## 7. Controllers Layer

### Example: Public Booking Controller

```typescript
// src/controllers/PublicController.ts
import { Request, Response, NextFunction } from 'express';
import { BookingService } from '../services/BookingService';
import { AvailabilityService } from '../services/AvailabilityService';
import { PublicService } from '../services/PublicService';
import { BookingView } from '../views/BookingView';
import { SlotView } from '../views/SlotView';
import { EventTypeView } from '../views/EventTypeView';

export class PublicController {
  constructor(
    private publicService: PublicService,
    private availabilityService: AvailabilityService,
    private bookingService: BookingService,
  ) {}

  getEventPage = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { username, eventSlug } = req.params;
      const data = await this.publicService.getEventPage(username, eventSlug);
      res.json(EventTypeView.toPublicPage(data));
    } catch (err) {
      next(err);
    }
  };

  getSlots = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { username, eventSlug } = req.params;
      const { from, to, timezone } = req.query as Record<string, string>;
      const slots = await this.availabilityService.getAvailableSlots({
        username,
        eventSlug,
        from,
        to,
        guestTimezone: timezone,
      });
      res.json(SlotView.toList(slots, timezone));
    } catch (err) {
      next(err);
    }
  };

  createBooking = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { username, eventSlug } = req.params;
      const idempotencyKey = req.headers['idempotency-key'] as string;
      const embedSource = req.headers['x-embed-key'] as string | undefined;

      const booking = await this.bookingService.createPublicBooking({
        username,
        eventSlug,
        ...req.body,
        idempotencyKey,
        embedSource,
      });

      res.status(201).json(BookingView.toPublicConfirmation(booking));
    } catch (err) {
      next(err);
    }
  };
}
```

### Controller Registration

Controllers are instantiated once and injected with services (manual DI or lightweight container):

```typescript
// src/routes/public.routes.ts
import { Router } from 'express';
import { PublicController } from '../controllers/PublicController';
import { validate } from '../middleware/validate.middleware';
import { createBookingSchema } from '../dto/booking.dto';
import { publicBookingRateLimit } from '../middleware/rateLimit.middleware';

const router = Router();
const controller = new PublicController(/* services */);

router.get('/:username/:eventSlug', controller.getEventPage);
router.get('/:username/:eventSlug/slots', controller.getSlots);
router.post(
  '/:username/:eventSlug/bookings',
  publicBookingRateLimit,
  validate(createBookingSchema),
  controller.createBooking,
);

export default router;
```

---

## 8. Views Layer (API Responses)

Views are serializer modules — not HTML templates (except embed script).

```typescript
// src/views/BookingView.ts
import { Booking } from '../models/entities/Booking.entity';

export class BookingView {
  static toHostList(booking: Booking) {
    return {
      id: booking.id,
      eventType: {
        id: booking.eventType?.id,
        title: booking.eventType?.title,
      },
      guestName: booking.guestName,
      guestEmail: booking.guestEmail,
      startTime: booking.startTime.toISOString(),
      endTime: booking.endTime.toISOString(),
      guestTimezone: booking.guestTimezone,
      status: booking.status,
      customAnswers: booking.customAnswers,
      embedSource: booking.embedSource ?? null,
      createdAt: booking.createdAt.toISOString(),
    };
  }

  static toPublicConfirmation(booking: Booking & { cancelUrl: string }) {
    return {
      id: booking.id,
      status: booking.status,
      startTime: booking.startTime.toISOString(),
      endTime: booking.endTime.toISOString(),
      guestName: booking.guestName,
      guestEmail: booking.guestEmail,
      cancelUrl: booking.cancelUrl,
      confirmationMessage: `You're booked!`,
    };
  }

  static toList(bookings: Booking[]) {
    return {
      data: bookings.map(BookingView.toHostList),
    };
  }
}
```

### Standard Response Envelope

```typescript
// Success
{ "data": { ... } }

// List with pagination
{
  "data": [ ... ],
  "pagination": { "page": 1, "limit": 20, "total": 100 }
}

// Error
{
  "error": "slot_unavailable",
  "message": "This time slot is no longer available.",
  "details": {}
}
```

---

## 9. Routes and Middleware

### Route Map

| Prefix | Auth | Controller |
|--------|------|------------|
| `POST /v1/auth/register` | None | AuthController |
| `POST /v1/auth/login` | None | AuthController |
| `GET /v1/me/*` | JWT | UserController, etc. |
| `GET /v1/public/*` | None | PublicController |
| `GET /v1/embed/*` | Embed key + origin | EmbedController |
| `GET /v1/oauth/callback/*` | OAuth state | CalendarController |

### Middleware Stack

```typescript
// src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import routes from './routes';
import { errorHandler } from './middleware/error.middleware';

const app = express();

app.use(helmet());
app.use(cors({
  origin: (origin, cb) => {
    // Dynamic CORS for embed — see EmbedService
    cb(null, true);
  },
  credentials: true,
}));
app.use(express.json());
app.use('/v1', routes);
app.use('/embed', express.static('public/embed'));
app.use(errorHandler);

export default app;
```

### Validation Middleware (Zod)

```typescript
// src/middleware/validate.middleware.ts
import { ZodSchema } from 'zod';
import { Request, Response, NextFunction } from 'express';

export const validate = (schema: ZodSchema) =>
  (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: 'validation_error',
        message: 'Invalid request body',
        details: result.error.flatten(),
      });
    }
    req.body = result.data;
    next();
  };
```

---

## 10. Services (Domain Logic)

### BookingService — Core Flow

```typescript
// src/services/BookingService.ts
export class BookingService {
  async createPublicBooking(input: CreatePublicBookingInput): Promise<Booking> {
    const { username, eventSlug, startTime, idempotencyKey } = input;

    // 1. Idempotency check
    if (idempotencyKey) {
      const existing = await this.bookingRepo.findByIdempotencyKey(idempotencyKey);
      if (existing) return existing;
    }

    const eventPage = await this.publicService.getEventPage(username, eventSlug);
    const { user, eventType } = eventPage;

    const start = new Date(startTime);
    const end = addMinutes(start, eventType.durationMinutes);

    // 2. Transaction: lock + validate + insert
    return this.dataSource.transaction(async (manager) => {
      const overlaps = await this.bookingRepo.findOverlapping(user.id, start, end);
      if (overlaps.length > 0) {
        throw new AppError('slot_unavailable', 409);
      }

      const busy = await this.availabilityService.isSlotBusy(user.id, start, end);
      if (busy) {
        throw new AppError('slot_unavailable', 409);
      }

      const booking = await this.bookingRepo.create({
        id: generateUuid(),
        userId: user.id,
        eventTypeId: eventType.id,
        guestName: input.guestName,
        guestEmail: input.guestEmail,
        guestTimezone: input.guestTimezone,
        startTime: start,
        endTime: end,
        customAnswers: input.customAnswers ?? {},
        status: 'pending',
        cancelTokenHash: hashToken(generateCancelToken()),
        idempotencyKey,
        embedSource: input.embedSource,
      });

      // 3. Queue calendar write + email (async)
      await this.queue.add('pushCalendarEvent', { bookingId: booking.id });
      await this.queue.add('sendConfirmationEmail', { bookingId: booking.id });

      booking.status = 'confirmed';
      await this.bookingRepo.updateStatus(booking.id, 'confirmed');

      return { ...booking, cancelUrl: buildCancelUrl(booking) };
    });
  }
}
```

### AvailabilityService

Computes slots by merging:

1. Weekly rules + date overrides (host timezone)
2. Existing bookings (MySQL)
3. External busy blocks (Redis cache)

See [COMPLETE_DOCUMENTATION.md](./COMPLETE_DOCUMENTATION.md) § Availability Engine.

---

## 11. Calendar Integration Services

Provider interface for Google, Microsoft, and Apple:

```typescript
// src/services/calendars/CalendarProvider.interface.ts
export interface CalendarProvider {
  listCalendars(connection: CalendarConnection): Promise<ExternalCalendar[]>;
  getFreeBusy(connection: CalendarConnection, from: Date, to: Date): Promise<BusyInterval[]>;
  createEvent(connection: CalendarConnection, booking: Booking, eventType: EventType): Promise<string>;
  updateEvent(connection: CalendarConnection, externalEventId: string, booking: Booking): Promise<void>;
  deleteEvent(connection: CalendarConnection, externalEventId: string): Promise<void>;
  refreshToken(connection: CalendarConnection): Promise<TokenPair>;
}
```

Factory:

```typescript
// src/services/calendars/CalendarProviderFactory.ts
export function getProvider(provider: 'google' | 'microsoft' | 'apple'): CalendarProvider {
  switch (provider) {
    case 'google': return new GoogleCalendarProvider();
    case 'microsoft': return new MicrosoftCalendarProvider();
    case 'apple': return new AppleCalendarProvider();
  }
}
```

Full OAuth and API details: [CALENDAR_INTEGRATIONS.md](./CALENDAR_INTEGRATIONS.md).

---

## 12. Embed API for Third-Party Sites

The platform supports embedding the booking widget into external websites and applications.

### Architecture

```mermaid
sequenceDiagram
    participant Site as Third-Party Website
    participant Loader as embed/loader.js
    participant API as Node API
    participant Widget as Angular Embed App

    Site->>Loader: Load script tag
    Loader->>API: GET /v1/embed/config/{embedKey}
    API->>API: Validate Origin header
    API->>Loader: Config + theme
    Loader->>Widget: Mount iframe or inline component
    Widget->>API: GET slots, POST booking
    API->>Site: postMessage booking.complete
```

### Embed Script (`public/embed/loader.js`)

Third-party sites add:

```html
<div id="calendar-booking-widget"></div>
<script
  src="https://api.book.example.com/embed/loader.js"
  data-embed-key="emb_abc123xyz"
  data-mode="inline"
  async
></script>
```

**Display modes:**

| Mode | Behavior |
|------|----------|
| `inline` | Renders widget inside target `div` |
| `popup` | Floating button opens popup panel |
| `modal` | Full-screen overlay modal |

### Embed API Endpoints

```http
GET /v1/embed/config/:embedKey
Origin: https://partner-site.com
```

**Response:**

```json
{
  "embedKey": "emb_abc123xyz",
  "displayMode": "inline",
  "theme": {
    "primaryColor": "#4F46E5",
    "backgroundColor": "#FFFFFF"
  },
  "publicUrl": "/book/alice-smith/30min-intro",
  "iframeUrl": "https://book.example.com/embed/widget/emb_abc123xyz",
  "allowedPostMessageOrigins": ["https://partner-site.com"]
}
```

```http
GET /v1/embed/widget/:embedKey
```

Returns minimal HTML shell that bootstraps the Angular embed bundle (or redirects to iframe URL).

### Origin Validation Middleware

```typescript
// src/middleware/embedOrigin.middleware.ts
export const validateEmbedOrigin = async (req: Request, res: Response, next: NextFunction) => {
  const embedKey = req.params.embedKey;
  const origin = req.headers.origin ?? req.headers.referer;

  const config = await embedService.getConfig(embedKey);
  if (!config) {
    return res.status(404).json({ error: 'embed_not_found' });
  }

  if (!embedService.isOriginAllowed(origin, config.allowedOrigins)) {
    return res.status(403).json({ error: 'origin_not_allowed' });
  }

  req.embedConfig = config;
  next();
};
```

### postMessage Events (Widget → Parent Page)

```typescript
// Events sent from iframe to parent window
type EmbedEvent =
  | { type: 'calendar.embed.ready' }
  | { type: 'calendar.embed.slotSelected'; payload: { startTime: string } }
  | { type: 'calendar.embed.bookingComplete'; payload: { bookingId: string } }
  | { type: 'calendar.embed.closed' }
  | { type: 'calendar.embed.error'; payload: { message: string } };
```

Parent page listener:

```javascript
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://book.example.com') return;
  if (event.data.type === 'calendar.embed.bookingComplete') {
    console.log('Booking ID:', event.data.payload.bookingId);
  }
});
```

### Server-to-Server API Keys

For backend integrations (not browser embed):

```http
GET /v1/me/bookings
Authorization: Bearer cal_live_xxxxxxxx
```

API keys are stored hashed in MySQL (`api_keys` table). Scopes: `embed:read`, `bookings:read`, `public:book`.

### CORS for Embed

Dynamic CORS allows origins listed in `embed_configs.allowed_origins`:

```typescript
// src/services/EmbedService.ts
async getAllowedOrigins(): Promise<Set<string>> {
  const configs = await this.embedRepo.findAllActive();
  const origins = new Set<string>();
  for (const c of configs) {
    for (const o of c.allowedOrigins as string[]) {
      origins.add(o);
    }
  }
  return origins;
}
```

---

## 13. Background Jobs

Use **BullMQ** with Redis:

| Queue | Job | Trigger |
|-------|-----|---------|
| `calendar-sync` | `syncFreeBusy` | Cron every 10 min |
| `calendar-sync` | `refreshTokens` | Cron every 30 min |
| `calendar-sync` | `pushCalendarEvent` | On booking create |
| `calendar-sync` | `deleteCalendarEvent` | On cancel |
| `notifications` | `sendConfirmationEmail` | On booking create |
| `notifications` | `sendReminder` | Cron 24h / 1h before |

```typescript
// src/jobs/pushCalendarEvent.job.ts
export async function pushCalendarEvent(job: Job<{ bookingId: string }>) {
  const booking = await bookingRepo.findById(job.data.bookingId);
  const connection = await calendarRepo.getWriteTarget(booking.userId);
  const provider = getProvider(connection.provider);
  const eventId = await provider.createEvent(connection, booking, booking.eventType);
  await syncRepo.markSynced(booking.id, connection.id, eventId);
}
```

---

## 14. Authentication and Security

### JWT Auth (Host Dashboard)

```typescript
// src/middleware/auth.middleware.ts
export const requireAuth = (req: Request, res: Response, next: NextFunction) => {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'unauthorized' });
  }
  try {
    const payload = jwt.verify(header.slice(7), env.JWT_SECRET) as JwtPayload;
    req.userId = payload.sub;
    next();
  } catch {
    return res.status(401).json({ error: 'unauthorized' });
  }
};
```

### Security Checklist

| Item | Implementation |
|------|----------------|
| Password hashing | bcrypt, cost factor 12 |
| OAuth tokens | AES-256-GCM encrypted in MySQL |
| Rate limiting | `express-rate-limit` + Redis store |
| Helmet | Security headers |
| Input validation | Zod on all POST/PATCH bodies |
| SQL injection | TypeORM parameterized queries only |
| Embed origin | Whitelist per embed config |

---

## 15. Error Handling

```typescript
// src/middleware/error.middleware.ts
export class AppError extends Error {
  constructor(
    public code: string,
    public statusCode: number,
    message?: string,
  ) {
    super(message ?? code);
  }
}

export const errorHandler = (err: Error, req: Request, res: Response, _next: NextFunction) => {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      error: err.code,
      message: err.message,
    });
  }

  console.error(err);
  return res.status(500).json({
    error: 'internal_error',
    message: 'An unexpected error occurred.',
  });
};
```

---

## 16. Testing

### Unit Tests (Services)

```typescript
// tests/unit/AvailabilityService.test.ts
describe('AvailabilityService', () => {
  it('excludes slots that overlap existing bookings', async () => {
    // mock repository + busy cache
  });
});
```

### Integration Tests (Supertest)

```typescript
// tests/integration/public.booking.test.ts
describe('POST /v1/public/:user/:event/bookings', () => {
  it('returns 201 for valid slot', async () => {
    const res = await request(app)
      .post('/v1/public/alice/30min/bookings')
      .set('Idempotency-Key', uuid())
      .send({ startTime: '...', guestName: 'Bob', guestEmail: 'bob@test.com', guestTimezone: 'UTC' });
    expect(res.status).toBe(201);
  });

  it('returns 409 when slot taken', async () => { /* ... */ });
});
```

### Test Database

Use a separate MySQL database `calendar_booking_test`. Run migrations before test suite.

---

## 17. Deployment

### Docker

```dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "dist/server.js"]
```

### Production Checklist

- [ ] `NODE_ENV=production`
- [ ] MySQL connection pool configured
- [ ] Redis for cache + BullMQ workers (separate process recommended)
- [ ] OAuth redirect URIs match production domain
- [ ] `EMBED_SCRIPT_URL` points to CDN or API domain
- [ ] Health check: `GET /health`
- [ ] Migrations run on deploy (not `synchronize: true`)

### Process Layout

| Process | Role |
|---------|------|
| `api` | Express HTTP server |
| `worker` | BullMQ job consumer |
| `scheduler` | Cron job enqueuer |

---

## API Reference

Full endpoint documentation: [API_REFERENCE.md](./API_REFERENCE.md)

---

## Related Documents

- [MySQL Developer Guide](./mysql.md)
- [Angular Developer Guide](./angular.md)
- [Calendar Integrations](./CALENDAR_INTEGRATIONS.md)
- [Complete Documentation](./COMPLETE_DOCUMENTATION.md)
