mirror of https://github.com/docusealco/docuseal
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
632 lines
16 KiB
632 lines
16 KiB
# Data Models - FloDoc Architecture
|
|
|
|
**Document**: Database Schema & Data Models
|
|
**Version**: 1.0
|
|
**Last Updated**: 2026-01-14
|
|
|
|
---
|
|
|
|
## 📊 Database Overview
|
|
|
|
FloDoc extends the existing DocuSeal database schema with three new tables to support the 3-portal cohort management system. All new tables follow Rails conventions and include soft delete functionality.
|
|
|
|
---
|
|
|
|
## 🎯 New Tables (FloDoc Enhancement)
|
|
|
|
### 1. institutions
|
|
|
|
**Purpose**: Single training institution record (one per deployment)
|
|
|
|
```ruby
|
|
create_table :institutions do |t|
|
|
t.string :name, null: false
|
|
t.string :email, null: false
|
|
t.string :contact_person
|
|
t.string :phone
|
|
t.jsonb :settings, default: {}
|
|
t.timestamps
|
|
t.datetime :deleted_at
|
|
end
|
|
|
|
# Indexes
|
|
add_index :institutions, :name, unique: true
|
|
add_index :institutions, :email, unique: true
|
|
```
|
|
|
|
**Key Fields**:
|
|
- `name`: Institution name (e.g., "TechPro Training Academy")
|
|
- `email`: Official contact email
|
|
- `contact_person`: Primary contact name
|
|
- `phone`: Contact phone number
|
|
- `settings`: JSONB for future configuration (logo, branding, etc.)
|
|
- `deleted_at`: Soft delete timestamp
|
|
|
|
**Design Decisions**:
|
|
- **Single Record**: Only one institution per deployment (not multi-tenant)
|
|
- **No Account Link**: Independent of DocuSeal's `accounts` table
|
|
- **Settings JSONB**: Flexible for future features without migrations
|
|
|
|
**Relationships**:
|
|
```ruby
|
|
class Institution < ApplicationRecord
|
|
has_many :cohorts, dependent: :destroy
|
|
has_many :cohort_enrollments, through: :cohorts
|
|
|
|
validates :name, presence: true, uniqueness: true
|
|
validates :email, presence: true, uniqueness: true
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
### 2. cohorts
|
|
|
|
**Purpose**: Represents a training program cohort (maps to DocuSeal template)
|
|
|
|
```ruby
|
|
create_table :cohorts do |t|
|
|
t.references :institution, null: false, foreign_key: true
|
|
t.references :template, null: false # Links to existing templates
|
|
t.string :name, null: false
|
|
t.string :program_type, null: false # learnership/internship/candidacy
|
|
t.string :sponsor_email, null: false
|
|
t.jsonb :required_student_uploads, default: [] # ['id', 'matric', 'tertiary']
|
|
t.jsonb :cohort_metadata, default: {} # Additional cohort info
|
|
t.string :status, default: 'draft' # draft/active/completed
|
|
t.datetime :tp_signed_at # TP completed signing
|
|
t.datetime :students_completed_at # All students completed
|
|
t.datetime :sponsor_completed_at # Sponsor completed
|
|
t.datetime :finalized_at # TP finalized review
|
|
t.timestamps
|
|
t.datetime :deleted_at
|
|
end
|
|
|
|
# Indexes
|
|
add_index :cohorts, [:institution_id, :status]
|
|
add_index :cohorts, :template_id
|
|
add_index :cohorts, :sponsor_email
|
|
```
|
|
|
|
**Key Fields**:
|
|
- `institution_id`: Foreign key to institutions
|
|
- `template_id`: Foreign key to existing `templates` table
|
|
- `name`: Cohort name (e.g., "2026 Q1 Learnership Program")
|
|
- `program_type`: Type of training program
|
|
- `learnership`: SETA-funded learnership
|
|
- `internship`: Workplace internship
|
|
- `candidacy`: Professional certification candidacy
|
|
- `sponsor_email`: Email for sponsor notifications
|
|
- `required_student_uploads`: Array of required documents
|
|
- Example: `["id_copy", "matric_certificate", "tertiary_transcript"]`
|
|
- `cohort_metadata`: JSONB for additional data
|
|
- Example: `{"start_date": "2026-02-01", "duration_months": 12}`
|
|
- `status`: Workflow state
|
|
- `draft`: Being configured by TP
|
|
- `active`: Students can enroll
|
|
- `completed`: All phases done
|
|
- `*_at` fields: Audit trail for workflow phases
|
|
|
|
**Workflow States**:
|
|
```
|
|
draft → active → [students_enroll] → [students_complete] → [tp_verifies] → [sponsor_signs] → [tp_finalizes] → completed
|
|
```
|
|
|
|
**Relationships**:
|
|
```ruby
|
|
class Cohort < ApplicationRecord
|
|
belongs_to :institution
|
|
belongs_to :template # Existing DocuSeal model
|
|
|
|
has_many :cohort_enrollments, dependent: :destroy
|
|
has_many :submissions, through: :cohort_enrollments
|
|
|
|
validates :name, :program_type, :sponsor_email, presence: true
|
|
validates :status, inclusion: { in: %w[draft active completed] }
|
|
|
|
scope :active, -> { where(status: 'active') }
|
|
scope :completed, -> { where(status: 'completed') }
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
### 3. cohort_enrollments
|
|
|
|
**Purpose**: Links students to cohorts with state tracking
|
|
|
|
```ruby
|
|
create_table :cohort_enrollments do |t|
|
|
t.references :cohort, null: false, foreign_key: true
|
|
t.references :submission, null: false # Links to existing submissions
|
|
t.string :student_email, null: false
|
|
t.string :student_name
|
|
t.string :student_surname
|
|
t.string :student_id
|
|
t.string :status, default: 'waiting' # waiting/in_progress/complete
|
|
t.string :role, default: 'student' # student/sponsor
|
|
t.jsonb :uploaded_documents, default: {} # Track required uploads
|
|
t.jsonb :values, default: {} # Copy of submitter values
|
|
t.datetime :completed_at
|
|
t.timestamps
|
|
t.datetime :deleted_at
|
|
end
|
|
|
|
# Indexes
|
|
add_index :cohort_enrollments, [:cohort_id, :status]
|
|
add_index :cohort_enrollments, [:cohort_id, :student_email], unique: true
|
|
add_index :cohort_enrollments, [:submission_id], unique: true
|
|
```
|
|
|
|
**Key Fields**:
|
|
- `cohort_id`: Foreign key to cohorts
|
|
- `submission_id`: Foreign key to existing `submissions` table
|
|
- `student_email`: Student's email (unique per cohort)
|
|
- `student_name`: First name
|
|
- `student_surname`: Last name
|
|
- `student_id`: Student ID number (optional)
|
|
- `status`: Enrollment state
|
|
- `waiting`: Awaiting student action
|
|
- `in_progress`: Student is filling forms
|
|
- `complete`: Student submitted
|
|
- `role`: Participant role
|
|
- `student`: Student participant
|
|
- `sponsor`: Sponsor participant (rare, usually one per cohort)
|
|
- `uploaded_documents`: JSONB tracking required uploads
|
|
- Example: `{"id_copy": true, "matric": false}`
|
|
- `values`: JSONB copy of submitter values for quick access
|
|
- Avoids joining to `submitters` table for simple queries
|
|
- `completed_at`: When student finished
|
|
|
|
**Unique Constraints**:
|
|
- One enrollment per student per cohort (`[cohort_id, student_email]`)
|
|
- One enrollment per submission (`[submission_id]`)
|
|
|
|
**Relationships**:
|
|
```ruby
|
|
class CohortEnrollment < ApplicationRecord
|
|
belongs_to :cohort
|
|
belongs_to :submission # Existing DocuSeal model
|
|
|
|
validates :student_email, presence: true
|
|
validates :status, inclusion: { in: %w[waiting in_progress complete] }
|
|
validates :role, inclusion: { in: %w[student sponsor] }
|
|
|
|
scope :students, -> { where(role: 'student') }
|
|
scope :sponsors, -> { where(role: 'sponsor') }
|
|
scope :completed, -> { where(status: 'complete') }
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 🏗️ Existing DocuSeal Tables (Integration Points)
|
|
|
|
### templates (Existing)
|
|
**Used by**: `cohorts.template_id`
|
|
|
|
```ruby
|
|
# Existing schema (simplified)
|
|
create_table :templates do |t|
|
|
t.string :name
|
|
t.string :status
|
|
t.references :account
|
|
# ... other fields
|
|
end
|
|
```
|
|
|
|
**Integration**: Cohorts reference templates for PDF generation
|
|
|
|
---
|
|
|
|
### submissions (Existing)
|
|
**Used by**: `cohort_enrollments.submission_id`
|
|
|
|
```ruby
|
|
# Existing schema (simplified)
|
|
create_table :submissions do |t|
|
|
t.references :template
|
|
t.string :status
|
|
t.jsonb :values
|
|
# ... other fields
|
|
end
|
|
```
|
|
|
|
**Integration**: Enrollments track submission progress
|
|
|
|
---
|
|
|
|
### submitters (Existing)
|
|
**Used by**: Workflow logic (not directly referenced)
|
|
|
|
```ruby
|
|
# Existing schema (simplified)
|
|
create_table :submitters do |t|
|
|
t.references :submission
|
|
t.string :email
|
|
t.string :name
|
|
t.string :status
|
|
# ... other fields
|
|
end
|
|
```
|
|
|
|
**Integration**: Used for signing workflow, values copied to `cohort_enrollments.values`
|
|
|
|
---
|
|
|
|
## 🔗 Relationships Diagram
|
|
|
|
```
|
|
┌─────────────────┐
|
|
│ institutions │◄────┐
|
|
│ (1 per dep) │ │
|
|
└────────┬────────┘ │
|
|
│ │
|
|
1│ │
|
|
│ │
|
|
▼ │
|
|
┌─────────────────┐ │
|
|
│ cohorts │ │
|
|
│ │ │
|
|
└────────┬────────┘ │
|
|
│ │
|
|
1│ │
|
|
│ │
|
|
▼ │
|
|
┌─────────────────┐ │
|
|
│cohort_enrollments│ │
|
|
│ │ │
|
|
└────────┬────────┘ │
|
|
│ │
|
|
│ │
|
|
│ │
|
|
▼ │
|
|
┌─────────────────┐ │
|
|
│ submissions │─────┘
|
|
│ (existing) │
|
|
└────────┬────────┘
|
|
│
|
|
│
|
|
▼
|
|
┌─────────────────┐
|
|
│ submitters │
|
|
│ (existing) │
|
|
└─────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 🎯 State Management
|
|
|
|
### Cohort Status Flow
|
|
|
|
```ruby
|
|
class Cohort < ApplicationRecord
|
|
def advance_to_active!
|
|
update!(status: 'active')
|
|
end
|
|
|
|
def mark_students_completed!
|
|
update!(students_completed_at: Time.current)
|
|
end
|
|
|
|
def mark_sponsor_completed!
|
|
update!(sponsor_completed_at: Time.current)
|
|
end
|
|
|
|
def finalize!
|
|
update!(status: 'completed', finalized_at: Time.current)
|
|
end
|
|
|
|
def can_be_signed_by_sponsor?
|
|
students_completed_at.present? && tp_signed_at.present?
|
|
end
|
|
end
|
|
```
|
|
|
|
### Enrollment Status Flow
|
|
|
|
```ruby
|
|
class CohortEnrollment < ApplicationRecord
|
|
def start!
|
|
update!(status: 'in_progress')
|
|
end
|
|
|
|
def complete!
|
|
update!(
|
|
status: 'complete',
|
|
completed_at: Time.current,
|
|
values: submission.values # Copy for quick access
|
|
)
|
|
end
|
|
|
|
def incomplete_uploads
|
|
required = cohort.required_student_uploads
|
|
uploaded = uploaded_documents.keys
|
|
required - uploaded
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 🔍 Query Patterns
|
|
|
|
### Get all students for a cohort
|
|
```ruby
|
|
cohort = Cohort.find(id)
|
|
students = cohort.cohort_enrollments.students
|
|
```
|
|
|
|
### Get pending enrollments
|
|
```ruby
|
|
pending = CohortEnrollment.where(status: ['waiting', 'in_progress'])
|
|
```
|
|
|
|
### Get sponsor dashboard data
|
|
```ruby
|
|
cohort = Cohort.find(id)
|
|
{
|
|
total_students: cohort.cohort_enrollments.students.count,
|
|
completed: cohort.cohort_enrollments.completed.count,
|
|
pending: cohort.cohort_enrollments.where(status: 'waiting').count,
|
|
documents_ready: cohort.tp_signed_at.present?
|
|
}
|
|
```
|
|
|
|
### Check if cohort is ready for sponsor
|
|
```ruby
|
|
cohort = Cohort.find(id)
|
|
ready = cohort.students_completed_at.present? &&
|
|
cohort.tp_signed_at.present? &&
|
|
cohort.cohort_enrollments.students.any?
|
|
```
|
|
|
|
---
|
|
|
|
## 📊 Data Integrity Rules
|
|
|
|
### Foreign Keys
|
|
```ruby
|
|
# All new tables have foreign keys
|
|
add_foreign_key :cohorts, :institutions
|
|
add_foreign_key :cohorts, :templates
|
|
add_foreign_key :cohort_enrollments, :cohorts
|
|
add_foreign_key :cohort_enrollments, :submissions
|
|
```
|
|
|
|
### Validations
|
|
```ruby
|
|
# Institution
|
|
validates :name, presence: true, uniqueness: true
|
|
validates :email, presence: true, uniqueness: true
|
|
|
|
# Cohort
|
|
validates :name, presence: true
|
|
validates :program_type, inclusion: { in: %w[learnership internship candidacy] }
|
|
validates :sponsor_email, presence: true
|
|
validates :status, inclusion: { in: %w[draft active completed] }
|
|
|
|
# Enrollment
|
|
validates :student_email, presence: true
|
|
validates :status, inclusion: { in: %w[waiting in_progress complete] }
|
|
validates :role, inclusion: { in: %w[student sponsor] }
|
|
```
|
|
|
|
### Unique Constraints
|
|
```ruby
|
|
# One enrollment per student per cohort
|
|
add_index :cohort_enrollments, [:cohort_id, :student_email], unique: true
|
|
|
|
# One enrollment per submission
|
|
add_index :cohort_enrollments, [:submission_id], unique: true
|
|
```
|
|
|
|
---
|
|
|
|
## 🗄️ Migration Strategy
|
|
|
|
### Phase 1: Create Tables
|
|
```ruby
|
|
class CreateFloDocTables < ActiveRecord::Migration[7.0]
|
|
def change
|
|
create_table :institutions do |t|
|
|
# ... fields
|
|
end
|
|
|
|
create_table :cohorts do |t|
|
|
# ... fields
|
|
end
|
|
|
|
create_table :cohort_enrollments do |t|
|
|
# ... fields
|
|
end
|
|
|
|
# Add indexes
|
|
add_index :cohorts, [:institution_id, :status]
|
|
# ... more indexes
|
|
|
|
# Add foreign keys
|
|
add_foreign_key :cohorts, :institutions
|
|
# ... more foreign keys
|
|
end
|
|
end
|
|
```
|
|
|
|
### Phase 2: Add Models
|
|
```ruby
|
|
# app/models/institution.rb
|
|
class Institution < ApplicationRecord
|
|
has_many :cohorts, dependent: :destroy
|
|
# ...
|
|
end
|
|
|
|
# app/models/cohort.rb
|
|
class Cohort < ApplicationRecord
|
|
belongs_to :institution
|
|
belongs_to :template
|
|
has_many :cohort_enrollments, dependent: :destroy
|
|
# ...
|
|
end
|
|
|
|
# app/models/cohort_enrollment.rb
|
|
class CohortEnrollment < ApplicationRecord
|
|
belongs_to :cohort
|
|
belongs_to :submission
|
|
# ...
|
|
end
|
|
```
|
|
|
|
### Rollback
|
|
```bash
|
|
# All migrations are reversible
|
|
bin/rails db:rollback STEP=1
|
|
# Tables are dropped, data is lost (intentional for MVP)
|
|
```
|
|
|
|
---
|
|
|
|
## 🎯 Performance Considerations
|
|
|
|
### Index Strategy
|
|
```ruby
|
|
# For cohort queries by status
|
|
add_index :cohorts, [:institution_id, :status]
|
|
|
|
# For enrollment queries by cohort and status
|
|
add_index :cohort_enrollments, [:cohort_id, :status]
|
|
|
|
# For student lookup (unique per cohort)
|
|
add_index :cohort_enrollments, [:cohort_id, :student_email], unique: true
|
|
|
|
# For submission lookup (unique)
|
|
add_index :cohort_enrollments, [:submission_id], unique: true
|
|
```
|
|
|
|
### Query Optimization
|
|
```ruby
|
|
# Eager load associations to avoid N+1
|
|
Cohort.includes(:institution, :cohort_enrollments).find(id)
|
|
|
|
# Use scopes for common queries
|
|
cohort.cohort_enrollments.completed.count
|
|
cohort.cohort_enrollments.students.pending
|
|
```
|
|
|
|
### JSONB Usage
|
|
```ruby
|
|
# Store flexible data without schema changes
|
|
cohort.update!(cohort_metadata: {
|
|
start_date: '2026-02-01',
|
|
duration_months: 12,
|
|
funding_source: 'SETA'
|
|
})
|
|
|
|
# Query JSONB fields
|
|
Cohort.where("cohort_metadata->>'funding_source' = ?", 'SETA')
|
|
```
|
|
|
|
---
|
|
|
|
## 🔒 Security Considerations
|
|
|
|
### Data Isolation
|
|
```ruby
|
|
# All queries must filter by institution
|
|
class Cohort < ApplicationRecord
|
|
scope :for_institution, ->(institution_id) { where(institution_id: institution_id) }
|
|
end
|
|
|
|
# In controllers
|
|
@cohort = current_institution.cohorts.find(params[:id])
|
|
```
|
|
|
|
### Email Encryption (Optional)
|
|
```ruby
|
|
# If policy requires, encrypt sensitive fields
|
|
class CohortEnrollment < ApplicationRecord
|
|
encrypts :student_email
|
|
encrypts :student_name
|
|
encrypts :student_surname
|
|
end
|
|
```
|
|
|
|
### Audit Trail
|
|
```ruby
|
|
# All tables have timestamps and soft deletes
|
|
t.timestamps
|
|
t.datetime :deleted_at
|
|
|
|
# Use paranoia gem or manual soft delete
|
|
def soft_delete
|
|
update!(deleted_at: Time.current)
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 📈 Sample Data
|
|
|
|
### Institution
|
|
```ruby
|
|
Institution.create!(
|
|
name: "TechPro Training Academy",
|
|
email: "admin@techpro.co.za",
|
|
contact_person: "Jane Smith",
|
|
phone: "+27 11 123 4567",
|
|
settings: {
|
|
logo_url: "/images/techpro-logo.png",
|
|
primary_color: "#1e3a8a"
|
|
}
|
|
)
|
|
```
|
|
|
|
### Cohort
|
|
```ruby
|
|
Cohort.create!(
|
|
institution: institution,
|
|
template: template, # Existing DocuSeal template
|
|
name: "2026 Q1 Software Learnership",
|
|
program_type: "learnership",
|
|
sponsor_email: "sponsor@company.co.za",
|
|
required_student_uploads: ["id_copy", "matric_certificate", "cv"],
|
|
cohort_metadata: {
|
|
start_date: "2026-02-01",
|
|
duration_months: 12,
|
|
stipend_amount: 3500
|
|
},
|
|
status: "active"
|
|
)
|
|
```
|
|
|
|
### Enrollment
|
|
```ruby
|
|
CohortEnrollment.create!(
|
|
cohort: cohort,
|
|
submission: submission, # Existing DocuSeal submission
|
|
student_email: "john.doe@example.com",
|
|
student_name: "John",
|
|
student_surname: "Doe",
|
|
student_id: "STU2026001",
|
|
status: "waiting",
|
|
role: "student",
|
|
uploaded_documents: {
|
|
"id_copy": true,
|
|
"matric_certificate": false
|
|
}
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## 🎯 Next Steps
|
|
|
|
1. **Implement Story 1.1**: Create migrations for these tables
|
|
2. **Implement Story 1.2**: Create ActiveRecord models
|
|
3. **Write Tests**: Verify schema and relationships
|
|
4. **Test Integration**: Ensure existing DocuSeal tables work
|
|
|
|
---
|
|
|
|
**Document Status**: ✅ Complete
|
|
**Ready for**: Story 1.1 implementation |