ROAR DocumentationROAR Documentation
  • Tech Specs
  • Databases
  • Workflows
  • Application
  • GitHub Actions
  • Dashboard Components
  • Firebase App Check
  • Cloud Functions
  • Backend Architecture
  • Internationalization
  • Integrating New Apps
  • Optimizing Assets
  • ROAR Redivis Instance
  • Logging and Querying
  • Emulation
  • Data Tools
  • Data Organization
  • Data Requests
GitHub
  • Tech Specs
  • Databases
  • Workflows
  • Application
  • GitHub Actions
  • Dashboard Components
  • Firebase App Check
  • Cloud Functions
  • Backend Architecture
  • Internationalization
  • Integrating New Apps
  • Optimizing Assets
  • ROAR Redivis Instance
  • Logging and Querying
  • Emulation
  • Data Tools
  • Data Organization
  • Data Requests
GitHub
  • Tech Specs
    • ROAR Backend Technical Specification
    • ROAR Administrations & Assignments: Technical Specification
    • ROAR Agreements: Technical Specification
    • ROAR Assessment Execution: Technical Specification
    • ROAR Task Integration with Measurement Services: Technical Specification
    • ROAR Parent Accounts: Technical Specification
    • ROAR Permissions: Technical Specification
    • ROAR Rostering Integration Spec
    • ROAR Task Configuration and Variant System: Technical Specification
    • ROAR Users and Organizations: Technical Specification
  • Databases
    • Database Information
    • gse-roar-admin
    • gse-roar-assessment
  • BigQuery
    • Querying Assessment Data
    • BigQuery schema: administrations
    • BigQuery Runtime Configuration File
    • BigQuery schema: classes
    • BigQuery schema: districts
    • BigQuery schema: families
    • BigQuery schema: groups
    • BigQuery schema: legal_versions
    • BigQuery schema: legal
    • Querying Admin Data
    • BigQuery schema: schools
    • BigQuery schema: user_runs
    • BigQuery schema: user_trials
    • BigQuery schema: users
  • Workflows
    • Workflows
    • Creating an Assignment
    • Authentication
    • Creating new Users
    • User Roster Changes
    • How to Impersonate a Clever User on Localhost
  • Application

    • Auth
  • GitHub Actions
    • ROAR Apps GitHub Actions
      • GitHub Actions in ROAR Apps
      • firebase-deploy-preview.yml
      • firebase-hosting-merge.yml
      • publish-to-npm-create-new-release.yml
      • submit-dashboard-pr.yml
    • ROAR Dashboard GitHub Actions
      • GitHub Actions in the ROAR Dashboard
  • Dashboard Components
    • Dashboard Components
    • Organization Score Reports
  • Firebase App Check
    • Firebase App Check Configuration for roar-firekit and roar-dashboard
  • Backend Architecture
    • Architecture
      • Backend Architecture in ROAR
      • Data Models
      • Database Implementations
      • Error Handling Architecture in ROAR
      • Repository Layer Architecture
      • Service Layer Architecture
    • API
      • Classes
        • Class: AdministrationServiceError
        • Class: FirebaseClientError
        • Class: FirebaseImplementationError
        • Class: FirestoreAdministrationRepository
        • Class: FirestoreAdministrationRepositoryError
        • Class: abstract FirestoreBaseRepository<T>
        • Class: FirestoreFilterAdapter
        • Class: FirestoreIdentityProviderRepository
        • Class: FirestoreIdentityProviderRepositoryError
        • Class: FirestoreOrgRepository
        • Class: FirestoreOrgRepositoryError
        • Class: FirestoreRepositoryError
        • Class: FirestoreUserClaimRepository
        • Class: FirestoreUserClaimRepositoryError
        • Class: FirestoreUserRepository
        • Class: FirestoreUserRepositoryError
        • Class: IdentityProviderServiceError
        • Classes
      • Enumerations
        • Enumeration: CollectionType
        • Enumeration: IdentityProviderType
        • Enumeration: Operator
        • Enumerations
      • Functions
        • Functions
        • Function: chunkOrgs()
        • Function: createAdministrationService()
        • Function: createFirestoreImplementation()
        • Function: createIdentityProviderService()
        • Function: isEmptyOrgs()
      • Interfaces
        • Interface: Administration
        • Interface: AdministrationBaseRepository
        • Interface: AdministrationService
        • Interface: AssentConsent
        • Interface: Assessment
        • Interface: BaseModel
        • Interface: BaseRepository<T>
        • Interface: Claims
        • Interface: CompositeCondition
        • Interface: CompositeFilter
        • Interface: CreateAdministrationServiceParams<AdminRepo, OrgRepo, UserClaimRepo>
        • Interface: CreateParams
        • Interface: DeleteParams
        • Interface: EducationalOrgsList
        • Interface: FieldCondition
        • Interface: FilterAdapter<T>
        • Interface: FirestoreCreateParams
        • Interface: FirestoreDeleteParams
        • Interface: FirestoreFetchDocumentParams
        • Interface: FirestoreGetAllParams
        • Interface: FirestoreGetByIdParams
        • Interface: FirestoreGetByNameParams
        • Interface: FirestoreGetByRoarUidParams
        • Interface: FirestoreGetParams
        • Interface: FirestoreGetWithFiltersParams
        • Interface: FirestoreImplementation
        • Interface: FirestoreRunTransactionParams<T>
        • Interface: FirestoreUpdateParams
        • Interface: FutureParams
        • Interface: GetAdministrationIdsForAdministratorParams
        • Interface: GetAdministrationIdsFromOrgsParams
        • Interface: GetAllParams
        • Interface: GetByNameParams
        • Interface: GetByProviderIdParams
        • Interface: GetByRoarUidParams
        • Interface: GetParams
        • Interface: GetRoarUidParams
        • Interface: IdentityProvider
        • Interface: IdentityProviderBaseRepository
        • Interface: IdentityProviderService
        • Interface: Legal
        • Interface: OrgBase
        • Interface: OrgBaseRepository
        • Interface: OrgsList
        • Interfaces
        • Interface: Result<T>
        • Interface: RunTransactionParams<T>
        • Interface: SingleFilter
        • Interface: UpdateParams
        • Interface: User
        • Interface: UserBaseRepository
        • Interface: UserClaim
        • Interface: UserClaimBaseRepository
        • Interface: createIdentityProviderServiceParams<IDPRepo, UserClaimRepo, UserRepo>
        • Interface: getAdministrationIdsFromOrgsParams
        • Interface: _setAdministrationIdsParams
      • Type Aliases
        • Type Alias: BaseFilter
        • Type Alias: ComparisonOperator
        • Type Alias: Condition
        • Type Alias: DocumentCreatedEvent
        • Type Alias: DocumentDeletedEvent
        • Type Alias: DocumentUpdatedEvent
        • Type Alias: DocumentWrittenEvent
        • Type Alias: ParameterValue
        • Type Aliases
        • Type Alias: SelectAllCondition
      • Variables
        • Variable: FirebaseAppClient
        • Variable: FirebaseAuthClient
        • Variable: FirestoreClient
        • Variable: ORG_NAMES
        • Variables API Documentation
    • Examples
      • Examples
    • Guides
      • Guides
  • Cloud Functions
    • gse-roar-admin
      • Admin Database
      • appendToAdminClaims()
      • associateassessmentuid()
      • createAdministratorAccount()
      • createGuestDocsForGoogleUsers()
      • createLevanteGroup()
      • createLevanteUsers()
      • createnewfamily()
      • createstudentaccount()
      • mirrorClasses()
      • mirrorCustomClaims
      • mirrorDistricts()
      • mirrorFamilies()
      • mirrorGroups()
      • mirrorSchools()
      • removefromadminclaims()
      • saveSurveyResponses()
      • setuidcustomclaims()
      • softDeleteUserAssignment()
      • softDeleteUserExternalData
      • softDeleteUser()
      • syncAssignmentCreated()
      • syncAssignmentDeleted()
      • syncAssignmentUpdated()
      • syncAssignmentsOnAdministrationUpdate()
      • syncAssignmentsOnUserUpdate()
      • syncCleverOrgs()
      • syncCleverUser()
    • gse-roar-assessment
      • Assessment Database
      • organizeBucketLogsByDate()
      • setuidclaims()
      • softDeleteGuestTrial()
      • softDeleteGuest()
      • softDeleteUserRun()
      • softDeleteUserTrial()
      • syncOnRunDocUpdate()
  • Internationalization
    • ROAM Fluency
    • ROAR Letter
    • ROAR Phoneme
    • Internationalization of ROAR Apps
    • ROAR Sentence
    • ROAR Word
  • Integrating New Apps
    • Integrating Roar Apps into the Dashboard
    • Dashboard Integration
    • Monitoring and Testing
    • Preparing the App for Packaging and Deployment
    • Packaging and Publishing to npm
    • Secrets in the GitHub Repository
  • Assets Optimization
    • Optimizing Assets
    • Audio Optimization Guide
    • Image Optimization Guide
  • ROAR Redivis Instance
    • ROAR Redivis Instance
    • ROAR Data Validator Trigger
    • ROAR Data Validator
  • Logging and Querying
    • ROAR Logging
  • Emulation
    • Running the Emulator
      • Commands
    • Emulator Configuration Guide
      • Configuration
      • Cypress Configuration
      • Setup and Dependencies
      • Firebase CLI Configuration
      • Firebase Emulator Configuration
      • GitHub Secrets and Workflows
      • Importing and Exporting Data
      • Local Environment Variables
  • Clowder Implementation
    • Clowder Integration
    • Letter - Clowder
    • Multichoice - Clowder
    • Phoneme - Clowder
    • ARF & CALF - Clowder

