19 KiB
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 |
| 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:
{
"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:
{
"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:
{
"x": 100,
"y": 250
}
Example transformations:
{
"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
-- 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:
- Initial migration creates all tables
- Subsequent migrations track schema changes
- All migrations tested in staging before production
- Rollback scripts maintained for each migration
- 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
- User must own board to modify it
- Images can only be added to boards by board owner
- Share links can only be created/revoked by board owner
- Comments only allowed on boards with active View+Comment links
- Soft deletes used for boards (is_deleted flag) to preserve history
- 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):
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):
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:
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:
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:
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.