# KADi Reservation API Discovery Report

**Date:** 2026-05-24  
**Repo:** `https://github.com/KADiCon-UG/kadicon.git` (shallow clone, main HEAD)  
**Analyst:** Aria sub-agent (claude-sonnet-4-6)  
**Purpose:** Deep API mapping for Restaurant Durrani Next.js integration — BFF/server-side, no API keys in browser.

---

## Tech Stack (confirmed from repo)

| Layer | Technology |
|-------|-----------|
| Runtime | Node.js |
| Framework | NestJS (v10+) on Fastify adapter |
| ORM | TypeORM (PostgreSQL) |
| Queue | BullMQ (Redis-backed) |
| Auth | Custom JWT (HS256) + HTTP Basic for Google Booking Server |
| WebSocket | Socket.io (port 3001) |
| Storage | AWS S3 |
| Email | Nodemailer (template-based, .ics attachments) |
| Logging | Pino / nestjs-pino |
| Observability | OpenTelemetry tracing |
| Billing | Stripe |
| API Docs | Swagger/OpenAPI auto-generated at /api |
| Build | Nx monorepo |

---

## 1. API Surface Mapping

All routes are prefixed with `/api`. Two namespaces matter for integration: **`customer/*`** (no JWT, tenant-header only) and **`reservation/*`** (JWT-protected, admin only).

### 1.1 Customer-Facing Routes (BFF-relevant)

These are the routes the Next.js BFF should call. They require **no JWT**, only the `X-TENANT-ID` header.

| Method | Path | Purpose | Auth |
|--------|------|---------|------|
| `GET` | `/api/customer/settings/widget` | Widget config: displayName, logo, banner, defaultReservationTime, schedule, restaurant contact | `X-TENANT-ID` header |
| `GET` | `/api/customer/settings/app` | Customer app display settings only | `X-TENANT-ID` header |
| `GET` | `/api/customer/reservation/slots/:date` | Available 30-min time slots for a date. `?timezone=Europe/Berlin` optional. Returns `{ time: {hours, minutes}, available: boolean }[]` | `X-TENANT-ID` header |
| `POST` | `/api/customer/reservation` | Create a reservation (widget flow). Returns `{ confirmed: boolean }` | `X-TENANT-ID` header |
| `GET` | `/api/customer/reservation/verify/:tableId/:query` | Verify reservation by table + customer name (QR check-in use case) | `X-TENANT-ID` header |
| `GET` | `/api/customer/menu/category` | Visible, non-archived menu categories | `X-TENANT-ID` header |
| `GET` | `/api/customer/menu/item` | Visible menu items with categories | `X-TENANT-ID` header |

### 1.2 Admin/Dashboard Routes (JWT required — not for BFF)

For completeness — these drive the restaurant dashboard, not the guest website.

| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/reservation` | List all reservations (`?date=`, `?tableIds=`) |
| `GET` | `/api/reservation/open` | Open reservations only |
| `GET` | `/api/reservation/:id` | Single reservation by UUID |
| `POST` | `/api/reservation` | Create reservation (admin source) |
| `PUT` | `/api/reservation/:id` | Update reservation |
| `PATCH` | `/api/reservation/:id/confirm` | Confirm → state: `approved` |
| `PATCH` | `/api/reservation/:id/unconfirm` | Revert to `unconfirmed` |
| `PATCH` | `/api/reservation/:id/reject` | Reject reservation |
| `GET` | `/api/reservation/search/:query` | Full-text search by name/email/phone |
| `GET` | `/api/reservation/slots/:date` | Same slot engine, JWT version |
| `GET` | `/api/reservation/tables/free` | Count of free tables right now |
| `GET` | `/api/reservation/:id/review` | Trigger review email to customer |
| `GET` | `/api/reservation/customer/:field/:query` | Customer autocomplete |
| `GET` | `/api/reservation/order` | Reservations linked to order IDs |

### 1.3 Auth Routes

| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/api/auth/login` | Email+password → JWT `access_token` |
| `POST` | `/api/auth/verify` | Start email verification |
| `POST` | `/api/auth/verify/token` | Confirm email verification token |
| `POST` | `/api/auth/password/reset` | Request password reset email |
| `POST` | `/api/auth/password/reset/new` | Submit new password with token |
| `PUT` | `/api/auth/password` | Update password (JWT required) |

### 1.4 Google Booking Server Routes (not for website BFF)

Used by Google's reservation infrastructure. Protected by HTTP Basic auth.

| Method | Path |
|--------|------|
| `GET` | `/v3/HealthCheck` |
| `POST` | `/v3/BatchAvailabilityLookup` |
| `POST` | `/v3/CreateBooking` |
| `POST` | `/v3/UpdateBooking` |
| `POST` | `/v3/GetBookingStatus` |