ROAR Users and Organizations: Technical Specification

Purpose and Scope

This document defines the data model for users and the organizational hierarchy in ROAR, including support for various org types (e.g., districts, schools, classes, families, groups) and user-org relationships.

System Overview

ROAR supports hierarchical and non-hierarchical organizations. Users may belong to multiple orgs and may hold different roles depending on their context. This spec focuses on the representation of orgs and memberships only — permissions are handled in a separate spec.

Definitions

  • User: A person with a ROAR account (student, teacher, admin, etc.)

  • Organization (Org): A logical unit like a school or group

  • Org Type:

    ROAR supports multiple types of organizations. Each org is identified by an org_type, which controls its behavior, relationships, and access patterns:

    org_typeDescription
    districtA school district, typically the top-level administrative unit for school rostering and SSO.
    schoolAn individual school (e.g., elementary, middle, high school) belonging to a district.
    localA city or community education authority (e.g., city early childhood services). May own one or more schools but does not imply a full district.
    stateA state or province-level education agency (e.g., NYSED, TEA).
    regionA multi-district cooperative, county office, or non-state regional education unit.
    familyA parent-created organization representing a household. It contains children and parent(s).
    groupA general-purpose user-defined org for collaboration, e.g., a teacher club, study group, or research circle.
    cohortA research study participant cohort, composed primarily of children enrolled via invitation codes or researcher enrollment.
  • Org Hierarchy: Parent-child relationships among orgs

  • Course: A curriculum-aligned instructional offering (e.g., "Grade 4 Math") that can be scheduled multiple times across different classes.

  • Class: A scheduled instance of a course, with associated students, educators, term, and time block metadata. A class may reference a course_id.

  • Term: A period of time during which classes are scheduled. Terms are used to group classes and students together for reporting and analysis.

  • Time Block: A period of time during which a class is scheduled. Time blocks are used to group classes and students together for reporting and analysis.

  • Role: A user’s function in an org (e.g., teacher, student, admin)

  • Opt-out status: Whether a user has individually opted out of research. Opted out users must never be included in research data, but their data is retained in order to fulfill obligations to partners (e.g., score reporting). Once the service obligation to a partner is terminated, opted out users are permanently deleted from the database and all backups.

  • Personally Identifiable Information (PII): PII for education records is a FERPA term referring to identifiable information that is maintained in education records and includes direct identifiers, such as a student’s name or identification number, indirect identifiers, such as a student’s date of birth, or other information which can be used to distinguish or trace an individual’s identity either directly or indirectly through linkages with other information. See Family Educational Rights and Privacy Act Regulations, 34 CFR §99.3, for a complete definition of PII specific to education records and for examples of other data elements that are defined to constitute PII.

  • Invitation Codes: To support self-service linking of users (especially children) to cohorts or groups, ROAR supports invitation codes. Invitation codes are stored in the invitation_codes table. Codes are typically used to join cohort, group, or family orgs. Codes must not be used for district, school, or class orgs tied to official rostering. After a code is redeemed, the child is added to the org_membership table with the designated role.

