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.
docuseal/docs/architecture/project-structure.md

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_attributes for data cleaning
  • Include soft delete via deleted_at timestamp
  • 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]_tables or add_[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, not cohorts.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

  1. Create migration: rails g migration CreateTableName
  2. Create model: rails g model TableName
  3. Add associations & validations
  4. Write model specs
  5. Run migration: rails db:migrate

Adding a New Controller

  1. Create controller: rails g controller Namespace/Name
  2. Add routes
  3. Add authentication/authorization
  4. Write controller specs
  5. Test manually

Adding a New Vue Component

  1. Create component file in appropriate portal folder
  2. Add to view or register globally
  3. Write component spec
  4. 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

  1. Convention Over Configuration: Follow Rails and Vue conventions
  2. Separation of Concerns: Keep models, controllers, views separate
  3. DRY: Reuse code via concerns, mixins, and components
  4. Testability: Design for easy testing
  5. Maintainability: Clear structure, good naming, documentation

  • 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