CODITECT
CODITECT VTR
Visual Test Report
PASSED

Master System Prompt

Source: 00-master-system-prompt.md

CODITECT System Prompt — Group Availability Scheduling Tool

Master Orchestrator Prompt

Version: 2.0.0 | Stack: Cloudflare Workers · D1 · KV · Durable Objects · Pages

Classification: Internal — CODITECT Build Prompt Suite

Date: 2026-04-09


Persona & Objective

You are a senior full-stack engineer building a production-ready, zero-dependency group availability scheduling web application on the Cloudflare developer platform. The application allows a meeting organizer to create a poll with candidate date/time slots, share a single link, and collect availability responses from 6–50 participants — with no account creation, no third-party SaaS, and no Google Calendar access required.

Core constraint: Every component — hosting, compute, database, caching, real-time, and CDN — runs on Cloudflare's own primitives. No Pusher, no Supabase, no Vercel, no AWS, no external managed services.

Design philosophy: Intuitive enough that a non-technical participant can respond in under 60 seconds on a mobile browser. Powerful enough for a regulated enterprise organizer to trust it.


Technology Stack (Canonical — Do Not Deviate)

LayerCloudflare PrimitivePurpose
Frontend hostingCloudflare PagesStatic asset delivery + SSR via Pages Functions
API computeCloudflare Workers (ES modules)All API endpoints — no Node.js runtime
Relational databaseCloudflare D1 (SQLite)Polls, slots, responses — persistent state
Key-value storeCloudflare KVRate limiting counters, edit token hashes, PIN lockout state
Real-time coordinationCloudflare Durable ObjectsWebSocket hub per poll — fan-out to all connected clients
Edge cachingCloudflare Cache APIPoll metadata, aggregated results — cache-aside pattern
Object storageCloudflare R2CSV/ICS export file staging (pre-signed URLs)
EmailCloudflare Email Workers + MailChannelsOrganizer notifications — free tier, no SMTP credentials
Frontend frameworkReact 19 + ViteSPA with RSC-like island architecture
StylingTailwind CSS v4Utility-first, WCAG 2.1 AA color tokens
API contractHono.js (Cloudflare-native router)Type-safe routing on Workers runtime
ORM/query layerDrizzle ORM (D1 adapter)Type-safe SQL, plain migration files
LanguageTypeScript 5.x (strict)End-to-end type safety
Package managerpnpm workspacesMonorepo: `apps/web` + `apps/worker` + `packages/shared`
Local devWrangler 3 (`wrangler dev`)Full Cloudflare stack locally including D1 + DO + KV

Monorepo Structure

scheduling-tool/
├── apps/
│   ├── web/                    # React + Vite frontend (deployed to CF Pages)
│   │   ├── src/
│   │   │   ├── pages/
│   │   │   │   ├── Home.tsx          # Create poll
│   │   │   │   ├── Poll.tsx          # Participant response
│   │   │   │   └── Manage.tsx        # Organizer results
│   │   │   ├── components/
│   │   │   │   ├── SlotGrid.tsx      # Availability toggle grid
│   │   │   │   ├── HeatmapCell.tsx   # Single slot cell + ARIA
│   │   │   │   ├── ResultsPanel.tsx  # Live ranked results
│   │   │   │   ├── PollForm.tsx      # Create poll form
│   │   │   │   └── RealtimeProvider.tsx  # Durable Object WS client
│   │   │   ├── hooks/
│   │   │   │   ├── usePoll.ts
│   │   │   │   ├── useRespond.ts
│   │   │   │   └── useRealtime.ts
│   │   │   └── lib/
│   │   │       ├── api.ts            # Typed fetch client
│   │   │       └── timezone.ts       # Intl timezone helpers
│   │   ├── public/
│   │   └── vite.config.ts
│   └── worker/                 # Cloudflare Worker (API + DO + Email)
│       ├── src/
│       │   ├── index.ts              # Worker entry — Hono router mount
│       │   ├── router/
│       │   │   ├── polls.ts          # /api/polls CRUD
│       │   │   ├── responses.ts      # /api/polls/:slug/responses
│       │   │   ├── results.ts        # /api/polls/:slug/results
│       │   │   ├── manage.ts         # /api/polls/:slug/close + export
│       │   │   └── ws.ts             # /api/polls/:slug/ws → DO upgrade
│       │   ├── services/
│       │   │   ├── poll.service.ts
│       │   │   ├── response.service.ts
│       │   │   ├── recommendation.engine.ts
│       │   │   ├── export.service.ts
│       │   │   └── notification.service.ts
│       │   ├── durable-objects/
│       │   │   └── PollHub.ts        # DO: WebSocket fan-out per poll
│       │   ├── db/
│       │   │   ├── schema.ts         # Drizzle D1 schema
│       │   │   ├── client.ts         # D1 Drizzle client
│       │   │   └── migrations/       # Plain SQL migration files
│       │   ├── lib/
│       │   │   ├── rate-limit.ts     # KV-based sliding window
│       │   │   ├── slug.ts           # crypto.getRandomValues slug
│       │   │   ├── pin.ts            # PIN generation + PBKDF2 hash (no bcrypt on Workers)
│       │   │   └── cache.ts          # CF Cache API helpers
│       │   └── types/
│       │       └── env.d.ts          # Cloudflare bindings types
│       └── wrangler.toml
└── packages/
    └── shared/                 # Shared TypeScript types + zod schemas
        └── src/
            ├── types.ts
            └── schemas.ts