**Note on widget JS:** The file at `reservation-widget.kadicon.de/production/KADiReservation.js` loaded as `zone.js v0.16.1` — the Angular custom element's actual API client code is in a separate minified bundle. The endpoints it uses are the same `/api/customer/*` routes above.

---

## 2. Auth Flows

### 2.1 Customer/Widget Flow (BFF use)

**No JWT needed.** Authentication is purely via HTTP header:

```
X-TENANT-ID: 4e22f973-36d5-4e8c-b8a3-c85a7b87cf10
```

The `TenantHeaderInterceptor` reads this header and throws `406 Not Acceptable` if absent. It sets the tenant context via `SessionStore` (AsyncLocalStorage). All TypeORM queries automatically filter by `tenantId` in the base repository — data isolation is enforced at ORM level.

**For the BFF:** Next.js server-side code injects `X-TENANT-ID: <tenant-uuid>` on every outbound KADi call. The tenant ID is public (embedded in the widget tag), so it lives safely in `KADI_TENANT_ID` server env var. It must never appear in browser bundles — the BFF holds it and proxies.

### 2.2 Admin Flow (not for BFF)

1. `POST /api/auth/login` → `{ access_token: "<JWT>" }`
2. All requests: `Authorization: Bearer <JWT>`
3. `MultiTenancyInterceptor` validates `X-TENANT-ID` header matches `tenantId` in JWT payload.

JWT payload:
```typescript
{
  id: string;
  firstName: string;
  lastName: string;
  email: string;
  emailVerified: boolean;
  tenantId: string;      // scopes all DB queries
  preferedLanguage?: string;
}
```

JWT secret: `process.env.JWT_SECRET` (HS256). Fallback dev value exists in code. Expiry: not in `.env.sample` — **unknown / needs clarification**.

### 2.3 Google Booking Server Auth

HTTP Basic auth with credentials from `AuthModuleOptions.googleBookingServer.{username,password}`. Irrelevant for website integration.

---

## 3. Booking Lifecycle

### 3.1 State Machine

```
[Widget POST] ──────────────────────────────────────────────────────────────────────
                                                                                    |
                                         auto-approve conditions met?               |
                                              YES → state: approved                 |
                                              NO  → state: unconfirmed              |
                                                                                    |
Restaurant admin actions (via dashboard):                                           |
  unconfirmed ──confirm──→ approved ──seat──→ seated ──done──→ done                |
  unconfirmed/approved ──reject──→ rejected                                         |
  approved ──unconfirm──→ unconfirmed                                               |
```

**States (enum `ReservationState`):**
| State | Description |
|-------|-------------|
| `unconfirmed` | Default on widget create (if no auto-approve) |
| `approved` | Confirmed by restaurant; Google bookings always land here |
| `rejected` | Declined by restaurant, or Google CANCELED |
| `seated` | Customer physically seated (dashboard) |
| `done` | Completed visit (dashboard) |

**Open states** (used in availability): `[unconfirmed, approved, seated]`  
**Approved-open** (table capacity calculations): `[approved, seated]`

### 3.2 Widget Creation — Request/Response

```
POST /api/customer/reservation
Headers: X-TENANT-ID: <uuid>
Content-Type: application/json

Body:
{
  "noOfPersons": 2,                    // min: 1, required
  "startDate": "2024-12-24T18:00:00Z", // ISO8601, must be future
  "endDate":   "2024-12-24T19:30:00Z", // ISO8601, must be > startDate
  "customerData": {
    "name": "Max Mustermann",          // 2-240 chars, required
    "email": "max@example.com",        // optional; used for confirmation email
    "phoneNumber": "+49 1234 567890"   // optional
  },
  "comment": "Window seat please",     // optional
  "tableId": null,                     // UUID, optional hint (backend auto-assigns)
  "tableCombinationId": null           // UUID, optional
}

Response 200:
{ "confirmed": true }   // auto-approved
{ "confirmed": false }  // pending manual confirmation
```

### 3.3 Auto-Approval Logic

Configured per tenant in `IAutoApproveReservationSettings`:
1. Feature `enabled = true`
2. `noOfPersons <= guestLimitPerReservation` (0 = unlimited)
3. `noOfPersons + currentGuestsInInterval <= guestLimitOverall` (0 = unlimited)
4. At least one table can be assigned

When auto-approved: customer gets confirmation email with `.ics` calendar attachment.  
When not auto-approved: restaurant gets email with Accept/Decline links to dashboard.

### 3.4 Cancellation

