Real-time collaborative canvas with AI-powered note classification, structured export, and role-based access control. Built for hackathon by Team Raptors.
- Overview
- Tech Stack
- Architecture
- Database Schema
- API Reference
- WebSocket Protocol
- AI Features
- RBAC
- Environment Variables
- Running Locally
- Deployment (Render)
- License
Draftly is a multi-user collaborative whiteboard designed for meeting teams. Participants join a named room, drop sticky notes and drawings onto a shared canvas, and get an AI-generated PDF summary at the end — either a prose narrative or a structured brief (Decisions / Action Items / Open Questions / References).
Key capabilities:
- Real-time sync — sub-50 ms delta propagation via Yjs CRDTs over WebSocket
- Offline resilience — client stores last-seen sequence number; on reconnect the server replays only missed deltas with a smooth animated catch-up
- AI intent classification — every sticky note is automatically labelled
decision | action_item | open_question | blocker | reference | noneby Groq'sllama-3.1-8b-instant - PDF export — canvas screenshot + Groq-generated summary merged into a branded PDF via PDFKit
- Email notifications — invite emails via EmailJS
- Role-based access control —
lead | contributor | viewer; every incoming WebSocket delta is validated server-side against a cloned doc before being applied
| Layer | Technology |
|---|---|
| Frontend | Next.js 16, React 19, Konva / react-konva, Zustand, Yjs |
| Backend | Fastify (Node.js), TypeScript, ws (WebSocket), Yjs |
| Database | PostgreSQL (via pg) |
| AI | Groq API — llama-3.1-8b-instant |
| PDFKit | |
| EmailJS REST API | |
| Deployment | Render (server + DB) + Vercel or Render (web) |
┌─────────────────────────────────────────┐
│ Browser (Next.js) │
│ │
│ Konva Canvas ↔ Yjs Y.Doc │
│ ↕ binary Yjs updates │
│ WsProvider (reconnect + replay) │
└──────────────────┬──────────────────────┘
│
WebSocket /ws/:roomId?token=…
HTTP /rooms /auth /export
│
▼
┌─────────────────────────────────────────┐
│ Server (Fastify + ws) │
│ │
│ RBAC validator → Y.Doc per room │
│ Intent watcher → Groq classifier │
│ Export route → Groq summarizer │
│ Event log → write buffer 200ms │
└──────────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Postgres │
│ │
│ events — append-only log │
│ rooms — room metadata │
│ users — email/password auth │
│ memberships — per-room roles │
│ sessions — bearer tokens │
│ invites — invite links │
└─────────────────────────────────────────┘
Draftly uses Yjs (YATA algorithm). Unlike Operational Transformation, CRDTs need no central serialization — every client merges updates deterministically. The server acts as a relay and persistent store, not an arbiter of order.
- Canvas nodes →
Y.Mapkeyed by node ID inside the rootnodesmap - Text content →
Y.Text(sequence CRDT, handles concurrent character inserts) - Geometry fields (x, y, w, h) → plain values, last-write-wins via Yjs lamport timestamp
Every mutation is stored as a yjs_update row (base64 binary delta in payload.update) in an append-only events table with a BIGSERIAL seq. On server restart, hydrateDocFromDB() replays all rows in seq order to reconstruct exact Y.Doc state. A 200 ms write buffer batches rapid keystrokes into a single INSERT.
Client Server
|── HTTP Upgrade ────────────▶| token + membership validated
|◀─ 101 Switching ────────────|
|◀─ { type:'init', seq } ─────| current head seq
|◀─ binary (full state) ──────| Y.encodeStateAsUpdate(doc)
|── binary (state vector) ───▶| client pushes its state vec
|◀─ { type:'synced' } ────────|
[on reconnect after gap]
|── ?lastSeq=N ───────────────▶|
|◀─ { type:'replay', … } ──────| base state + missed deltas (animated)
Reconnection uses exponential backoff [1s, 2s, 4s, 8s, 16s, 30s]. The client persists draftly:lastSeq:<roomId> in localStorage; the server computes the minimal diff via Y.encodeStateVectorFromUpdate / Y.diffUpdate.
validateUpdate(roomDoc, incomingUpdate, role) in rbac.ts:
- Fast-path — viewers rejected immediately, no clone needed
- Clone — applies update to a copy of the authoritative doc
- ACL diff — rejects if update would grant/revoke permissions the actor doesn't own
- Locked nodes — only
leadmay modifyis_locked = truenodes - Create/delete —
viewerrole is always rejected
If rejected → { type: 'rejected', reason, nodeId? } sent only to the offending client; authoritative doc is never touched.
-- Core identity
users (id UUID PK, name, email UNIQUE, password_hash, created_at)
sessions (id UUID PK, user_id FK, expires_at, created_at)
-- Rooms
rooms (id UUID PK, name, created_by FK, created_at)
memberships (id UUID PK, room_id FK, user_id FK, role, joined_at)
UNIQUE (room_id, user_id)
invites (id UUID PK, room_id FK, email, role, token UUID UNIQUE,
invited_by FK, expires_at, accepted_at)
-- Event log (append-only)
events (id UUID PK, room_id FK, seq BIGSERIAL, actor,
type, payload JSONB, created_at)
-- Read receipts
user_room_reads (user_id FK, room_id FK, last_seen_seq, last_seen_at)
PRIMARY KEY (user_id, room_id)
-- Canvas geometry (denormalized cache — authoritative state is in Y.Doc)
canvas_nodes (id UUID PK, room_id FK,
x, y, width, height NUMERIC,
type CHECK(sticky|text|rect|circle|pen),
content TEXT,
intent CHECK(decision|action_item|open_question|blocker|reference|none),
author_id FK, acl JSONB DEFAULT '{}',
is_locked BOOLEAN DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at, updated_at)All endpoints require Authorization: Bearer <session_token> unless noted.
| Method | Path | Body | Description |
|---|---|---|---|
| POST | /auth/register |
{name, email, password} |
Create account |
| POST | /auth/login |
{email, password} |
Returns {token, user} |
| POST | /auth/logout |
— | Invalidates session |
| GET | /auth/me |
— | Returns current user |
| Method | Path | Description |
|---|---|---|
| GET | /rooms |
List rooms the user is a member of |
| POST | /rooms |
Create a new room |
| GET | /rooms/:id |
Room metadata + membership list |
| DELETE | /rooms/:id |
Delete room (lead only) |
| Method | Path | Body | Description |
|---|---|---|---|
| POST | /rooms/:id/invite |
{email, role} |
Send invite email + create invite record |
| GET | /invite/:token |
— | Resolve token → room info (no auth required) |
| POST | /invite/:token/accept |
— | Accept invite, add membership |
| Method | Path | Body | Description |
|---|---|---|---|
| POST | /rooms/:id/export |
{mode, image} |
Generate PDF summary; mode is narrative or structured; image is raw base64 PNG (no data-URL prefix); returns application/pdf |
Body limit: 12 MB.
| Method | Path | Description |
|---|---|---|
| GET | /dashboard/rooms |
Per-room missed-update counts and member stats |
| GET | /dashboard/user |
Aggregated personal summary: total rooms, missed updates |
Connect to ws://<host>/rooms/<roomId>/ws?token=<sessionToken>&lastSeq=<N>.
| Type | Format | Meaning |
|---|---|---|
init |
{type, seq} |
Handshake; seq is current head |
| binary | Uint8Array |
Full Y.Doc state (immediately after init) |
synced |
{type} |
Client and server are in sync |
replay |
{type, baseState?, updates[], finalSeq} |
Missed-delta catch-up |
awareness |
{type, awareness} |
Cursor / presence broadcast |
rejected |
{type, reason, nodeId?} |
Update rejected by RBAC |
| Format | Meaning |
|---|---|
binary Uint8Array |
Yjs delta update |
{type:'awareness', awareness} |
Cursor position / user colour |
{type:'sync', stateVector} |
State vector for diff calculation |
server/src/classifier.ts — called 800 ms after the user stops typing (debounced in intent-watcher.ts).
Model: llama-3.1-8b-instant at temperature 0.
Cache: SHA-256 of the input text → label, stored in-process.
Labels and their meaning:
| Label | Meaning |
|---|---|
decision |
Something the group resolved |
action_item |
A task that needs to be done |
open_question |
An unresolved question |
blocker |
An impediment |
reference |
A link, doc, or reference material |
none |
General note |
POST /rooms/:id/export takes a canvas screenshot (base64 PNG) and builds a PDF containing:
- Room name, date, member list
- Canvas screenshot (scaled to fit)
- AI-generated summary (narrative prose or structured sections)
The structured mode uses explicit section headers (Decisions / Action Items / Open Questions / References) extracted from intent-labelled notes.
| Action | viewer | contributor | lead |
|---|---|---|---|
| Read canvas | yes | yes | yes |
| Create node | no | yes | yes |
| Edit own node | no | yes | yes |
| Edit any node | no | no | yes |
| Delete node | no | yes (own) | yes |
| Lock/unlock node | no | no | yes |
| Invite members | no | no | yes |
| Export PDF | yes | yes | yes |
| Delete room | no | no | yes |
DATABASE_URL=postgres://user:password@host:5432/dbname
GROQ_API_KEY=gsk_...
PORT=3001
ALLOWED_ORIGIN=http://localhost:3000
FRONTEND_URL=http://localhost:3000
# EmailJS
EMAILJS_SERVICE_ID=service_...
EMAILJS_TEMPLATE_ID=template_...
EMAILJS_TASK_TEMPLATE_ID=template_... # falls back to EMAILJS_TEMPLATE_ID
EMAILJS_PUBLIC_KEY=...
EMAILJS_PRIVATE_KEY=...NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_WS_URL=ws://localhost:3001 # optional; derived from API_URL if omitted- Node.js 20+
- PostgreSQL 15+
createdb draftly
psql draftly < server/migrations/001_init.sql
psql draftly < server/migrations/002_canvas_nodes.sql
psql draftly < server/migrations/003_auth.sqlcd server
cp .env.example .env # fill in DATABASE_URL and GROQ_API_KEY
npm install
npm run dev # nodemon + ts-node, port 3001cd web
cp .env.local.example .env.local
npm install
npm run dev # Next.js dev server, port 3000Open http://localhost:3000, register an account, create a room, and open it in two browser tabs to test real-time sync.
| Setting | Value |
|---|---|
| Environment | Node |
| Build command | cd server && npm install && npm run build |
| Start command | cd server && npm start |
| Health check path | /health |
Add all env vars from the Server section above.
Set ALLOWED_ORIGIN to your web app's public URL.
Create a Render PostgreSQL instance. Copy the Internal Database URL into DATABASE_URL on the server service. Run migrations via the Render Shell tab:
psql $DATABASE_URL < migrations/001_init.sql
psql $DATABASE_URL < migrations/002_canvas_nodes.sql
psql $DATABASE_URL < migrations/003_auth.sqlDeploy web/ as a Next.js app on Vercel or as a Render Web Service:
| Setting | Value |
|---|---|
| Build command | cd web && npm install && npm run build |
| Start command | cd web && npm start |
Set NEXT_PUBLIC_API_URL to the Render server's public URL (e.g. https://draftly-server.onrender.com).
Render supports WebSockets on standard Web Services with no additional configuration. Ensure your server's Fastify instance listens on 0.0.0.0 (the default) and that PORT is set from process.env.PORT (Render injects this automatically).
Draftly/
├── server/
│ ├── migrations/
│ │ ├── 001_init.sql
│ │ ├── 002_canvas_nodes.sql
│ │ └── 003_auth.sql
│ └── src/
│ ├── index.ts # Fastify entry, WSS setup
│ ├── ws.ts # WebSocket upgrade handler
│ ├── ydoc-store.ts # Hydration, applyAndBroadcast
│ ├── sync.ts # sendFullState, replayMissedEvents
│ ├── rbac.ts # Server-side update validation
│ ├── rooms.ts # In-memory room registry
│ ├── event-log.ts # Append events, 200ms batch buffer
│ ├── intent-watcher.ts # observeDeep + debounce classify
│ ├── classifier.ts # Groq intent classify
│ ├── mailer.ts # EmailJS wrappers
│ ├── export-pdf.ts # PDFKit builder
│ ├── db.ts # pg pool
│ └── routes/
│ ├── auth.ts
│ ├── rooms.ts
│ ├── invites.ts
│ ├── export.ts
│ └── dashboard.ts
└── web/
├── app/ # Next.js App Router pages
├── components/ # Canvas, toolbar, sidebar UI
└── lib/
├── ws-provider.ts # WsProvider class
├── node-types.ts # NodeSnapshot, NodeKind, Role
└── store.ts # Zustand canvas store
MIT