# Data Model: Reference Board Viewer **Created:** 2025-11-02 **Status:** Active **Version:** 1.0.0 ## Overview This document defines the data model for the Reference Board Viewer application, including entities, relationships, validation rules, and state transitions. --- ## Entity Relationship Diagram ``` ┌─────────┐ ┌──────────┐ ┌────────────┐ │ User │────1:N──│ Board │────M:N──│ Image │ └─────────┘ └──────────┘ └────────────┘ │ │ │ │ 1:N 1:N │ │ ┌──────────┐ ┌─────────────┐ │ Group │ │ BoardImage │ └──────────┘ └─────────────┘ │ │ ┌─────────────┐ │ ShareLink │ └─────────────┘ ``` --- ## Core Entities ### User **Purpose:** Represents an authenticated user of the system **Fields:** | Field | Type | Constraints | Description | |-------|------|-------------|-------------| | id | UUID | PK, NOT NULL | Unique identifier | | email | VARCHAR(255) | UNIQUE, NOT NULL | User email (login) | | password_hash | VARCHAR(255) | NOT NULL | Bcrypt hashed password | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | Account creation time | | updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | Last update time | | is_active | BOOLEAN | NOT NULL, DEFAULT TRUE | Account active status | **Validation Rules:** - Email must be valid format (RFC 5322) - Email must be lowercase - Password minimum 8 characters before hashing - Password must contain: 1 uppercase, 1 lowercase, 1 number **Indexes:** - PRIMARY KEY (id) - UNIQUE INDEX (email) - INDEX (created_at) **Relationships:** - User → Board (1:N) - User → Image (1:N, images they own) --- ### Board **Purpose:** Represents a reference board (canvas) containing images **Fields:** | Field | Type | Constraints | Description | |-------|------|-------------|-------------| | id | UUID | PK, NOT NULL | Unique identifier | | user_id | UUID | FK(users.id), NOT NULL | Owner reference | | title | VARCHAR(255) | NOT NULL | Board title | | description | TEXT | NULL | Optional description | | viewport_state | JSONB | NOT NULL | Canvas viewport (zoom, pan, rotation) | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | Creation time | | updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | Last modification | | is_deleted | BOOLEAN | NOT NULL, DEFAULT FALSE | Soft delete flag | **Validation Rules:** - Title: 1-255 characters, non-empty - viewport_state must contain: `{x: number, y: number, zoom: number, rotation: number}` - Zoom: 0.1 to 5.0 - Rotation: 0 to 360 degrees **Indexes:** - PRIMARY KEY (id) - INDEX (user_id, created_at) - INDEX (updated_at) - GIN INDEX (viewport_state) - for JSONB queries **Relationships:** - Board → User (N:1) - Board → BoardImage (1:N) - Board → Group (1:N) - Board → ShareLink (1:N) **Example viewport_state:** ```json { "x": 0, "y": 0, "zoom": 1.0, "rotation": 0 } ``` --- ### Image **Purpose:** Represents an uploaded image file **Fields:** | Field | Type | Constraints | Description | |-------|------|-------------|-------------| | id | UUID | PK, NOT NULL | Unique identifier | | user_id | UUID | FK(users.id), NOT NULL | Owner reference | | filename | VARCHAR(255) | NOT NULL | Original filename | | storage_path | VARCHAR(512) | NOT NULL | Path in MinIO | | file_size | BIGINT | NOT NULL | Size in bytes | | mime_type | VARCHAR(100) | NOT NULL | MIME type (image/jpeg, etc) | | width | INTEGER | NOT NULL | Original width in pixels | | height | INTEGER | NOT NULL | Original height in pixels | | metadata | JSONB | NOT NULL | Additional metadata | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | Upload time | | reference_count | INTEGER | NOT NULL, DEFAULT 0 | How many boards use this | **Validation Rules:** - filename: non-empty, sanitized (no path traversal) - file_size: 1 byte to 50MB (52,428,800 bytes) - mime_type: must be in allowed list (image/jpeg, image/png, image/gif, image/webp, image/svg+xml) - width, height: 1 to 10,000 pixels - metadata must contain: `{format: string, exif?: object, checksum: string}` **Indexes:** - PRIMARY KEY (id) - INDEX (user_id, created_at) - INDEX (filename) - GIN INDEX (metadata) **Relationships:** - Image → User (N:1) - Image → BoardImage (1:N) **Example metadata:** ```json { "format": "jpeg", "exif": { "DateTimeOriginal": "2025:11:02 12:00:00", "Model": "Camera Model" }, "checksum": "sha256:abc123...", "thumbnails": { "low": "/thumbnails/low/abc123.webp", "medium": "/thumbnails/medium/abc123.webp", "high": "/thumbnails/high/abc123.webp" } } ``` --- ### BoardImage **Purpose:** Junction table connecting boards and images with position/transformation data **Fields:** | Field | Type | Constraints | Description | |-------|------|-------------|-------------| | id | UUID | PK, NOT NULL | Unique identifier | | board_id | UUID | FK(boards.id), NOT NULL | Board reference | | image_id | UUID | FK(images.id), NOT NULL | Image reference | | position | JSONB | NOT NULL | X, Y coordinates | | transformations | JSONB | NOT NULL | Scale, rotation, crop, etc | | z_order | INTEGER | NOT NULL | Layer order (higher = front) | | group_id | UUID | FK(groups.id), NULL | Optional group membership | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | Added to board time | | updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | Last transformation time | **Validation Rules:** - position: `{x: number, y: number}` - no bounds (infinite canvas) - transformations must contain: `{scale: number, rotation: number, opacity: number, flipped_h: bool, flipped_v: bool, crop?: object, greyscale: bool}` - scale: 0.01 to 10.0 - rotation: 0 to 360 degrees - opacity: 0.0 to 1.0 - z_order: 0 to 999999 - One image can appear on multiple boards (via different BoardImage records) **Indexes:** - PRIMARY KEY (id) - UNIQUE INDEX (board_id, image_id) - prevent duplicates - INDEX (board_id, z_order) - for layer sorting - INDEX (group_id) - GIN INDEX (position, transformations) **Relationships:** - BoardImage → Board (N:1) - BoardImage → Image (N:1) - BoardImage → Group (N:1, optional) **Example position:** ```json { "x": 100, "y": 250 } ``` **Example transformations:** ```json { "scale": 1.5, "rotation": 45, "opacity": 0.8, "flipped_h": false, "flipped_v": false, "crop": { "x": 10, "y": 10, "width": 200, "height": 200 }, "greyscale": false } ``` --- ### Group **Purpose:** Groups of images with shared annotation and color label **Fields:** | Field | Type | Constraints | Description | |-------|------|-------------|-------------| | id | UUID | PK, NOT NULL | Unique identifier | | board_id | UUID | FK(boards.id), NOT NULL | Board reference | | name | VARCHAR(255) | NOT NULL | Group name | | color | VARCHAR(7) | NOT NULL | Hex color (e.g., #FF5733) | | annotation | TEXT | NULL | Optional text note | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | Creation time | | updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | Last update | **Validation Rules:** - name: 1-255 characters, non-empty - color: must be valid hex color (#RRGGBB format) - annotation: max 10,000 characters **Indexes:** - PRIMARY KEY (id) - INDEX (board_id, created_at) **Relationships:** - Group → Board (N:1) - Group → BoardImage (1:N) --- ### ShareLink **Purpose:** Shareable links to boards with permission control **Fields:** | Field | Type | Constraints | Description | |-------|------|-------------|-------------| | id | UUID | PK, NOT NULL | Unique identifier | | board_id | UUID | FK(boards.id), NOT NULL | Board reference | | token | VARCHAR(64) | UNIQUE, NOT NULL | Secure random token | | permission_level | VARCHAR(20) | NOT NULL | 'view-only' or 'view-comment' | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | Link creation time | | expires_at | TIMESTAMP | NULL | Optional expiration | | last_accessed_at | TIMESTAMP | NULL | Last time link was used | | access_count | INTEGER | NOT NULL, DEFAULT 0 | Usage counter | | is_revoked | BOOLEAN | NOT NULL, DEFAULT FALSE | Revocation flag | **Validation Rules:** - token: 64 character random string (URL-safe base64) - permission_level: must be 'view-only' or 'view-comment' - expires_at: if set, must be future date - Access count incremented on each use **Indexes:** - PRIMARY KEY (id) - UNIQUE INDEX (token) - INDEX (board_id, is_revoked) - INDEX (expires_at, is_revoked) **Relationships:** - ShareLink → Board (N:1) **State Transitions:** ``` [Created] → [Active] → [Revoked] ↓ [Expired] (if expires_at set) ``` --- ### Comment (for View+Comment links) **Purpose:** Comments from viewers on shared boards **Fields:** | Field | Type | Constraints | Description | |-------|------|-------------|-------------| | id | UUID | PK, NOT NULL | Unique identifier | | board_id | UUID | FK(boards.id), NOT NULL | Board reference | | share_link_id | UUID | FK(share_links.id), NULL | Origin link (optional) | | author_name | VARCHAR(100) | NOT NULL | Commenter name | | content | TEXT | NOT NULL | Comment text | | position | JSONB | NULL | Optional canvas position reference | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | Comment time | | is_deleted | BOOLEAN | NOT NULL, DEFAULT FALSE | Soft delete | **Validation Rules:** - author_name: 1-100 characters, sanitized - content: 1-5,000 characters, non-empty - position: if set, `{x: number, y: number}` **Indexes:** - PRIMARY KEY (id) - INDEX (board_id, created_at) - INDEX (share_link_id) **Relationships:** - Comment → Board (N:1) - Comment → ShareLink (N:1, optional) --- ## Database Schema SQL ### PostgreSQL Schema Creation ```sql -- Enable UUID extension CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- Users table CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), email VARCHAR(255) UNIQUE NOT NULL CHECK (email = LOWER(email)), password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), is_active BOOLEAN NOT NULL DEFAULT TRUE ); CREATE INDEX idx_users_created_at ON users(created_at); -- Boards table CREATE TABLE boards ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL CHECK (LENGTH(title) > 0), description TEXT, viewport_state JSONB NOT NULL DEFAULT '{"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}', created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), is_deleted BOOLEAN NOT NULL DEFAULT FALSE ); CREATE INDEX idx_boards_user_created ON boards(user_id, created_at); CREATE INDEX idx_boards_updated ON boards(updated_at); CREATE INDEX idx_boards_viewport ON boards USING GIN (viewport_state); -- Images table CREATE TABLE images ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, filename VARCHAR(255) NOT NULL, storage_path VARCHAR(512) NOT NULL, file_size BIGINT NOT NULL CHECK (file_size > 0 AND file_size <= 52428800), mime_type VARCHAR(100) NOT NULL, width INTEGER NOT NULL CHECK (width > 0 AND width <= 10000), height INTEGER NOT NULL CHECK (height > 0 AND height <= 10000), metadata JSONB NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), reference_count INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_images_user_created ON images(user_id, created_at); CREATE INDEX idx_images_filename ON images(filename); CREATE INDEX idx_images_metadata ON images USING GIN (metadata); -- Groups table CREATE TABLE groups ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), board_id UUID NOT NULL REFERENCES boards(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL CHECK (LENGTH(name) > 0), color VARCHAR(7) NOT NULL CHECK (color ~ '^#[0-9A-Fa-f]{6}$'), annotation TEXT, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_groups_board_created ON groups(board_id, created_at); -- BoardImages junction table CREATE TABLE board_images ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), board_id UUID NOT NULL REFERENCES boards(id) ON DELETE CASCADE, image_id UUID NOT NULL REFERENCES images(id) ON DELETE CASCADE, position JSONB NOT NULL, transformations JSONB NOT NULL DEFAULT '{"scale": 1.0, "rotation": 0, "opacity": 1.0, "flipped_h": false, "flipped_v": false, "greyscale": false}', z_order INTEGER NOT NULL DEFAULT 0, group_id UUID REFERENCES groups(id) ON DELETE SET NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), UNIQUE(board_id, image_id) ); CREATE INDEX idx_board_images_board_z ON board_images(board_id, z_order); CREATE INDEX idx_board_images_group ON board_images(group_id); CREATE INDEX idx_board_images_position ON board_images USING GIN (position); CREATE INDEX idx_board_images_transformations ON board_images USING GIN (transformations); -- ShareLinks table CREATE TABLE share_links ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), board_id UUID NOT NULL REFERENCES boards(id) ON DELETE CASCADE, token VARCHAR(64) UNIQUE NOT NULL, permission_level VARCHAR(20) NOT NULL CHECK (permission_level IN ('view-only', 'view-comment')), created_at TIMESTAMP NOT NULL DEFAULT NOW(), expires_at TIMESTAMP, last_accessed_at TIMESTAMP, access_count INTEGER NOT NULL DEFAULT 0, is_revoked BOOLEAN NOT NULL DEFAULT FALSE ); CREATE UNIQUE INDEX idx_share_links_token ON share_links(token); CREATE INDEX idx_share_links_board_revoked ON share_links(board_id, is_revoked); CREATE INDEX idx_share_links_expires_revoked ON share_links(expires_at, is_revoked); -- Comments table CREATE TABLE comments ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), board_id UUID NOT NULL REFERENCES boards(id) ON DELETE CASCADE, share_link_id UUID REFERENCES share_links(id) ON DELETE SET NULL, author_name VARCHAR(100) NOT NULL, content TEXT NOT NULL CHECK (LENGTH(content) > 0 AND LENGTH(content) <= 5000), position JSONB, created_at TIMESTAMP NOT NULL DEFAULT NOW(), is_deleted BOOLEAN NOT NULL DEFAULT FALSE ); CREATE INDEX idx_comments_board_created ON comments(board_id, created_at); CREATE INDEX idx_comments_share_link ON comments(share_link_id); -- Triggers for updated_at CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ language 'plpgsql'; CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_boards_updated_at BEFORE UPDATE ON boards FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_groups_updated_at BEFORE UPDATE ON groups FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_board_images_updated_at BEFORE UPDATE ON board_images FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); ``` --- ## Migrations Strategy **Tool:** Alembic (SQLAlchemy migration tool) **Process:** 1. Initial migration creates all tables 2. Subsequent migrations track schema changes 3. All migrations tested in staging before production 4. Rollback scripts maintained for each migration 5. Migrations run automatically during deployment **Naming Convention:** ``` YYYYMMDD_HHMMSS_descriptive_name.py ``` Example: ``` 20251102_100000_initial_schema.py 20251110_140000_add_comments_table.py ``` --- ## Data Integrity Rules ### Referential Integrity - All foreign keys have ON DELETE CASCADE or SET NULL as appropriate - No orphaned records allowed ### Business Rules 1. User must own board to modify it 2. Images can only be added to boards by board owner 3. Share links can only be created/revoked by board owner 4. Comments only allowed on boards with active View+Comment links 5. Soft deletes used for boards (is_deleted flag) to preserve history 6. Hard deletes for images only when reference_count = 0 ### Validation - All constraints enforced at database level - Additional validation in application layer (Pydantic models) - Client-side validation for UX (pre-submit checks) --- ## Query Patterns ### Common Queries **1. Get user's boards (with image count):** ```sql SELECT b.*, COUNT(bi.id) as image_count FROM boards b LEFT JOIN board_images bi ON b.id = bi.board_id WHERE b.user_id = $1 AND b.is_deleted = FALSE GROUP BY b.id ORDER BY b.updated_at DESC; ``` **2. Get board with all images (sorted by Z-order):** ```sql SELECT bi.*, i.*, bi.transformations, bi.position FROM board_images bi JOIN images i ON bi.image_id = i.id WHERE bi.board_id = $1 ORDER BY bi.z_order ASC; ``` **3. Get groups with member count:** ```sql SELECT g.*, COUNT(bi.id) as member_count FROM groups g LEFT JOIN board_images bi ON g.id = bi.group_id WHERE g.board_id = $1 GROUP BY g.id ORDER BY g.created_at DESC; ``` **4. Validate share link:** ```sql SELECT sl.*, b.user_id as board_owner_id FROM share_links sl JOIN boards b ON sl.board_id = b.id WHERE sl.token = $1 AND sl.is_revoked = FALSE AND (sl.expires_at IS NULL OR sl.expires_at > NOW()); ``` **5. Search user's image library:** ```sql SELECT * FROM images WHERE user_id = $1 AND filename ILIKE $2 ORDER BY created_at DESC LIMIT 50; ``` --- ## Performance Considerations ### Indexes - All foreign keys indexed - JSONB fields use GIN indexes for fast queries - Compound indexes for common query patterns ### Optimization - Pagination for large result sets (LIMIT/OFFSET) - Connection pooling (SQLAlchemy default: 5-20 connections) - Prepared statements for repeated queries - JSONB queries optimized with proper indexing ### Monitoring - Slow query log enabled (>100ms) - Query explain plans reviewed regularly - Database statistics collected (pg_stat_statements) --- ## Backup & Recovery **Strategy:** - Daily full backups (pg_dump) - Point-in-time recovery enabled (WAL archiving) - Retention: 30 days - Test restores monthly **Data Durability:** - Database: PostgreSQL with WAL (99.99% durability) - Images: MinIO with erasure coding (99.999% durability) - Separate backup of both systems --- This data model supports all 18 functional requirements and ensures data integrity, performance, and scalability.