**No customer-facing cancel endpoint exists.** Only admin routes:
- `PATCH /api/reservation/:id/reject` (JWT required)
- Google: `UpdateBooking` with `status: CANCELED`

> **Action required:** Confirm with KADiCon whether a customer cancel endpoint (`POST /api/customer/reservation/:id/cancel`) is planned. Until then, guests must call the restaurant to cancel.

---

## 4. Availability Engine

### 4.1 Time Slot Endpoint

```
GET /api/customer/reservation/slots/2024-12-24?timezone=Europe/Berlin
Headers: X-TENANT-ID: <uuid>

Response 200:
[
  { "time": { "hours": 17, "minutes": 0 },  "available": true  },
  { "time": { "hours": 17, "minutes": 30 }, "available": true  },
  { "time": { "hours": 18, "minutes": 0 },  "available": false },
  ...
]
// Empty array if day is marked closed in schedule
```

**Algorithm:**
1. Fetches restaurant schedule for the weekday (`getDaySchedule`)
2. If `closed`, returns `[]`
3. Iterates schedule rules, generates 30-min slots from opening to closing time
4. For each slot: counts tables occupied by `approved`/`seated` reservations overlapping the 30-min window
5. `available = occupiedTables.length !== totalTableCount`
6. Past slots (relative to current time, timezone-aware) are marked `available: false`

**Critical note:** Availability is binary at table level — a slot is full only when ALL tables are taken. Party size is not considered here. The Google Booking Server does party-size filtering separately. The website BFF should note this.

**Recommended BFF pattern:**
```typescript
// Compute endDate using defaultReservationTime from widget settings
const endDate = new Date(startDate.getTime() + defaultReservationTime * 60_000);
```

---

## 5. Rate Limits / Anti-Bot

**None found in code.** No throttle guards, no captcha, no reCAPTCHA. Specific findings:
- `enableCors({ origin: '*', methods: '*' })` — wide-open CORS
- `@fastify/helmet` — security headers only (no rate limiting)
- No `@nestjs/throttler` usage anywhere

> **Risk:** `POST /api/customer/reservation` is unprotected from bot abuse. The BFF provides one layer by keeping `X-TENANT-ID` server-side. **Strongly recommend:** Add BFF-level rate limiting (e.g., Upstash Ratelimit + Vercel Edge Middleware, or `express-rate-limit` equivalent) — e.g., 5 booking attempts per IP per hour.

---

## 6. Error Contracts

NestJS default error shape (no custom exception filter found):

```json
// Validation error (class-validator)
{
  "statusCode": 400,
  "message": ["noOfPersons must not be less than 1", "customerData.name must be longer than 2 characters"],
  "error": "Bad Request"
}

// Single-message error
{
  "statusCode": 422,
  "message": "Selected Time Slot is not available",
  "error": "Unprocessable Entity"
}

// Not found
{
  "statusCode": 404,
  "message": "Reservation not found",
  "error": "Not Found"
}

// Missing tenant header
{
  "statusCode": 406,
  "message": "TENANT NOT FOUND",
  "error": "Not Acceptable"
}
```

**Status code mapping:**

| HTTP Status | Trigger |
|-------------|---------|
| `200` | Success with body |
| `204` | Success, no body (confirm/unconfirm/reject) |
| `400` | Validation failure, past date, start > end, tenant not configured |
| `401` | Missing/invalid JWT |
| `404` | Entity not found |
| `406` | Missing `X-TENANT-ID` header |
| `409` | Duplicate email on registration |
| `422` | Business logic failure (no table available) |
| `500` | Unexpected server error |

**BFF guidance:** Map 422 → user-friendly "No availability for this time" message. Map 406 → server config error (never show to user, alert ops).

---

## 7. Timeout / Retry Behavior

**No explicit timeout or retry in the backend codebase.** DB config has `DB_TIMEOUT=30000` (30s). For the BFF:

- Slot queries (`GET .../slots/...`): `AbortSignal.timeout(8_000)` — fast DB lookup
- Booking creation (`POST .../reservation`): `AbortSignal.timeout(12_000)` — table assignment logic
- Settings (`GET .../settings/widget`): `AbortSignal.timeout(5_000)` — cache at ISR level

**Idempotency warning:** The backend has a `// TODO: Use idempotency token to validate duplicate request` comment in `createBooking`. **Duplicate POSTs create duplicate reservations.** Do not retry booking POSTs automatically. If a request times out, redirect user to a "check your email / call us" fallback.

---

## 8. Webhooks

**KADi does not emit outbound webhooks.** Internal events propagate via Socket.io (port 3001) to connected admin clients only. `EventService` emits `reservations_updated` events internally.

