Data Rooms
Data Rooms let users bundle multiple documents into a single shareable room with its own access controls, analytics, branding, flat folders, owner-only tagging through the shared global tag model, and private room notes.
Architecture Overview
Database Schema
text
data_rooms
├── id (uuid, PK)
├── user_id (uuid, FK → auth.users)
├── name (text)
├── slug (text, unique)
├── description (text, nullable)
├── icon_url (text, nullable)
├── require_email (boolean, default false)
├── require_password (boolean, default false)
├── view_password (text, nullable)
├── created_at (timestamptz)
└── updated_at (timestamptz)
data_room_documents (junction table)
├── id (uuid, PK)
├── data_room_id (uuid, FK → data_rooms)
├── deck_id (uuid, FK → decks)
├── folder_id (uuid, FK → data_room_folders, nullable)
├── display_order (integer)
└── added_at (timestamptz)
data_room_folders
├── id (uuid, PK)
├── data_room_id (uuid, FK → data_rooms)
├── name (text)
├── color (text)
├── position (text)
├── created_by (uuid)
├── updated_by (uuid, nullable)
├── created_at (timestamptz)
└── updated_at (timestamptz)
global_tags
├── id (uuid, PK)
├── user_id (uuid, FK → auth.users)
├── name (text)
├── color (text)
├── created_at (timestamptz)
├── updated_at (timestamptz)
└── deleted_at (timestamptz, nullable)
global_tag_aliases
├── id (uuid, PK)
├── user_id (uuid, FK → auth.users)
├── alias_type (text: legacy_name | legacy_id)
├── alias_value (text)
├── tag_id (uuid, FK → global_tags)
├── created_at (timestamptz)
└── updated_at (timestamptz)
data_room_folder_tags (junction table)
├── folder_id (uuid, FK → data_room_folders)
└── tag_id (uuid, FK → global_tags)
data_room_document_tags (junction table)
├── document_id (uuid, FK → data_room_documents)
└── tag_id (uuid, FK → global_tags)
saved_data_rooms
├── id (uuid, PK)
├── user_id (uuid, FK → auth.users)
├── data_room_id (uuid, FK → data_rooms)
├── folder_id (uuid, FK → library_folders, nullable)
├── saved_at (timestamptz)
├── room_title (text snapshot)
├── room_slug (text snapshot)
└── is_deleted (boolean snapshot)
room_notes
├── user_id (uuid, FK → auth.users)
├── data_room_id (uuid, FK → data_rooms)
├── content (text)
└── updated_at (timestamptz)Service Layer — dataRoomService.ts
Core functions:
| Function | Description |
|---|---|
getDataRooms() | Fetch all rooms for current user (cached) |
getDataRoom(id) | Fetch single room by ID |
createDataRoom(data) | Create new room, returns room record |
updateDataRoom(id, data) | Update room metadata |
deleteDataRoom(id) | Delete room + remove junction entries |
getDocuments(roomId) | Fetch full linked room documents (ordered) |
getDocumentSearchSummaries(roomId) | Fetch lightweight search metadata for overview filtering |
addDocuments(roomId, deckIds) | Link existing decks to a room |
removeDocument(roomId, deckId) | Unlink a deck from a room |
reorderDocuments(roomId, deckIds) | Update display_order for all docs |
getDocumentCount(roomId) | Count of linked documents |
getDataRoomAnalytics(roomId) | Aggregate visitor analytics across all docs |
uploadRoomIcon(file) | Upload custom icon to assets bucket |
setDocumentTags(documentId, tagIds) | Apply owner-only tags to one document |
saveToLibrary(dataRoomId) | Save a room into the user-private library |
roomNoteService.saveNote(dataRoomId, content) | Persist a private room note |
Folder / Tag Rules
- Flat grouping only: folders are top-level groups only. There is no nesting.
- Permanent delete: folder deletion is hard delete. Documents inside return to
Unorganizedviafolder_id = NULL. - Shared global tag source: folder and document tags now point at
global_tags, so the same canonical user tag can be reused across the library, decks, saved rooms, folders, and room documents. - Owner-only folder tags: folder tags are visible only to the room owner and are not exposed in shared links.
- Document tags: documents can also have their own owner-only global tags, rendered in the room detail view.
- Alias compatibility: migrated room-local tags are preserved through
global_tag_aliasesso old IDs and names can still resolve during the transition. - Folder tag limit: each folder can have up to 4 tags.
- Free-tier guard: Free users can create up to 1 folder per room; Pro downgrade gets a 15-day warning window before room access is blocked.
- Saved-library separation: Saved room folders and tags are private library organization only; they do not change the room owner's original room folders or tags.
- Tombstones: If a saved room is deleted, the saved-library entry stays visible as a tombstone so users can still unsave it or see the preserved note snapshot.
Key Pages & Components
Pages
| File | Route | Purpose |
|---|---|---|
DataRoomsPage.tsx | /rooms | Fluid header + responsive grid (1-3 cols). Usage dots + upgrade banner |
DataRoomDetail.tsx | /rooms/:roomId | Tabbed room shell with content, analytics, and settings panels. |
ManageDataRoom.tsx | /rooms/new, /rooms/:roomId/edit | Create / edit room form |
DataRoomViewer.tsx | /:handle/:slug | Public viewer with access gate + folder-aware sidebar navigation |
OwnerDataRoomPreview.tsx | /preview/room/:roomId | Owner preview with signed URLs and the same deck rendering flow |
SavedDecks.tsx | /saved | Mixed saved library for decks and rooms with private notes |
Components
| Component | Location | Purpose |
|---|---|---|
DataRoomCard | components/dashboard/ | Card with grid pattern + corner glow texture |
DocumentPicker | components/dashboard/ | Modal for selecting existing decks |
RoomDocumentList | components/dashboard/ | Drag-reorderable list with row actions, tags, and folder moves |
Tier Limits
Data room creation is gated by the user's subscription tier, managed centrally in constants/tiers.ts:
| Tier | Max Data Rooms | Enforcement | Max Decks Per Room |
|---|---|---|---|
| FREE | 1 | Button disabled + upgrade banner | 50 |
| PRO | 5 | Button disabled + upgrade banner | 500 |
| PRO+ | ∞ (Unlimited) | No limit | ∞ (Unlimited) |
Where limits are enforced:
DataRoomsPage.tsx— Primary UI enforcement. Shows usage dots, locks "New Room" button, displays contextual upgrade banner.ManageDataRoom.tsx— Safety net. Checks room count on mount in create mode; redirects to/roomsif at limit.- Database Trigger (
tr_limit_decks_per_room) — Enforces the maximum number of decks allowed inside a single room based on the owner's tier, ensuring platform stability.
Custom Branding (Icons)
Data rooms support custom icon uploads to the assets storage bucket.
- Pathing:
assets/{userId}/room-icons/icon-{timestamp}.{ext}. - Hardening: The bucket RLS uses
COALESCEguards to ensure metadata processing doesn't block the initial icon upload. - Signing: Although icons are in the
publicbucket for fast viewer loading, the owner's dashboard uses $O(N)$ Map-based lookups for document thumbnails to maintain high performance.
Changing limits
Edit the maxDataRooms value in TIER_CONFIG inside constants/tiers.ts:
ts
export const TIER_CONFIG: Record<Tier, TierConfig> = {
FREE: { days: 7, label: "7 Day Analytics", maxDataRooms: 1 },
PRO: { days: 90, label: "90 Day Analytics", maxDataRooms: 5 },
PRO_PLUS: { days: 365, label: "1 Year Analytics", maxDataRooms: Infinity },
};
User Flows
Creating a Room
- User clicks "New Room" on
/rooms(if under tier limit) - Fills in name, URL slug, description on
ManageDataRoom. Slugs are name-spaced under the user's handle (e.g.deckly.app/alice/seed-round). Note that slugs must be globally unique across the platform to ensure secure link generation. - Optionally sets access controls (email gate, password, or expiration).
- Saves → redirected to room detail page. Any validation errors (e.g. slug already taken) are surfaced via non-blocking
sonnernotifications.
Security Gates & Private Storage
- Email Gate: Standard regex-based email validation.
- Password Gate: Server-side verification via
check_data_room_passwordRPC. - Expiration: Enforced at the database layer (Postgres). Once a room's
expires_attimestamp is passed, the specializedget_data_room_payloadRPC will fail to resolve the asset, ensuring immediate revocation across all sessions. - Private Storage Path: The room payload includes a
storage_pathfor each document. This path is used by the client to request a short-lived signed URL, ensuring that document access remains gated behind the room's security logic.
Adding Documents to a Room
Two paths from the room detail page:
- "New Deck" — Navigates to
/upload?returnToRoom=<roomId>. After upload completes, the deck is auto-added and the user returns to the room. - "Add Existing" — Opens
DocumentPickermodal to select from uploaded decks.
Working With Folders
- Click a folder card to filter the room list to that folder.
- Click the same folder again to return to the full room list.
- Moving a document into a folder updates the room detail page and preview so the document appears inside that folder group.
- The room preview uses signed URLs for both the owner preview route and the public shared route, so local preview and incognito/shared viewing render the same assets.
Room Overview Search
DataRoomsPage.tsxno longer loads every full room document just to power search and tag filters.- The overview now requests lightweight per-room document summaries through
dataRoomService.getDocumentSearchSummaries(roomId). - Full document payloads remain on-demand in room detail and viewer flows.
Document Actions
- Each row has a 3-dot menu for analytics, edit, and removal from the room.
- The tag button on a document applies owner-only document tags and renders the chips inline in the row.
Sharing a Room
- Copy the public link (
/:handle/:slug) from the room detail page - Visitors see the
DataRoomViewerwith either a desktop sidebar or mobile drawer navigation between documents - Access gates (email/password) are applied before document viewing begins. The AccessGate ensures data integrity through robust regex-based email validation and automatic input trimming.
- Visitor engagement is aggregated across all documents inside the room onto the Room's detail page via "Visitor Signals"
- Shared links show folder grouping, but never expose owner-only document or folder tags.
- Saving a room from the viewer or owner preview writes a private saved-library row for the current user.
- Private notes are room-scoped, stay hidden from shared links, and survive unsave so they can return on later resave.
- Deleted rooms remain manageable in the saved library as tombstones instead of silently disappearing.
Navigation After Editing
When editing a room via /rooms/:roomId/edit:
- Save → returns to
/rooms/:roomId(detail page) - Delete → returns to
/rooms(listing) - Back button → returns to
/rooms/:roomId(detail page)
