CREATE TABLE students ( id UUID PRIMARY KEY, school_id UUID NOT NULL REFERENCES schools(id), -- other fields);CREATE INDEX idx_students_tenant ON students(school_id);
-- Single-column index (basic)CREATE INDEX idx_students_school ON students(school_id);-- Composite index (optimized)CREATE INDEX idx_students_school_name ON students(school_id, full_name);
Composite indexes (school_id, other_field) are critical for performance with 50+ tenants.
CREATE TABLE schools ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, nit VARCHAR(20), resolution VARCHAR(100), is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE UNIQUE INDEX uq_schools_nit ON schools(nit) WHERE nit IS NOT NULL;
Key Points:
nit - Colombian tax ID (unique when present)
resolution - Ministry of Education approval number
Soft delete via is_active flag
school_settings
Purpose: Institution-wide configuration
CREATE TABLE school_settings ( school_id UUID PRIMARY KEY REFERENCES schools(id) ON DELETE CASCADE, pei_summary TEXT, simat_settings JSONB NOT NULL DEFAULT '{}'::jsonb, branding_settings JSONB NOT NULL DEFAULT '{}'::jsonb, security_settings JSONB NOT NULL DEFAULT '{}'::jsonb, habeas_data_text TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE TABLE school_years ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id), name VARCHAR(30) NOT NULL, starts_on DATE NOT NULL, ends_on DATE NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'planning', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT chk_school_years_status CHECK (status IN ('planning', 'active', 'closed', 'archived')), CONSTRAINT chk_school_years_dates CHECK (ends_on >= starts_on));CREATE UNIQUE INDEX uq_school_years_school_name ON school_years(school_id, name);
academic_periods
Purpose: Grading periods within a school year
CREATE TABLE academic_periods ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id), school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, number INT NOT NULL CHECK (number >= 1), name VARCHAR(50) NOT NULL, starts_on DATE NOT NULL, ends_on DATE NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'draft', CONSTRAINT chk_academic_periods_status CHECK (status IN ('draft', 'open', 'closed', 'published')));CREATE UNIQUE INDEX uq_academic_periods_school_year_number ON academic_periods(school_id, school_year_id, number);
CREATE TABLE users ( id UUID PRIMARY KEY, -- Same as Supabase Auth UUID email VARCHAR(255) NOT NULL, full_name VARCHAR(255) NOT NULL, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE UNIQUE INDEX uq_users_email ON users(lower(email));
Do not store passwords here. All auth is handled by Supabase. This table stores metadata only.
school_memberships
Purpose: Links users to schools with roles
CREATE TABLE school_memberships ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, roles JSONB NOT NULL DEFAULT '[]'::jsonb, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE UNIQUE INDEX uq_school_memberships_school_user ON school_memberships(school_id, user_id);
CREATE TABLE students ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id), user_id UUID REFERENCES users(id) ON DELETE SET NULL, document_type VARCHAR(10) NOT NULL, -- TI, RC, CC, CE document_number VARCHAR(30) NOT NULL, full_name VARCHAR(255) NOT NULL, birth_date DATE, gender VARCHAR(20), is_active BOOLEAN NOT NULL DEFAULT true, extra_data JSONB NOT NULL DEFAULT '{}'::jsonb, piar_data JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE UNIQUE INDEX uq_students_school_document ON students(school_id, document_type, document_number);CREATE INDEX idx_students_school_name ON students(school_id, full_name);
Key Points:
user_id is nullable - not all students have system access
piar_data stores PIAR/DUA plans (Ley 1421/2017)
Document uniqueness enforced per tenant
guardians
Purpose: Parent/guardian information (no system access)
CREATE TABLE guardians ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id), document_type VARCHAR(10) NOT NULL, document_number VARCHAR(30) NOT NULL, full_name VARCHAR(255) NOT NULL, phone VARCHAR(30), email VARCHAR(255), address TEXT, occupation VARCHAR(150), workplace VARCHAR(150), is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE UNIQUE INDEX uq_guardians_school_document ON guardians(school_id, document_type, document_number);
Guardians do not have user accounts. They exist only as administrative records linked to students.
student_guardians
Purpose: Student-guardian relationships
CREATE TABLE student_guardians ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id), student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, guardian_id UUID NOT NULL REFERENCES guardians(id) ON DELETE CASCADE, relationship VARCHAR(50) NOT NULL, -- padre, madre, abuelo, tío, etc. is_primary BOOLEAN NOT NULL DEFAULT false, priority INT NOT NULL DEFAULT 1, can_pickup BOOLEAN NOT NULL DEFAULT false, is_emergency_contact BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE UNIQUE INDEX uq_student_guardians_school_pair ON student_guardians(school_id, student_id, guardian_id);CREATE INDEX idx_student_guardians_student_priority ON student_guardians(school_id, student_id, priority);
CREATE TABLE enrollments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id), student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, grade_level VARCHAR(20) NOT NULL, group_code VARCHAR(10), shift VARCHAR(20), -- morning, afternoon, full_day, night status VARCHAR(30) NOT NULL DEFAULT 'pending_documents', status_history JSONB NOT NULL DEFAULT '[]'::jsonb, simat_status JSONB NOT NULL DEFAULT '{}'::jsonb, extra_data JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE UNIQUE INDEX uq_enrollments_school_student_year ON enrollments(school_id, student_id, school_year_id);CREATE INDEX idx_enrollments_school_status ON enrollments(school_id, status);
Status Flow:pending_documents → in_review → active → graduated | withdrawnStatus History Example:
Purpose: Track required documents for enrollment (including consent forms)
CREATE TABLE enrollment_documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id), enrollment_id UUID NOT NULL REFERENCES enrollments(id) ON DELETE CASCADE, document_type VARCHAR(50) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', r2_object_key VARCHAR(500) NOT NULL, file_name VARCHAR(255) NOT NULL, uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), validated_at TIMESTAMPTZ, validated_by UUID REFERENCES users(id) ON DELETE SET NULL, accepted_by_guardian_id UUID REFERENCES guardians(id) ON DELETE SET NULL, document_version VARCHAR(50), metadata JSONB NOT NULL DEFAULT '{}'::jsonb);CREATE UNIQUE INDEX uq_enrollment_documents_type ON enrollment_documents(school_id, enrollment_id, document_type);
# Auto-generate migration from model changesalembic revision --autogenerate -m "add_piar_data_to_students"# Review the generated file in alembic/versions/# Edit if needed, then apply:alembic upgrade head