Component Flow Diagram

Runtime Behavior

  • A user may belong to multiple orgs at once.
  • Roles are stored per org membership. That is, a user may have different roles in different orgs.
  • Org hierarchies enable queries like "all students in a district."
  • User authentication and role checks should always resolve to the canonical user_id after following merged_into pointers. This allows multiple login credentials (e.g., SSO and email) to be linked to a unified identity.
  • Org Type Constraints:
    • family, group, and cohort orgs support flexible membership and invitation code-based joining.
    • district, school, and class orgs are roster-controlled. Membership must be provisioned via integrations (e.g., OneRoster API, Clever API) or trusted internal workflows (e.g., CSV upload).
    • Invitation codes must not grant access to roster-controlled orgs to avoid unverified role elevation.

Annual PII scrubbing

To comply with FERPA and data minimization principles, ROAR performs an annual PII scrubbing process to remove personally identifiable information (PII) for users who are no longer affiliated with any active organizations.

A user is eligible for PII scrubbing if:

  • The user has no active users_orgs records (i.e., all associated users_orgs.end_date values are in the past)
  • The user has not already been scrubbed (users.pii_scrubbed_at IS NULL)

For each user to be scrubbed, we take the following actions:

  • In the users table:

    • Nullify:
      • email
      • username
      • name fields
      • date_of_birth
    • Set pii_scrubbed_at = CURRENT_TIMESTAMP
  • In the user_external_ids table:

    • Nullify all identifiers not explicitly retained for reporting. This includes sis_id, state_student_id, clever_id, classlink_id
    • pii_scrubbed_at = CURRENT_TIMESTAMP

