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.
23 KiB
23 KiB
Project Structure - FloDoc Architecture
Document: File Organization & Conventions Version: 1.0 Last Updated: 2026-01-14
📁 Root Directory Structure
floDoc-v3/
├── app/ # Rails application code
├── config/ # Configuration files
├── db/ # Database migrations and schema
├── docs/ # Documentation
│ ├── architecture/ # Architecture docs (this folder)
│ ├── prd/ # Product requirements (sharded)
│ ├── po/ # Product Owner validation
│ ├── qa/ # QA assessments & gates
│ └── stories/ # Developer story files
├── lib/ # Library code
├── spec/ # Tests
├── app/javascript/ # Frontend code
├── .bmad-core/ # BMAD workflow configuration
├── docker-compose.yml # Local Docker setup
└── Gemfile, package.json # Dependencies
🎯 Application Directory Structure
app/models/ - Business Logic
app/models/
├── application_record.rb # Base model
├── user.rb # Devise authentication
├── account.rb # Multi-tenancy (existing)
├── template.rb # Document templates (existing)
├── submission.rb # Document workflows (existing)
├── submitter.rb # Signers (existing)
│
├── # NEW FloDoc Models
├── institution.rb # Single training institution
├── cohort.rb # Training program cohort
├── cohort_enrollment.rb # Student enrollment
│
├── # Concerns & Utilities
├── feature_flag.rb # Feature flag system
├── account_access.rb # Role-based access (existing)
└── ability.rb # Cancancan abilities (existing)
Key Patterns:
- All models inherit from
ApplicationRecord - Use
strip_attributesfor data cleaning - Include soft delete via
deleted_attimestamp - Follow Rails naming conventions (singular, lowercase)
Example Model:
# app/models/cohort.rb
class Cohort < ApplicationRecord
include SoftDeletable
strip_attributes
belongs_to :institution
belongs_to :template
has_many :cohort_enrollments, dependent: :destroy
validates :name, :program_type, :sponsor_email, presence: true
validates :status, inclusion: { in: %w[draft active completed] }
scope :active, -> { where(status: 'active') }
end
app/controllers/ - Request Handling
app/controllers/
├── application_controller.rb # Base controller
├── dashboard_controller.rb # TP dashboard (existing)
│
├── # NEW FloDoc Controllers
├── tp/ # TP Portal namespace
│ ├── base_controller.rb
│ ├── cohorts_controller.rb
│ ├── enrollments_controller.rb
│ └── dashboard_controller.rb
│
├── student/ # Student Portal namespace
│ ├── base_controller.rb
│ ├── enrollment_controller.rb
│ └── documents_controller.rb
│
├── sponsor/ # Sponsor Portal namespace
│ ├── base_controller.rb
│ ├── dashboard_controller.rb
│ └── signing_controller.rb
│
├── # API Controllers
├── api/
│ ├── v1/
│ │ ├── base_controller.rb
│ │ ├── cohorts_controller.rb
│ │ ├── enrollments_controller.rb
│ │ └── webhooks_controller.rb
│ └── v2/ # Future versions
│
├── # Settings & Admin
├── settings/
│ ├── profile_controller.rb
│ └── security_controller.rb
│
└── # Existing DocuSeal Controllers
├── templates_controller.rb
├── submissions_controller.rb
└── submitters_controller.rb
Controller Patterns:
TP Portal Controller:
# app/controllers/tp/cohorts_controller.rb
class tp::CohortsController < ApplicationController
before_action :authenticate_user!
load_and_authorize_resource
def index
@cohorts = current_institution.cohorts.order(created_at: :desc)
end
def create
@cohort = current_institution.cohorts.new(cohort_params)
if @cohort.save
redirect_to tp_cohort_path(@cohort), notice: 'Cohort created'
else
render :new
end
end
private
def cohort_params
params.require(:cohort).permit(:name, :program_type, :sponsor_email, :template_id)
end
end
Ad-hoc Portal Controller:
# app/controllers/student/enrollment_controller.rb
class Student::EnrollmentController < ApplicationController
skip_before_action :authenticate_user!
def show
@enrollment = CohortEnrollment.find_by!(token: params[:token])
redirect_to root_path, alert: 'Invalid token' unless @enrollment.pending?
end
def submit
@enrollment = CohortEnrollment.find_by!(token: params[:token])
# Process submission
end
end
API Controller:
# app/api/v1/cohorts_controller.rb
class Api::V1::CohortsController < Api::V1::BaseController
def index
@cohorts = current_institution.cohorts
render json: @cohorts
end
def create
@cohort = current_institution.cohorts.new(cohort_params)
if @cohort.save
render json: @cohort, status: :created
else
render json: { errors: @cohort.errors }, status: :unprocessable_entity
end
end
private
def cohort_params
params.permit(:name, :program_type, :sponsor_email, :template_id)
end
end
app/javascript/ - Frontend Code
app/javascript/
├── application.js # Main entry point
├── packs/ # Webpack packs
│ └── application.js
│
├── # NEW FloDoc Portals
├── tp/ # TP Portal (Admin)
│ ├── index.js # Vue app entry
│ ├── views/
│ │ ├── Dashboard.vue
│ │ ├── CohortList.vue
│ │ ├── CohortCreate.vue
│ │ ├── CohortDetail.vue
│ │ ├── StudentManagement.vue
│ │ └── SponsorPortal.vue
│ ├── components/
│ │ ├── CohortCard.vue
│ │ ├── CohortForm.vue
│ │ ├── StudentTable.vue
│ │ └── StatusBadge.vue
│ ├── stores/
│ │ ├── cohortStore.js # Pinia store
│ │ └── uiStore.js
│ └── api/
│ └── cohorts.js # API client
│
├── student/ # Student Portal
│ ├── index.js
│ ├── views/
│ │ ├── Enrollment.vue
│ │ ├── DocumentUpload.vue
│ │ ├── FormFill.vue
│ │ └── SubmissionStatus.vue
│ ├── components/
│ │ ├── UploadZone.vue
│ │ ├── FormField.vue
│ │ └── ProgressTracker.vue
│ ├── stores/
│ │ └── enrollmentStore.js
│ └── api/
│ └── enrollment.js
│
├── sponsor/ # Sponsor Portal
│ ├── index.js
│ ├── views/
│ │ ├── Dashboard.vue
│ │ ├── BulkSigning.vue
│ │ ├── DocumentPreview.vue
│ │ └── ProgressTracker.vue
│ ├── components/
│ │ ├── CohortSummary.vue
│ │ ├── SigningTable.vue
│ │ └── BulkSignButton.vue
│ ├── stores/
│ │ └── sponsorStore.js
│ └── api/
│ └── sponsor.js
│
├── # Shared Components
├── components/
│ ├── ui/
│ │ ├── Button.vue
│ │ ├── Modal.vue
│ │ ├── Alert.vue
│ │ └── LoadingSpinner.vue
│ └── layout/
│ ├── Header.vue
│ ├── Sidebar.vue
│ └── Footer.vue
│
├── # Existing DocuSeal UI
├── template_builder/ # PDF form builder
├── elements/ # Web components
├── submission_form/ # Multi-step signing
│
└── # Utilities
├── utils/
│ ├── auth.js
│ ├── api.js
│ └── validators.js
└── plugins/
└── axios.js
Vue Component Pattern:
<!-- app/javascript/tp/views/CohortList.vue -->
<template>
<div class="cohort-list">
<Header title="Cohorts" />
<div v-if="loading">Loading...</div>
<div v-else>
<CohortCard
v-for="cohort in cohorts"
:key="cohort.id"
:cohort="cohort"
@click="viewCohort(cohort.id)"
/>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useCohortStore } from '@/tp/stores/cohortStore'
import CohortCard from '@/tp/components/CohortCard.vue'
const cohortStore = useCohortStore()
const loading = ref(true)
onMounted(async () => {
await cohortStore.fetchCohorts()
loading.value = false
})
function viewCohort(id) {
// Navigate to detail view
}
</script>
<style scoped>
.cohort-list {
padding: 2rem;
}
</style>
Pinia Store Pattern:
// app/javascript/tp/stores/cohortStore.js
import { defineStore } from 'pinia'
import { CohortsAPI } from '@/tp/api/cohorts'
export const useCohortStore = defineStore('cohort', {
state: () => ({
cohorts: [],
currentCohort: null,
loading: false,
error: null
}),
actions: {
async fetchCohorts() {
this.loading = true
this.error = null
try {
this.cohorts = await CohortsAPI.getAll()
} catch (err) {
this.error = err.message
} finally {
this.loading = false
}
},
async createCohort(data) {
const cohort = await CohortsAPI.create(data)
this.cohorts.unshift(cohort)
return cohort
},
async fetchCohort(id) {
this.currentCohort = await CohortsAPI.get(id)
}
},
getters: {
activeCohorts: (state) => state.cohorts.filter(c => c.status === 'active'),
completedCohorts: (state) => state.cohorts.filter(c => c.status === 'completed')
}
})
API Client Pattern:
// app/javascript/tp/api/cohorts.js
import axios from 'axios'
export const CohortsAPI = {
async getAll() {
const response = await axios.get('/api/v1/cohorts')
return response.data
},
async get(id) {
const response = await axios.get(`/api/v1/cohorts/${id}`)
return response.data
},
async create(data) {
const response = await axios.post('/api/v1/cohorts', data)
return response.data
},
async update(id, data) {
const response = await axios.patch(`/api/v1/cohorts/${id}`, data)
return response.data
},
async startSigning(id) {
const response = await axios.post(`/api/v1/cohorts/${id}/start_signing`)
return response.data
}
}
app/controllers/api/ - API Layer
app/controllers/api/
├── v1/
│ ├── base_controller.rb # API authentication & versioning
│ ├── cohorts_controller.rb # Cohort CRUD
│ ├── enrollments_controller.rb # Enrollment management
│ ├── students_controller.rb # Student portal API
│ ├── sponsors_controller.rb # Sponsor portal API
│ ├── webhooks_controller.rb # Webhook endpoints
│ └── uploads_controller.rb # File uploads
│
└── v2/ # Future version
└── base_controller.rb
API Base Controller:
# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ActionController::API
before_action :authenticate_api!
rescue_from StandardError, with: :handle_error
private
def authenticate_api!
token = request.headers['Authorization']&.split(' ')&.last
@current_user = User.find_by(jwt_token: token)
render json: { error: 'Unauthorized' }, status: :unauthorized unless @current_user
end
def current_institution
@current_user.institution
end
def handle_error(exception)
render json: { error: exception.message }, status: :internal_server_error
end
end
db/migrate/ - Database Migrations
db/migrate/
├── 20260114000001_create_flo_doc_tables.rb # Story 1.1
├── 20260114000002_create_feature_flags.rb # Story 1.2
├── 20260114000003_add_flo_doc_indexes.rb # Performance
└── # Existing DocuSeal migrations
Migration Naming Convention:
- Timestamp format:
YYYYMMDDHHMMSS - Descriptive name:
create_[table]_tablesoradd_[field]_to_[table] - Group related changes
Example Migration:
# db/migrate/20260114000001_create_flo_doc_tables.rb
class CreateFloDocTables < ActiveRecord::Migration[7.0]
def change
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
add_index :institutions, :name, unique: true
add_index :institutions, :email, unique: true
# ... more tables
end
end
spec/ - Test Suite
spec/
├── models/
│ ├── institution_spec.rb
│ ├── cohort_spec.rb
│ └── cohort_enrollment_spec.rb
│
├── controllers/
│ ├── tp/
│ │ ├── cohorts_controller_spec.rb
│ │ └── dashboard_controller_spec.rb
│ ├── student/
│ │ └── enrollment_controller_spec.rb
│ └── api/
│ └── v1/
│ ├── cohorts_controller_spec.rb
│ └── webhooks_controller_spec.rb
│
├── requests/
│ └── api/
│ └── v1/
│ ├── cohorts_spec.rb
│ └── webhooks_spec.rb
│
├── system/
│ ├── tp_portal_spec.rb
│ ├── student_portal_spec.rb
│ └── sponsor_portal_spec.rb
│
├── migrations/
│ └── create_flo_doc_tables_spec.rb
│
├── javascript/
│ ├── tp/
│ │ ├── views/
│ │ │ └── CohortList.spec.js
│ │ └── stores/
│ │ └── cohortStore.spec.js
│ └── student/
│ └── stores/
│ └── enrollmentStore.spec.js
│
├── factories/
│ ├── institutions.rb
│ ├── cohorts.rb
│ └── cohort_enrollments.rb
│
└── support/
├── database_cleaner.rb
└── api_helpers.rb
Model Spec Example:
# spec/models/cohort_spec.rb
require 'rails_helper'
RSpec.describe Cohort, type: :model do
describe 'validations' do
it { should validate_presence_of(:name) }
it { should validate_presence_of(:program_type) }
it { should validate_inclusion_of(:status).in_array(%w[draft active completed]) }
end
describe 'associations' do
it { should belong_to(:institution) }
it { should belong_to(:template) }
it { should have_many(:cohort_enrollments) }
end
describe '#active?' do
it 'returns true when status is active' do
cohort = build(:cohort, status: 'active')
expect(cohort.active?).to be true
end
end
end
Vue Component Spec Example:
// spec/javascript/tp/views/CohortList.spec.js
import { mount, flushPromises } from '@vue/test-utils'
import CohortList from '@/tp/views/CohortList.vue'
import { createPinia, setActivePinia } from 'pinia'
import { useCohortStore } from '@/tp/stores/cohortStore'
describe('CohortList', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('displays loading state', () => {
const wrapper = mount(CohortList)
expect(wrapper.text()).toContain('Loading')
})
it('displays cohorts after loading', async () => {
const wrapper = mount(CohortList)
const store = useCohortStore()
store.cohorts = [{ id: 1, name: 'Test Cohort' }]
await flushPromises()
expect(wrapper.text()).toContain('Test Cohort')
})
})
lib/ - Utility Modules
lib/
├── # Business Logic Helpers
├── submissions.rb # Submission workflows (existing)
├── submitters.rb # Submitter logic (existing)
├── cohorts.rb # Cohort workflows (NEW)
├── enrollments.rb # Enrollment logic (NEW)
│
├── # PDF Processing
├── pdf_utils.rb # PDF utilities
├── pdfium.rb # PDF rendering
│
├── # Webhooks
├── send_webhook_request.rb # Webhook delivery
├── webhook_events.rb # Event definitions
│
├── # Token Management
├── token_generator.rb # Secure token generation
├── token_verifier.rb # Token validation
│
└── # Utilities
├── load_active_storage_configs.rb
└── feature_flag_loader.rb
Utility Module Example:
# lib/cohorts.rb
module Cohorts
module Workflow
def self.advance_to_active(cohort)
return false unless cohort.draft?
cohort.update!(status: 'active')
CohortMailer.activated(cohort).deliver_later
true
end
def self.ready_for_sponsor?(cohort)
cohort.students_completed_at.present? &&
cohort.tp_signed_at.present? &&
cohort.cohort_enrollments.students.any?
end
end
end
config/ - Configuration
config/
├── application.rb # Rails config
├── database.yml # Database config
├── routes.rb # All routes
├── storage.yml # Active Storage
├── sidekiq.yml # Sidekiq config
├── shakapacker.yml # Webpack config
│
├── # Initializers
├── devise.rb # Devise config
├── cors.rb # CORS settings
├── active_storage.rb # Storage config
│
└── # Environments
├── development.rb
├── test.rb
└── production.rb
Routes Configuration:
# config/routes.rb
Rails.application.routes.draw do
# Existing DocuSeal routes
resources :templates
resources :submissions
# TP Portal (authenticated)
namespace :tp do
root 'dashboard#index'
resources :cohorts do
member do
post :start_signing
post :finalize
end
resources :enrollments, only: [:index, :show]
end
resources :students, only: [:index, :show]
resources :sponsors, only: [:index, :show]
end
# Student Portal (ad-hoc tokens)
scope module: :student do
get '/enroll/:token', to: 'enrollment#show', as: :student_enroll
post '/enroll/:token/submit', to: 'enrollment#submit'
get '/status/:token', to: 'enrollment#status'
end
# Sponsor Portal (ad-hoc tokens)
scope module: :sponsor do
get '/sponsor/:token', to: 'dashboard#show', as: :sponsor_dashboard
post '/sponsor/:token/sign', to: 'signing#bulk_sign'
end
# API v1
namespace :api do
namespace :v1 do
resources :cohorts do
member do
post :start_signing
end
end
resources :enrollments
resources :students, only: [:show, :update]
resources :sponsors, only: [:show]
resources :webhooks, only: [:create]
end
end
# Devise (existing)
devise_for :users
end
🎯 File Naming Conventions
Models
- Singular:
cohort.rb, notcohorts.rb - Snake case:
cohort_enrollment.rb - Table name: Plural (Rails convention)
Controllers
- Plural:
cohorts_controller.rb - Namespaced:
tp/cohorts_controller.rb - API versioned:
api/v1/cohorts_controller.rb
Views
- Controller-based:
app/views/tp/cohorts/ - Template names:
index.html.erb,show.html.erb,_form.html.erb
JavaScript
- Components: PascalCase (
CohortCard.vue) - Stores: camelCase (
cohortStore.js) - API: PascalCase (
CohortsAPI) - Views: PascalCase (
CohortList.vue)
Tests
- Models:
model_name_spec.rb - Controllers:
controller_name_spec.rb - Requests:
request_name_spec.rb - System:
feature_name_spec.rb - JavaScript:
ComponentName.spec.js
🔧 Configuration Files
Gemfile
# Core
gem 'rails', '~> 7.0'
# Database
gem 'pg', '~> 1.4'
# Authentication
gem 'devise', '~> 4.8'
gem 'devise-two-factor'
gem 'cancancan', '~> 3.0'
# Background Jobs
gem 'sidekiq', '~> 7.0'
# PDF
gem 'hexapdf', '~> 0.15'
# API
gem 'jbuilder'
# Security
gem 'rack-attack'
package.json
{
"name": "flo-doc",
"dependencies": {
"vue": "^3.3.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"tailwindcss": "^3.4.17",
"daisyui": "^3.9.4"
},
"devDependencies": {
"@vue/test-utils": "^2.4.0",
"vitest": "^1.0.0"
}
}
docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
depends_on:
- db
- redis
- minio
environment:
DATABASE_URL: postgresql://postgres:password@db:5432/flo_doc
REDIS_URL: redis://redis:6379
db:
image: postgres:14
environment:
POSTGRES_PASSWORD: password
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
minio:
image: minio/minio
command: server /data
ports:
- "9000:9000"
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025"
- "8025:8025"
volumes:
postgres_data:
📊 Source Tree Reference
For complete file tree with explanations, see: docs/architecture/source-tree.md
🎯 Development Workflow
Adding a New Model
- Create migration:
rails g migration CreateTableName - Create model:
rails g model TableName - Add associations & validations
- Write model specs
- Run migration:
rails db:migrate
Adding a New Controller
- Create controller:
rails g controller Namespace/Name - Add routes
- Add authentication/authorization
- Write controller specs
- Test manually
Adding a New Vue Component
- Create component file in appropriate portal folder
- Add to view or register globally
- Write component spec
- Test in browser
Running Tests
# Ruby tests
bundle exec rspec spec/models/cohort_spec.rb
# JavaScript tests
yarn test spec/javascript/tp/views/CohortList.spec.js
# All tests
bundle exec rspec
yarn test
🔍 Key Principles
- Convention Over Configuration: Follow Rails and Vue conventions
- Separation of Concerns: Keep models, controllers, views separate
- DRY: Reuse code via concerns, mixins, and components
- Testability: Design for easy testing
- Maintainability: Clear structure, good naming, documentation
📚 Related Documents
- Tech Stack:
docs/architecture/tech-stack.md - Data Models:
docs/architecture/data-models.md - Coding Standards:
docs/architecture/coding-standards.md - Source Tree:
docs/architecture/source-tree.md
Document Status: ✅ Complete Ready for: Implementation