SDD v2.0 — Software Design Document
Source: sdd-v2.md
Software Design Document
Group Availability Scheduling Tool
Version: 2.1.0 — Cloudflare-native replatform
Date: 2026-04-11
Classification: Internal — CODITECT Platform Artifact A4
Status: Draft — supersedes v1.0.0
Owner: Platform Engineering
Change summary: Full replatform from Node.js/PostgreSQL/Redis to Cloudflare Workers · D1 · KV · Durable Objects · Pages · R2 · Email Workers. All functional requirements unchanged. Non-functional requirements updated for Cloudflare edge runtime. New sections: §5.4 Cloudflare Runtime Constraints, §11.3 Email Workers. | v2.1: Contact capture (email), Google Calendar event creation on poll close, VCF/CSV/JSON contact export, CODITECT CRM integration.
---|---------|
| Meeting organizer | Create polls quickly; see aggregated results |
| Participant | Respond without account creation |
| Platform admin | Data retention, privacy compliance |
| Security lead | No PII leakage, link hijack prevention |
2. System Context
2.1 C4 Context
[Organizer] ──creates poll──► [CF Pages SPA] ──fetch /api/polls──► [CF Worker]
[Participant] ──responds via──► [CF Pages SPA] ──fetch /api/responses─► [CF Worker]
[Any browser] ──WebSocket──────► [CF Pages SPA] ──WSS upgrade──────────► [CF Worker]
│
[CF Worker] ──D1 SQL──────► [Cloudflare D1]
[CF Worker] ──KV get/put──► [Cloudflare KV]
[CF Worker] ──DO fetch──────► [Durable Object: PollHub]
[CF Worker] ──R2 put──────► [Cloudflare R2]
[CF Worker] ──fetch──────────► [MailChannels API] (notifications only)
[CF Cron] ──scheduled()──► [CF Worker] ──D1──► [Cloudflare D1]
2.2 Cloudflare Primitive Map
| Concern | v1.0 (Node.js) | v2.0 (Cloudflare) |
| Frontend hosting | Vercel / Railway | Cloudflare Pages |
| API compute | Node.js Express / Hono | Cloudflare Workers (Hono) |
| Relational DB | PostgreSQL 16 | Cloudflare D1 (SQLite) |
| Key-value / rate limit | Redis 7 | Cloudflare KV |
| Real-time WebSocket | `ws` npm + Redis pub/sub | Durable Objects (hibernation API) |
| Edge cache | CloudFront / CDN | Cloudflare Cache API |
| Object storage | S3 | Cloudflare R2 |
| Nodemailer + SES | Email Workers + MailChannels |
3. Functional Requirements (unchanged from v1.0)
3.1 Poll Management
| ID | Requirement | Priority |
| FR-01 | Organizer can create a poll with title, description, timezone, 1–30 candidate slots | P0 |
| FR-02 | Each slot has a date and session label (Morning / Afternoon / custom) | P0 |
| FR-03 | System generates a unique shareable URL per poll | P0 |
| FR-04 | Organizer receives a private management URL + PIN to edit/close poll | P0 |
| FR-05 | Poll has configurable expiry (default 14 days) | P1 |
| FR-06 | Organizer can mark one slot as "selected" to close the poll | P1 |
| FR-07 | Organizer can add optional notes per slot | P2 |
| FR-08 | Organizer can provide email for calendar event creation | P1 |
3.2 Participant Response (unchanged)
| ID | Requirement | Priority |
| FR-10 | Participant enters display name and toggles available/unavailable per slot | P0 |
| FR-11 | Participant can mark slots as "if needed" (tentative) | P1 |
| FR-12 | Participant can edit response via browser cookie token | P1 |
| FR-13 | Participant count and slot heatmap visible in real time | P1 |
| FR-14 | System warns on duplicate name (not a hard block) | P2 |
| FR-15 | Participant can optionally provide email for calendar invite | P1 |
3.3 Results & Recommendation (unchanged)
| ID | Requirement | Priority |
| FR-20 | System ranks slots by available count | P0 |
| FR-21 | System flags top slot as BEST when count ≥ 50% of respondents | P0 |
| FR-22 | Organizer can export results as CSV | P1 |
| FR-23 | Organizer can export draft calendar invite (.ics) | P1 |
| FR-24 | System notifies organizer when poll closes/expires | P2 |
| FR-30 | System creates Google Calendar event with selected slot when poll closes | P1 |
| FR-31 | System adds participants with email as calendar attendees | P1 |
| FR-32 | System sends calendar invites via Google Workspace | P1 |
| FR-33 | Organizer can export participant contacts as VCF | P1 |
| FR-34 | Organizer can export participant contacts as CSV | P1 |
| FR-35 | Organizer can export participant contacts as JSON | P1 |
| FR-36 | Contacts sync to CODITECT CRM on poll close | P2 |
4. Non-Functional Requirements (v2.0 updated)
| ID | Category | Requirement | Target | Notes |
| NFR-01 | Performance | Poll page FCP | < 1.0s p95 | CF edge serving |
| NFR-02 | Performance | Response submission RTT | < 300ms p99 | Worker at edge |
| NFR-03 | Scalability | Concurrent active polls | 100,000 | D1 + DO scale independently |
| NFR-04 | Scalability | Participants per poll | 500 | DO WS limit per instance |
| NFR-05 | Availability | Uptime SLA | 99.9% | Cloudflare global network |
| NFR-06 | Privacy | No PII beyond display name | Required | ADR-005 unchanged |
| NFR-07 | Privacy | Poll data purged after expiry + 30 days | Required | Cron trigger |
| NFR-08 | Security | 128-bit random slugs | Required | crypto.getRandomValues |
| NFR-09 | Security | Rate limiting on response submission | Required | KV sliding window |
| NFR-10 | Accessibility | WCAG 2.1 AA | Required | Tailwind color tokens |
| NFR-11 | I18N | Timezone-aware; UTC storage | Required | INTEGER timestamps in D1 |
| NFR-12 | Runtime | Cloudflare Workers runtime only | Required | No Node.js APIs |
5. Architecture Overview
5.1 Architectural Style
Cloudflare-native edge SPA. Static frontend on Cloudflare Pages (React + Vite). All API logic in a Cloudflare Worker (Hono router). Persistent state in D1 (SQLite), ephemeral state in KV. Real-time via Durable Objects using the WebSocket hibernation API. No long-running server processes.
5.2 Layer Diagram
┌──────────────────────────────────────────────────┐
│ Cloudflare Pages (React 19 + Vite) │
│ Home · Poll · Manage · Tailwind · WCAG 2.1 AA │
└───────────────────┬──────────────────────────────┘
│ HTTPS fetch /api/* · WSS /api/polls/:slug/ws
┌───────────────────▼──────────────────────────────┐
│ Cloudflare Worker (Hono router) │
│ polls.ts · responses.ts · results.ts │
│ manage.ts · ws.ts · health.ts │
│ rate-limit (KV) · security headers · CORS │
└────┬──────────┬──────────┬───────────┬───────────┘
│ │ │ │
┌───▼───┐ ┌──▼──┐ ┌────▼───┐ ┌───▼────────────┐
│ D1 │ │ KV │ │ R2 │ │ Durable Object │
│SQLite │ │rate │ │exports │ │ PollHub WS │
│polls │ │lock │ │staging │ │ fan-out │
└───────┘ └─────┘ └────────┘ └────────────────┘
│
┌─────▼──────────┐
│ MailChannels │
│ (notifications)│
└────────────────┘
5.3 Key Design Decisions
See ADRs: 001 (Next.js → Cloudflare Pages/Workers), 002 (auth model), 003 (DO WebSocket), 004 (Drizzle D1), 005 (data retention), 006 (D1 vs PostgreSQL).
5.4 Cloudflare Runtime Constraints (v2.0 NEW)
These constraints apply to every Workers file and must not be violated:
| Constraint | Detail |
| No `bcrypt` | Workers have no native module support. Use PBKDF2 via `crypto.subtle` |
| No Node.js built-ins | No `fs`, `path`, `os`, `net`, `crypto` (Node) — use Web Crypto API |
| No `ws` npm library | WebSockets via `WebSocketPair` + Durable Objects only |
| No persistent memory | Workers are stateless per request. All state in D1/KV/DO |
| No `setTimeout` for deferred work | Use Durable Object alarms or Cron Triggers |
| ES modules only | No `require()` — import/export everywhere |
| D1 is SQLite | No `gen_random_uuid()`, no PostgreSQL extensions, no `RETURNING` |
| DO hibernation | Always use `ctx.acceptWebSocket()` — never legacy `ws.accept()` |
| 30s CPU limit | Worker execution must complete within 30 seconds |
| 128MB memory limit | No large in-memory datasets — stream large outputs |
6. Component Design (v2.0 updated)
6.1 PollService (D1)
Same interface as v1.0. Implementation changes: uses Drizzle D1 adapter. ID generation via crypto.randomUUID() in application code (not DB). Timestamps as Unix integer seconds.
6.2 ResponseService (D1 + KV + DO)
After each successful D1 write: (1) invalidate Cache API results key, (2) call ctx.waitUntil(do.broadcast(payload)) — fire-and-forget DO broadcast.
6.3 RecommendationEngine (unchanged)
Pure function — no I/O. score = availableCount + tentativeCount * 0.5. isBest = availableCount >= totalRespondents * 0.5. Tie-break by earliest date.
6.4 PollHub Durable Object
One DO instance per poll, keyed by env.POLL_HUB.idFromName(slug). Uses hibernation API — DO sleeps between messages, waking only to broadcast. No application state stored in DO memory — all data in D1. broadcast() method serializes payload and sends to all connected WebSocket clients via ctx.getWebSockets().
7. Data Model (v2.0 — D1/SQLite)
7.1 Schema changes from v1.0
UUID→TEXT(SQLite has no native UUID type)TIMESTAMPTZ→INTEGER(Unix seconds; timezone handled in application layer)gen_random_uuid()removed — IDs generated in application codeRETURNINGclause removed — query after insertbcrypt_hashsplit intopin_hash+pin_salt(PBKDF2 requires explicit salt storage)
7.2 Schema
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,
organizer_email TEXT NOT NULL DEFAULT '',
calendar_event_id TEXT DEFAULT '',
google_meet_link TEXT DEFAULT ''
);
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,
email TEXT NOT NULL DEFAULT '',
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);
8. API Design (unchanged endpoints, v2.0 implementation notes)
| Method | Path | Auth | Cache TTL | |
| POST | `/api/polls` | None | — | |
| GET | `/api/polls/:slug` | None | 60s (Cache API) | |
| GET | `/api/polls/:slug/results` | None | 5s (Cache API) | |
| POST | `/api/polls/:slug/responses` | None | — (invalidates results cache) | |
| PATCH | `/api/polls/:slug/responses/:id` | Edit token cookie | — | |
| POST | `/api/polls/:slug/close` | PIN (KV lockout) | — | |
| GET | `/api/polls/:slug/export/csv` | PIN | — | |
| GET | `/api/polls/:slug/export/ics` | PIN | — | |
| GET | `/api/polls/:slug/export/contacts` | PIN | — | VCF contact export |
| GET | `/api/polls/:slug/export/contacts.csv` | PIN | — | CSV contact export |
| GET | `/api/polls/:slug/export/contacts.json` | PIN | — | JSON contact export |
| GET | `/api/polls/:slug/ws` | None | → DO upgrade | |
| GET | `/api/health` | None | no-store |
9–10. UI/UX Design & Security Design (v2.0)
9.1 UX Changes from v1.0
- Mobile-first Tailwind v4 throughout
- Slot grid: dates as columns desktop, stacked cards mobile
- Name entry step gates access to the slot grid (animated transition)
- Three-state cell toggle: Available → If Needed → Unavailable (tap cycles)
10.1 Security Changes (v2.0)
bcryptreplaced by PBKDF2 (100k iterations, SHA-256) — same security model, Web Crypto API compatible- KV used for rate limiting and PIN lockout — Redis no longer required
- Edit token: PBKDF2 hash + separate salt column in D1 (replaces bcrypt)
11. Integration Points (v2.0)
11.1 Real-Time Updates (Durable Objects)
Replaced Redis pub/sub with Durable Object hibernation API. Single DO instance per poll. Worker calls ctx.waitUntil(doStub.broadcast(payload)) — fire-and-forget from the critical path.
11.2 Email (Email Workers + MailChannels)
Replaced Nodemailer + SES with Cloudflare Email Workers + MailChannels. POST to https://api.mailchannels.net/tx/v1/send with JSON body. Free tier for Cloudflare Workers. No SMTP credentials. DKIM signing handled by Cloudflare.
11.3 File Export
CSV streamed via TransformStream — no full-file buffering in memory (Workers 128MB limit). ICS generated as string (small enough). Both returned as direct download responses. R2 not required for v2.0 (files fit within Workers response limits).
11.4 Google Calendar Integration (v2.1)
When organizer closes a poll with a selected slot, the Worker calls the Google Calendar API via a service account (impersonating 1@az1.ai) to:
1. Create a calendar event with the selected slot's date/time, poll title, and description
2. Add all participants who provided email as attendees
3. Google auto-sends calendar invite emails to all attendees
4. Store the calendar_event_id and google_meet_link in the polls table
The calendar event creation is non-blocking — if Google Calendar API fails, the poll still closes successfully. The error is logged but not surfaced to the user.
Service account key stored as GOOGLE_SERVICE_ACCOUNT_KEY Cloudflare secret. JWT signed with Web Crypto API (RS256) since google-auth-library is not available in Workers runtime.
12. Deployment Architecture (v2.0)
Frontend: Cloudflare Pages (auto-deploy from git, global CDN)
Worker: Cloudflare Workers (global edge, wrangler deploy)
Database: Cloudflare D1 (managed SQLite, automatic replication)
KV: Cloudflare KV (global, eventual consistency — acceptable for rate limiting)
DO: Cloudflare Durable Objects (strong consistency per instance)
R2: Cloudflare R2 (available for future large export needs)
Cron: Cloudflare Cron Triggers (defined in wrangler.toml)
12.1 wrangler.toml summary
[[d1_databases]] binding = "DB"
[[kv_namespaces]] binding = "KV"
[[r2_buckets]] binding = "R2"
[durable_objects] bindings = [{name="POLL_HUB", class_name="PollHub"}]
[[triggers]] crons = ["0 * * * *", "0 2 * * *"]
13. Open Issues (v2.0 updated)
| ID | Issue | Status |
| OI-01 | ~~Real-time strategy~~ | ✅ Resolved — Durable Objects (ADR-003 updated) |
| OI-02 | "If Needed" state in v1.0 | Open — include in v2.0 |
| OI-03 | CAPTCHA on poll creation | Open |
| OI-04 | Organizer email optional/required | Decision pending |
| OI-05 | Multi-language | Deferred |
| OI-06 | D1 row limits for very large polls (500 participants × 8 slots = 4,000 rows) | Assessed — within D1 limits |
*CODITECT Artifact A4 — SDD v2.0.0 · Group Availability Scheduling Tool · Cloudflare-native*
*Generated: 2026-04-09 · Status: Draft · Supersedes: v1.0.0*
*[LEGAL REVIEW REQUIRED] for EU/BR production launch per ADR-005*