The scrub job is scheduled to run annually (e.g., each July) but it may be invoked manually or run in batches for performance. Here is SQL pseudocode for the annual scrubber:

-- 1. Identify users who are eligible for scrubbing (not previously scrubbed and no current orgs).
WITH scrub_candidates AS (
  SELECT u.id AS user_id
  FROM users u
  WHERE u.pii_scrubbed_at IS NULL
    AND NOT EXISTS (
      SELECT 1
      FROM users_orgs uo
      WHERE uo.user_id = u.id
        AND (uo.end_date IS NULL OR uo.end_date > CURRENT_DATE)
    )
)

-- 2. Scrub PII fields in the `users` table
UPDATE users
SET
  email = NULL,
  username = NULL,
  dob = NULL,
  name_first = NULL,
  name_last = NULL,
  name_middle = NULL,
  pii_scrubbed_at = CURRENT_TIMESTAMP
WHERE id IN (SELECT user_id FROM scrub_candidates);

-- 3. Scrub external identifiers
UPDATE user_external_ids
SET
  value = NULL,
  scrubbed_at = CURRENT_TIMESTAMP
WHERE user_id IN (SELECT user_id FROM scrub_candidates);

Edge Cases and Error Handling

ScenarioBehavior
User has no orgsMay have limited access only
Org with invalid parent_org_id400 Bad Request
Circular org hierarchy attempted400 Bad Request or block at UI
Attempt to create or redeem invitation codes for rostered orgs400 Bad Request