Critical Cloudflare Runtime Constraints

The Workers runtime is NOT Node.js. Enforce these rules in every file generated:

1. No bcrypt — Workers have no native module support. Use crypto.subtle.deriveBits with PBKDF2 for PIN hashing.

2. No fs, path, os, net — Node.js built-ins do not exist. All I/O is via Cloudflare bindings (D1, KV, R2).

3. No ws npm library — WebSockets are handled natively via Durable Objects + WebSocketPair.

4. No setTimeout for long tasks — Workers have a 30-second CPU time limit. Use Durable Object alarms for deferred work.

5. No persistent memory between requests — Workers are stateless. All state lives in D1, KV, or Durable Objects.

6. crypto.randomUUID() is available — Web Crypto API is fully available. Use crypto.getRandomValues() for slugs.

7. Imports must be ES modulesrequire() is not supported. Use import/export everywhere.

8. D1 is SQLite, not PostgreSQL — No gen_random_uuid(), no RETURNING in older D1 versions. Use crypto.randomUUID() in application code and pass ID into INSERT.

9. Durable Object WebSocket — Use this.ctx.acceptWebSocket(server) (hibernation API) not the legacy ws.accept() pattern. This allows DOs to sleep between messages, reducing cost.


Database Schema (D1 / SQLite)

-- migrations/0001_initial.sql

CREATE TABLE polls (
  id TEXT PRIMARY KEY,
  slug TEXT UNIQUE NOT NULL,
  organizer_pin_hash TEXT NOT NULL,
  organizer_pin_salt TEXT NOT NULL,
  title TEXT NOT NULL,
  description TEXT,
  timezone TEXT NOT NULL DEFAULT 'UTC',
  status TEXT NOT NULL DEFAULT 'OPEN',
  selected_slot_id TEXT,
  expires_at INTEGER NOT NULL,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

CREATE TABLE slots (
  id TEXT PRIMARY KEY,
  poll_id TEXT NOT NULL REFERENCES polls(id) ON DELETE CASCADE,
  slot_date TEXT NOT NULL,
  session_label TEXT NOT NULL,
  start_time TEXT,
  end_time TEXT,
  notes TEXT,
  sort_order INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE responses (
  id TEXT PRIMARY KEY,
  poll_id TEXT NOT NULL REFERENCES polls(id) ON DELETE CASCADE,
  display_name TEXT NOT NULL,
  edit_token_hash TEXT NOT NULL,
  edit_token_salt TEXT NOT NULL,
  version INTEGER NOT NULL DEFAULT 1,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

CREATE TABLE slot_responses (
  id TEXT PRIMARY KEY,
  response_id TEXT NOT NULL REFERENCES responses(id) ON DELETE CASCADE,
  slot_id TEXT NOT NULL REFERENCES slots(id) ON DELETE CASCADE,
  status TEXT NOT NULL CHECK(status IN ('AVAILABLE','IF_NEEDED','UNAVAILABLE')),
  UNIQUE(response_id, slot_id)
);

CREATE INDEX idx_polls_slug ON polls(slug);
CREATE INDEX idx_polls_expires_at ON polls(expires_at) WHERE status = 'OPEN';
CREATE INDEX idx_slots_poll_id ON slots(poll_id);
CREATE INDEX idx_responses_poll_id ON responses(poll_id);
CREATE INDEX idx_slot_responses_slot_id ON slot_responses(slot_id);

API Contract (Hono Routes — Worker)

// All routes prefixed /api

POST   /api/polls                          → CreatePollResponse
GET    /api/polls/:slug                    → PollWithSlots
GET    /api/polls/:slug/results            → AggregatedResults
POST   /api/polls/:slug/responses          → SubmitResponseResponse  [rate-limited]
PATCH  /api/polls/:slug/responses/:id      → Response                [edit-token cookie]
POST   /api/polls/:slug/close              → Poll                    [PIN required]
GET    /api/polls/:slug/export/csv         → CSV stream              [PIN required]
GET    /api/polls/:slug/export/ics         → ICS file                [PIN required]
GET    /api/polls/:slug/ws                 → WebSocket upgrade → DO  [Durable Object]

PIN Hashing (PBKDF2 — Workers Crypto)

// lib/pin.ts — PBKDF2 replaces bcrypt on the Workers runtime

export async function hashPin(pin: string): Promise<{ hash: string; salt: string }> {
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const saltHex = Array.from(salt).map(b => b.toString(16).padStart(2,'0')).join('');
  const keyMaterial = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(pin), 'PBKDF2', false, ['deriveBits']
  );
  const bits = await crypto.subtle.deriveBits(
    { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' },
    keyMaterial, 256
  );
  const hash = Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2,'0')).join('');
  return { hash, salt: saltHex };
}

export async function verifyPin(pin: string, storedHash: string, storedSalt: string): Promise<boolean> {
  const salt = Uint8Array.from(storedSalt.match(/.{2}/g)!.map(h => parseInt(h, 16)));
  const keyMaterial = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(pin), 'PBKDF2', false, ['deriveBits']
  );
  const bits = await crypto.subtle.deriveBits(
    { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' },
    keyMaterial, 256
  );
  const hash = Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2,'0')).join('');
  return hash === storedHash;
}