**Implications for the website:**
1. No push notification when restaurant accepts/rejects a pending reservation
2. Widget response `{ confirmed: boolean }` is the only synchronous signal
3. Customer confirmation email (`.ics`) is the primary confirmation channel

**Workarounds:**
- For `confirmed: false` flows: display "Reservation request sent — you will receive a confirmation email" copy
- No polling needed for the guest-facing flow
- If status tracking becomes a product requirement: request KADi add a webhook delivery or a guest-accessible status endpoint

---

## 9. Logging (Server-Side Recommendations)

KADi backend uses Pino structured logging. The `@RequestResponseLogging()` decorator (on Google Booking Server endpoints) logs full request+response including headers.

**Log these in the Next.js BFF on every KADi call:**

```typescript
{
  kadi_endpoint: "POST /api/customer/reservation",
  tenant_id: process.env.KADI_TENANT_ID,
  request_id: crypto.randomUUID(),   // trace across BFF + KADi
  duration_ms: 347,
  http_status: 200,
  // Booking-specific:
  booking_confirmed: true,
  party_size: 2,
  start_date: "2024-12-24T18:00:00Z",
  // Error-specific:
  kadi_error_message: "Selected Time Slot is not available",
  kadi_error_status: 422
}
```

**Do NOT log:** `customerData.email`, `customerData.phoneNumber` (PII). Log `customerData.name` only in internal/ops logs with appropriate data classification.

---

## 10. Multi-Location Support

**Architecture is fully multi-tenant by design.** Every entity has `tenantId: string (UUID)`. The `BaseEntityRepository` automatically scopes every query via `SessionStore` (AsyncLocalStorage). No cross-tenant leakage is architecturally possible.

**Per-restaurant website (Restaurant Durrani):**
- One `tenantId` = one restaurant = one `X-TENANT-ID` header value
- Widget embed tag `tenant-id="4e22f973-36d5-4e8c-b8a3-c85a7b87cf10"` is Durrani's UUID

**If expanding to multiple locations later:**
- Each location gets its own tenant UUID from KADiCon
- BFF routes per location (subdomain or path) to the correct `KADI_TENANT_ID` env var
- API calls are identical — only the header changes
- No schema changes needed; the API is already multi-tenant-ready

---

## 11. Mock Adapter Strategy

The TypeScript interface the mock adapter should implement (real HTTP adapter swaps in without frontend changes):

```typescript
// lib/kadi/types.ts

export interface KadiTime {
  hours: number;
  minutes: number;
}

export interface KadiTimeSlot {
  time: KadiTime;
  available: boolean;
}

export interface KadiCustomerData {
  name: string;
  email?: string;
  phoneNumber?: string;
}

export interface KadiCreateReservationRequest {
  noOfPersons: number;
  startDate: string;   // ISO8601
  endDate: string;     // ISO8601
  customerData: KadiCustomerData;
  comment?: string;
}

export interface KadiCreateReservationResponse {
  confirmed: boolean;  // true = auto-approved, false = pending restaurant confirmation
}

export interface KadiWidgetSettings {
  displayName: string;
  logoUrl?: string;
  bannerUrl?: string;
  defaultReservationTime: number;  // minutes — use to compute endDate
  schedule?: unknown;              // unknown/needs clarification on full shape
  restaurant: {
    email: string;
    phone: string;
    address: string;
  };
}

export class KadiApiError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly kadiMessage: string | string[],
    public readonly kadiError?: string
  ) {
    super(Array.isArray(kadiMessage) ? kadiMessage.join('; ') : kadiMessage);
    this.name = 'KadiApiError';
  }
}

/** The primary adapter interface — BFF and mock both implement this */
export interface KadiReservationAdapter {
  getWidgetSettings(): Promise<KadiWidgetSettings>;
  getTimeSlots(date: string, timezone?: string): Promise<KadiTimeSlot[]>;
  createReservation(request: KadiCreateReservationRequest): Promise<KadiCreateReservationResponse>;
}
```

**Mock adapter:**