Design Rationale

  • A unified orgs table (as opposed to separate tables for districts, schools, etc.) keeps the model simple, flexible, and extensible.
  • Separate org types enable special-case logic (e.g., school vs. family).
  • Role scoping in users_orgs supports distinct permissions per org and use case.
  • Separating courses and classes from other orgs aligns with both OneRoster and Clever data models (N.B. Clever refers to classes as "sections").
  • Separate courses and classes allows course reuse across multiple classes (sections).
  • In Clever's data model each course and class are associated with only one grade, term, period, and subject. However, in the OneRoster data model, a course can be associated with multiple grades, terms, periods, and subjects. ROAR conforms to the OneRoster model by adding many-to-many join tables for these relationships. This aligns with OneRoster and is also more expressive (e.g., it supports multi-subject interdisciplinary classes, blended grades, and rolling terms).
  • Separate roles per org allows nuanced modeling (e.g., teacher in one, admin in another).
  • org_type = 'cohort' enables research-specific grouping and consent control.
  • Invitation codes enable secure, controlled, opt-in enrollment for ROAR@Home and study use.
  • merged_into allows identity unification without data loss or duplication.

SQL Schema

frl_status_enum

CREATE TYPE frl_status_enum AS ENUM ('free', 'reduced', 'paid', 'unknown');

grade_levels

CREATE TABLE grade_levels (
  name TEXT PRIMARY KEY,         -- The normalized enum value used in ROAR
  display_name TEXT NOT NULL,     -- Human-readable label for UI
  order_index INTEGER NOT NULL,   -- For ordered display
  one_roster_equiv TEXT           -- Closest OneRoster-compatible mapping
  school_level TEXT CHECK (school_level IN ('early', 'elementary', 'middle', 'high', 'postsecondary', 'ungraded', 'other'))
);

Here is the entire grade_levels table:

valuedisplay_nameorder_indexone_roster_equivschool_level
InfantToddlerInfant/Toddler0Otherearly
PreschoolPreschool1Otherearly
PreKindergartenPre-K2PKearly
TransitionalKindergartenTransitional Kindergarten3Otherearly
KindergartenKindergarten4Kelementary
11st Grade501elementary
22nd Grade602elementary
33rd Grade703elementary
44th Grade804elementary
55th Grade905elementary
66th Grade1006middle
77th Grade1107middle
88th Grade1208middle
99th Grade1309high
1010th Grade1410high
1111th Grade1511high
1212th Grade1612high
13Post-secondary1713postsecondary
PostGraduatePostgraduate18Otherpostsecondary
UngradedUngraded19Ungradedungraded
OtherOther20Otherother

users

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  auth_uid TEXT UNIQUE, -- Can be null on creation. Gets set when auth credentials are provisioned
  username TEXT UNIQUE NOT NULL,
  email TEXT,

  -- Name Fields
  name_first TEXT,
  name_last TEXT,
  name_middle TEXT,

  -- Demographics
  dob DATE,
  gender TEXT,               -- See enum options below
  grade TEXT REFERENCES grade_levels(name)
  school_level TEXT,         -- Derived (elementary, middle, etc.)
  hispanic_ethnicity BOOLEAN,
  race TEXT[],               -- Multiselect array (e.g., ['White', 'Asian'])
  frl_status frl_status_enum DEFAULT 'unknown',
  iep_status BOOLEAN,
  ell_status BOOLEAN,
  pii_scrubbed_at TIMESTAMP WITH TIME ZONE,

  -- Identifiers
  pid TEXT UNIQUE NOT NULL,  -- Human-readable unique participant ID
  email TEXT UNIQUE,

  -- Metadata
  merged_into UUID REFERENCES users(id),
  last_rostering_update TIMESTAMP,
  is_system_user BOOLEAN DEFAULT false,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
);

To support account linking (e.g., a parent with both a ROAR@Home and district SSO account), ROAR tracks identity merges with the merged_into column. If merged_into IS NULL, this is the canonical user account. If not null, the user record is a shadow of another user and all access should resolve to the target user.

Some tables include fields which track different users who have made changes or authorized certain actions. These could be used for audit logging and provenance tracking. However, it's common to run into cases where no human user directly initiated the action — e.g., a backend job, sync service, or webhook from Clever or another system. In these cases, we use a special "system" user (with is_system_user set to true) to represent the action. For example:

INSERT INTO users (id, name_first, name_last, username, email, pid, is_system_user)
VALUES
  ('00000000-0000-0000-0000-000000000001', 'System', 'Automated', 'system', 'system@roar.local', 'system', true),
  ('00000000-0000-0000-0000-000000000002', 'Clever', 'Sync', 'clever-sync', 'clever-sync@roar.local', 'clever-sync', true),
  ('00000000-0000-0000-0000-000000000003', 'OneRoster', 'Import', 'oneroster-import', 'oneroster-import@roar.local', 'oneroster-import', true);

external_id_types

CREATE TABLE external_id_types (
  name TEXT PRIMARY KEY,         -- e.g., 'clever', 'oneroster'
  display_name TEXT NOT NULL,    -- e.g., 'Clever', '1EdTech OneRoster'
  description TEXT,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
);

INSERT INTO external_id_types (name, display_name, description) VALUES
  ('clever', 'Clever', 'External ID provided by the Clever platform'),
  ('oneroster', '1EdTech OneRoster', 'ID aligned with OneRoster spec'),
  ('sis', 'SIS', 'Student Information System ID'),
  ('custom', 'Custom ID', 'Locally defined or imported identifier'),
  ('state_id', 'State ID', 'State-assigned unique student or org identifier'),
  ('local_id', 'Local ID', 'District-assigned ID not tied to a formal rostering system');
  ('nces_id', 'NCES ID', 'NCES-assigned unique identifier');
  ('mdr_number', 'MDR Number', 'MDR-assigned unique identifier')

user_external_ids

CREATE TABLE user_external_ids (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id),
  external_id TEXT NOT NULL,
  external_id_type TEXT REFERENCES external_id_types(name) NOT NULL,
  pii_scrubbed_at TIMESTAMP WITH TIME ZONE,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
  UNIQUE(user_id, external_id_type),
);

org_types

CREATE TABLE org_types (
  name TEXT PRIMARY KEY,
  one_roster_equiv TEXT CHECK (one_roster_equiv IN ('state', 'region', 'district', 'school', 'local', 'other')),
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
);

-- Seed values
INSERT INTO org_types (name, one_roster_equiv) VALUES
  ('district', 'district'),
  ('school', 'school'),
  ('local', 'local'),
  ('state', 'state'),
  ('region', 'region'),
  ('family', 'other'),
  ('group', 'other'),
  ('cohort', 'other');

orgs

CREATE TABLE orgs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  org_type TEXT REFERENCES org_types(name),
  parent_org_id UUID REFERENCES orgs(id) ON DELETE SET NULL,

  -- Location
  location_address_line1 TEXT;
  location_address_line2 TEXT;
  location_city TEXT;
  location_state_province TEXT;
  location_postal_code TEXT;
  location_country CHAR(2) DEFAULT 'US';  -- ISO 3166-1 alpha-2
  location_timezone TEXT;
  location_lat NUMERIC(9,6);
  location_long NUMERIC(9,6);

  -- Metadata
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
);

org_external_ids

CREATE TABLE org_external_ids (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID REFERENCES orgs(id),
  external_id TEXT NOT NULL,
  external_id_type TEXT REFERENCES external_id_types(name) NOT NULL,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
  UNIQUE(org_id, external_id_type),
);

users_orgs

The users_orgs table links users to orgs with a role. A user can belong to multiple orgs of different types (e.g., a child can be in a family, a class, and a cohort).

CREATE TABLE users_orgs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id),
  org_id UUID NOT NULL REFERENCES orgs(id),
  role TEXT NOT NULL REFERENCES roles(name),
  start_date DATE DEFAULT CURRENT_DATE,
  end_date DATE, -- NULL means currently active
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
  UNIQUE(user_id, org_id, role),
);

courses

CREATE TABLE courses (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID REFERENCES orgs(id),
  name TEXT NOT NULL,
  number TEXT,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
  UNIQUE(org_id, name),
);