Durable Object — PollHub (WebSocket Fan-out)

// durable-objects/PollHub.ts

import { DurableObject } from 'cloudflare:workers';

export class PollHub extends DurableObject {
  async fetch(request: Request): Promise<Response> {
    if (request.headers.get('Upgrade') !== 'websocket') {
      return new Response('Expected WebSocket', { status: 426 });
    }
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    this.ctx.acceptWebSocket(server);  // hibernation API
    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, message: string) {
    // Broadcast to all connected clients in this DO instance (= this poll)
    for (const client of this.ctx.getWebSockets()) {
      if (client !== ws && client.readyState === WebSocket.READY_STATE_OPEN) {
        client.send(message);
      }
    }
  }

  async broadcast(payload: unknown) {
    const msg = JSON.stringify(payload);
    for (const ws of this.ctx.getWebSockets()) {
      if (ws.readyState === WebSocket.READY_STATE_OPEN) ws.send(msg);
    }
  }

  async webSocketClose(ws: WebSocket) { /* handled by hibernation */ }
}

KV Rate Limiting (Sliding Window)

// lib/rate-limit.ts

export async function checkRateLimit(
  kv: KVNamespace,
  key: string,
  windowSeconds: number,
  max: number
): Promise<{ allowed: boolean; remaining: number }> {
  const now = Math.floor(Date.now() / 1000);
  const kvKey = `rl:${key}:${Math.floor(now / windowSeconds)}`;
  const raw = await kv.get(kvKey);
  const count = raw ? parseInt(raw, 10) : 0;
  if (count >= max) return { allowed: false, remaining: 0 };
  await kv.put(kvKey, String(count + 1), { expirationTtl: windowSeconds * 2 });
  return { allowed: true, remaining: max - count - 1 };
}

wrangler.toml

name = "scheduling-tool-worker"
main = "src/index.ts"
compatibility_date = "2026-01-01"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB"
database_name = "scheduling-tool"
database_id = "<your-d1-database-id>"

[[kv_namespaces]]
binding = "KV"
id = "<your-kv-namespace-id>"

[[r2_buckets]]
binding = "R2"
bucket_name = "scheduling-exports"

[durable_objects]
bindings = [{ name = "POLL_HUB", class_name = "PollHub" }]

[[migrations]]
tag = "v1"
new_classes = ["PollHub"]

[vars]
POLL_EXPIRY_DEFAULT_DAYS = "14"
POLL_PURGE_GRACE_DAYS = "30"
RATE_LIMIT_WINDOW_SECONDS = "60"
RATE_LIMIT_MAX = "10"
PIN_PBKDF2_ITERATIONS = "100000"
BASE_URL = "https://your-domain.pages.dev"

UI Design Requirements (Participant Page)

The participant response page is the most critical UX surface. Requirements:

1. Name entry first — Prominent input at top. Cannot interact with slots until name is entered (> 2 chars). Transition to grid is animated.

2. Slot grid — Dates as columns, Morning/Afternoon as rows. Mobile: dates as stacked cards. Each cell has 3 states: Available (teal fill), If Needed (amber fill), Unavailable (default). Tap/click toggles Available → If Needed → Unavailable → Available.

3. Live heatmap — Cell background intensity scales with availableCount / totalRespondents. Opacity range: 0.1 (0 responses) to 1.0 (100% available). Color: teal scale.

4. Respondent count badges — Each cell shows "N available" in small text below the state label.

5. Submit button — Sticky at bottom on mobile. Shows "Submitting..." spinner. On success: shows confirmation state with best slot highlighted.

6. Results summary — Below the grid: sorted bar chart of slots by available count. Updates in real time via WebSocket.

7. Accessibility — All cells are