```typescript
// lib/kadi/mock-adapter.ts
import type { KadiReservationAdapter, KadiTimeSlot, KadiWidgetSettings, KadiCreateReservationRequest, KadiCreateReservationResponse } from './types';

export class KadiMockAdapter implements KadiReservationAdapter {
  async getWidgetSettings(): Promise<KadiWidgetSettings> {
    return {
      displayName: 'Restaurant Durrani',
      defaultReservationTime: 90,
      restaurant: {
        email: 'info@restaurant-durrani.de',
        phone: '+49 6051 XXXXXX',
        address: 'Mustergasse 1, 63571 Gelnhausen',
      },
    };
  }

  async getTimeSlots(_date: string, _timezone = 'Europe/Berlin'): Promise<KadiTimeSlot[]> {
    const slots: KadiTimeSlot[] = [];
    for (let h = 17; h <= 21; h++) {
      for (const m of [0, 30]) {
        slots.push({ time: { hours: h, minutes: m }, available: Math.random() > 0.3 });
      }
    }
    return slots;
  }

  async createReservation(req: KadiCreateReservationRequest): Promise<KadiCreateReservationResponse> {
    await new Promise(r => setTimeout(r, 800)); // simulate latency
    return { confirmed: req.noOfPersons <= 4 };
  }
}
```

**Real BFF adapter:**

```typescript
// lib/kadi/http-adapter.ts
import { KadiApiError, KadiReservationAdapter, KadiWidgetSettings, KadiTimeSlot, KadiCreateReservationRequest, KadiCreateReservationResponse } from './types';

export class KadiHttpAdapter implements KadiReservationAdapter {
  private readonly baseUrl = process.env.KADI_API_URL!;
  private readonly tenantId = process.env.KADI_TENANT_ID!;

  private headers() {
    return { 'Content-Type': 'application/json', 'X-TENANT-ID': this.tenantId };
  }

  private async kfetch<T>(path: string, init?: RequestInit): Promise<T> {
    const res = await fetch(`${this.baseUrl}${path}`, {
      ...init,
      headers: { ...this.headers(), ...init?.headers },
      signal: AbortSignal.timeout(12_000),
    });
    if (!res.ok) {
      const body = await res.json().catch(() => ({}));
      throw new KadiApiError(res.status, body.message ?? 'KADi error', body.error);
    }
    return res.json() as Promise<T>;
  }

  getWidgetSettings() {
    return this.kfetch<KadiWidgetSettings>('/api/customer/settings/widget');
  }

  getTimeSlots(date: string, timezone?: string) {
    const qs = timezone ? `?timezone=${encodeURIComponent(timezone)}` : '';
    return this.kfetch<KadiTimeSlot[]>(`/api/customer/reservation/slots/${date}${qs}`);
  }

  createReservation(req: KadiCreateReservationRequest) {
    return this.kfetch<KadiCreateReservationResponse>('/api/customer/reservation', {
      method: 'POST',
      body: JSON.stringify(req),
    });
  }
}
```

**Factory (env-switched):**

```typescript
// lib/kadi/index.ts
import { KadiMockAdapter } from './mock-adapter';
import { KadiHttpAdapter } from './http-adapter';

export const kadi: KadiReservationAdapter =
  process.env.KADI_MOCK === 'true' ? new KadiMockAdapter() : new KadiHttpAdapter();

export * from './types';
```

---

## Open Questions / Needs Clarification

| # | Question | Impact |
|---|----------|--------|
| 1 | **Production base URL** of the KADi backend? (`KADI_API_URL` value for staging + prod) | Critical, blocks BFF setup |
| 2 | **Customer cancellation endpoint** — planned? Currently only admin can reject. | Product decision; affects booking flow copy |
| 3 | **JWT expiry** in production? (absent from `.env.sample`) | Needed if BFF ever calls admin routes |
| 4 | **Full `ScheduleSettings` / `TimingRule` shape?** (referenced but not fully traced) | Needed if BFF renders opening hours display |
| 5 | **Rate limiting at infra level** (reverse proxy / Cloudflare)? Code has none. | Security posture decision |
| 6 | **Is `reservation-widget.kadicon.de` the same NestJS backend?** (confirms base URL) | BFF target URL validation |
| 7 | **`defaultReservationTime` — per-tenant or global?** How to get it? (`/api/customer/settings/widget` confirms it's tenant-specific) | `endDate` computation |
| 8 | **`tableId` / `tableCombinationId` in create request** — should the website ever send these? (Likely no; backend auto-assigns) | Simplifies BFF contract |

---

## Summary

The KADi backend is a well-structured NestJS/Fastify monolith with full multi-tenancy enforced at the ORM layer. For the Restaurant Durrani website, the relevant API surface is exactly **3 BFF calls**: widget settings (cache via ISR), time slots (per date), and create reservation. All require only the `X-TENANT-ID` header — no JWT, no API key in the browser. Booking states are `unconfirmed → approved → seated → done` plus `rejected`. Auto-approval is configurable server-side. No webhooks exist; confirmation is synchronous + email. No rate limiting is in-code — the BFF must add it. The mock adapter interface is directly derivable from source; frontend development can start immediately against `KADI_MOCK=true`.