course_grades

CREATE TABLE course_grades (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  course_id UUID REFERENCES courses(id),
  grade TEXT REFERENCES grade_levels(name),
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
  UNIQUE(course_id, grade),
);

course_subjects

CREATE TABLE course_subjects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  course_id UUID REFERENCES courses(id),
  subject TEXT,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
  UNIQUE(course_id, subject),
);

terms

CREATE TABLE terms (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID REFERENCES orgs(id),
  name TEXT,
  start_date DATE,
  end_date DATE,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
  UNIQUE(org_id, name),
);

classes

CREATE TABLE classes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID REFERENCES orgs(id),
  school_id UUID REFERENCES orgs(id),
  district_id UUID REFERENCES orgs(id),
  course_id UUID REFERENCES courses(id),
  class_type TEXT CHECK (class_type IN ('homeroom', 'scheduled', 'other')),
  name TEXT NOT NULL,
  number TEXT,
  term_id UUID REFERENCES terms(id),
  period TEXT,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
);

class_grades

CREATE TABLE class_grades (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  class_id UUID REFERENCES classes(id),
  grade TEXT REFERENCES grade_levels(name),
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
  UNIQUE(class_id, grade),
);

class_subjects

CREATE TABLE class_subjects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  class_id UUID REFERENCES classes(id),
  subject TEXT,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
  UNIQUE(class_id, subject),
);

class_terms

CREATE TABLE class_terms (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  class_id UUID REFERENCES classes(id),
  term_id UUID REFERENCES terms(id),
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
  UNIQUE(class_id, term_id),
);

class_periods

CREATE TABLE class_periods (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  class_id UUID REFERENCES classes(id),
  period TEXT,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
  UNIQUE(class_id, period),
);

invitation_codes

CREATE TABLE invitation_codes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  code TEXT UNIQUE NOT NULL,
  org_id UUID NOT NULL REFERENCES orgs(id),
  role TEXT NOT NULL REFERENCES roles(name),
  expires_at TIMESTAMP,
  max_uses INTEGER,
  used_count INTEGER DEFAULT 0,
  created_by UUID REFERENCES users(id),
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP,
);

API Contract

Create an org

POST /api/orgs
{
  "name": "Lincoln High",
  "org_type": "school",
  "parent_org_id": "uuid-of-district"
}

Update an org

PATCH /api/orgs/:id
{
  "name": "Lincoln High"
}

List orgs

GET /api/orgs

Get an org

GET /api/orgs/:id

Add a user to an org

POST /api/user-orgs
{
  "user_id": "uuid",
  "org_id": "uuid",
  "role": "teacher"
}

Remove a user from an org

DELETE /api/user-orgs/:user_id/:org_id

List user-org memberships

GET /api/users

Get a user

GET /api/users/:id

Create a user

POST /api/users
{
  "name": "John Doe",
  "email": "john.doe@example.com",
  "password": "password"
}

Update a user

PATCH /api/users/:id
{
  "name": "John Doe"
}

Merge users

Performs identity merge and updates merged_into.

POST /api/admin/users/merge
{
  "from_user_id": "uuid-home",
  "into_user_id": "uuid-ssologin",
  "justification": "User authenticated in both systems"
}

Redeem an invitation code

POST /api/invitations/redeem
{
  "code": "study-a-code",
  "child_id": "uuid-of-child"
}

Returns success or an error if the code is invalid, expired, or not applicable to the given child.

Migration Plan

  • Migrate current users and roles into the unified membership model
  • Assign default roles where missing
  • Migrate the Firestore org collections (districts, schools, families, groups) into the orgs table.
  • Migrate the Firestore user-org relationships (currently stored in the users collection) into the users_orgs table.
  • Populate org_type based on known groupings.

Summary

This spec formalizes a scalable and flexible representation of users and their relationships to hierarchical and non-hierarchical organizations.

Edit this page
Last Updated:
Contributors: Adam Richie-Halford
Prev
ROAR Task Configuration and Variant System: Technical Specification