学習パッケージ Docs Home API リファレンスDashboardsDB DesignER Diagram

DB 設計ドキュメント

学習アプリのデータベース設計。PostgreSQL 16+ on Aurora Serverless v2 を前提とする。


目次


概要

項目
DBMS PostgreSQL 16+
実行基盤 Aurora Serverless v2 + Data API
文字コード UTF-8
タイムゾーン UTC (アプリ側で JST 表示変換)
テーブル数 18
インデックス数 33 (主キー除く)

設計方針

サブジェクト分離

学習対象 (subject) ごとにテーブル群を分離する設計を採用:

両者を共通テーブルにせず、専用テーブルに分けるのは:

  1. データ構造が根本的に異なる (文字 vs 計算)
  2. nullable カラムや CHECK 制約が複雑化するのを避ける
  3. インデックス設計を最適化したい

概念の直交性

概念 表現
サブジェクト テーブル名のプレフィックス (letter_, number_)
アクティビティ letter_session_log.category (screening / learning / posttest)
テスト種別 letter_test_result.test_type (screening / posttest)

これにより、同じカラム名で違う意味を持つことを避ける。


命名規則とコンベンション

テーブル名

カラム名

ID 型

種類 生成
学校 TEXT 人間可読 (例 sch_001)
生徒・教員・管理者 UUID v7 アプリ側で生成 (時系列ソート可)
クラス・在籍履歴・セッション・解答ログ BIGSERIAL DB 側で生成
招待 token TEXT secrets.token_urlsafe(32)
email message_id TEXT SES の MessageId

列挙型

PostgreSQL の ENUM 型は将来の値追加が困難なため、TEXT + CHECK 制約で表現する。例:

category TEXT NOT NULL CHECK (category IN ('screening', 'learning', 'posttest'))

TEXT を採用する理由:

ストレージ効率は若干劣るが、可読性とのトレードオフを考慮した上で TEXT に統一。 ただし、以下のような 数値的な意味を持つ カラムは SMALLINT のまま:

teacher.role のように追加が頻繁にあり値が少ないものも同様に TEXT + CHECK 制約で表現:

role TEXT NOT NULL CHECK (role IN ('tannin', 'shunin', 'school_admin'))

文字データの正規化

データ 正規化ルール
email 小文字・前後空白除去
ふりがな ひらがな統一 (カタカナはサーバ側で変換)

これらはアプリケーション層で処理し、DB には正規化済みデータが保存される前提。


テーブル一覧

共通系 (7 テーブル)

テーブル 用途
school 学校マスタ
school_class クラス (年度ごと)
student 生徒
student_enrollment 在籍履歴 (年度をまたいだ追跡)
teacher 教員 (担任 / 学年主任 / 学校管理者)
system_admin システム運用者
student_status 生徒の状態 (ログイン連続日数等)

招待・メール系 (4 テーブル)

テーブル 用途
invitation 招待 token
email_send_log 全送信メールのログ
email_suppression 送信停止リスト
suppression_action_log suppression 操作の監査ログ

dyslexia 領域系 (7 テーブル, letter_ プレフィックス)

テーブル 用途
letter_block_definition 学習ブロック定義 (例: あ行)
letter_session_log セッションログ (1 セッション = 1 行)
letter_answer_log 解答ログ (1 解答 = 1 行)
letter_character_stat 文字単位の習熟度
letter_block_stat ブロック単位の習熟度
letter_daily_record 日次集計 (learning のみ)
letter_test_result テスト結果 (screening / posttest)

ER 図

erDiagram
    school ||--o{ school_class : "has"
    school ||--o{ student : "enrolls"
    school ||--o{ teacher : "employs"
    school_class ||--o{ student : "contains"
    school_class ||--o{ student_enrollment : "tracks"
    school_class ||--o{ teacher : "assigned to"
    student ||--o{ student_enrollment : "history"
    student ||--|| student_status : "1:1"

    student ||..o{ invitation : "user_id"
    teacher ||..o{ invitation : "user_id"
    teacher ||--o{ suppression_action_log : "performed by"

    student ||--o{ letter_session_log : "owns"
    student ||--o{ letter_answer_log : "answered"
    student ||--o{ letter_character_stat : "has"
    student ||--o{ letter_block_stat : "has"
    student ||--o{ letter_daily_record : "has"
    student ||--o{ letter_test_result : "took"

    student_enrollment ||--o{ letter_session_log : "during"
    student_enrollment ||--o{ letter_answer_log : "during"
    student_enrollment ||--o{ letter_test_result : "during"

    letter_session_log ||--o{ letter_answer_log : "contains"
    letter_session_log ||--o| letter_test_result : "produces"

    letter_block_definition ||--o{ letter_block_stat : "tracked"

    teacher ||..o{ letter_test_result : "teacher_judgment_by"

完全版の ER 図 (各カラムの詳細を含む) は er.mermaid を参照。


テーブル詳細

共通系

school - 学校マスタ

CREATE TABLE school (
    id          TEXT PRIMARY KEY,    -- 例 "sch_001"
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

設計判断:

school_class - クラス

CREATE TABLE school_class (
    id           BIGSERIAL PRIMARY KEY,
    school_id    TEXT NOT NULL REFERENCES school(id) ON DELETE CASCADE,
    school_year  INTEGER NOT NULL,
    grade        SMALLINT NOT NULL CHECK (grade BETWEEN 1 AND 6),
    name         TEXT NOT NULL,
    UNIQUE (school_id, school_year, grade, name)
);

設計判断:

student - 生徒

CREATE TABLE student (
    id                UUID PRIMARY KEY,        -- UUID v7
    cognito_sub       UUID UNIQUE,              -- サインアップ前は NULL
    school_id         TEXT NOT NULL REFERENCES school(id),
    class_id          BIGINT REFERENCES school_class(id),
    student_no        SMALLINT,
    gakuseki_no       TEXT,
    email             TEXT NOT NULL,
    family_name       TEXT NOT NULL,
    given_name        TEXT NOT NULL,
    family_name_kana  TEXT NOT NULL,
    given_name_kana   TEXT NOT NULL,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

設計判断:

外部キー:

student_enrollment - 在籍履歴

CREATE TABLE student_enrollment (
    id           BIGSERIAL PRIMARY KEY,
    student_id   UUID NOT NULL REFERENCES student(id) ON DELETE CASCADE,
    class_id     BIGINT NOT NULL REFERENCES school_class(id),
    student_no   SMALLINT,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (student_id, class_id)
);

設計判断:

teacher - 教員

CREATE TABLE teacher (
    id                UUID PRIMARY KEY,
    cognito_sub       UUID UNIQUE,
    school_id         TEXT NOT NULL REFERENCES school(id),
    class_id          BIGINT REFERENCES school_class(id),
    email             TEXT NOT NULL,
    family_name       TEXT NOT NULL,
    given_name        TEXT NOT NULL,
    family_name_kana  TEXT NOT NULL,
    given_name_kana   TEXT NOT NULL,
    role              TEXT NOT NULL DEFAULT 'tannin',
    CHECK (role IN ('tannin', 'shunin', 'school_admin')),
    CHECK (
        (role IN ('tannin', 'shunin') AND class_id IS NOT NULL)
        OR role = 'school_admin'
    )
);

設計判断:

ロールの権限範囲:

ロール 権限範囲
system_admin 全学校
school_admin 自校全体
shunin (学年主任) 自学年の全クラス
tannin (担任) 自クラスのみ

system_admin - システム運用者

CREATE TABLE system_admin (
    cognito_sub  UUID PRIMARY KEY,
    email        TEXT NOT NULL UNIQUE,
    name         TEXT NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

設計判断:

student_status - 生徒の状態

CREATE TABLE student_status (
    student_id                              UUID PRIMARY KEY REFERENCES student(id) ON DELETE CASCADE,
    last_login_date                         DATE,
    login_streak                            INTEGER NOT NULL DEFAULT 0,
    completed_dyslexia_learning_tutorial    BOOLEAN NOT NULL DEFAULT FALSE,
    completed_dyslexia_screening_tutorial   BOOLEAN NOT NULL DEFAULT FALSE,
    updated_at                              TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

設計判断:


招待・メール系

invitation - 招待 token

CREATE TABLE invitation (
    token        TEXT PRIMARY KEY,
    user_type    TEXT NOT NULL CHECK (user_type IN ('student', 'teacher')),
    user_id      UUID NOT NULL,
    email        TEXT NOT NULL,
    expires_at   TIMESTAMPTZ NOT NULL,
    consumed_at  TIMESTAMPTZ,
    sent_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

設計判断:

CREATE UNIQUE INDEX idx_invitation_active_email
    ON invitation (LOWER(email))
    WHERE consumed_at IS NULL;

有効期限: 3 日 (72 時間)、POST /invitations/sendoptions.expires_in_hours で上書き可

email_send_log - メール送信ログ

CREATE TABLE email_send_log (
    id              UUID PRIMARY KEY,
    message_id      TEXT,
    email           TEXT NOT NULL,
    template_name   TEXT NOT NULL,
    related_id      TEXT,
    status          TEXT NOT NULL,
    bounce_type     TEXT,
    bounce_subtype  TEXT,
    queued_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    sent_at         TIMESTAMPTZ,
    delivered_at    TIMESTAMPTZ,
    bounced_at      TIMESTAMPTZ,
    complained_at   TIMESTAMPTZ
);

status の値:

設計判断:

email_suppression - 送信停止リスト

CREATE TABLE email_suppression (
    email           TEXT PRIMARY KEY,
    reason          TEXT NOT NULL CHECK (reason IN ('hard_bounce', 'complaint', 'manual')),
    bounce_subtype  TEXT,
    diagnostic      TEXT,
    suppressed_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    notes           TEXT
);

reason:

設計判断:

suppression_action_log - suppression 操作の監査ログ

CREATE TABLE suppression_action_log (
    id            BIGSERIAL PRIMARY KEY,
    action        TEXT NOT NULL CHECK (action IN ('added', 'removed')),
    email         TEXT NOT NULL,
    reason        TEXT,
    performed_by  UUID REFERENCES teacher(id),
    performed_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    notes         TEXT,

    CHECK (reason IS NULL OR reason IN ('hard_bounce', 'complaint', 'manual'))
);

action:

設計判断:


dyslexia 領域系 (letter_)

letter_block_definition - 学習ブロック定義

CREATE TABLE letter_block_definition (
    id            TEXT PRIMARY KEY,            -- "block_a" 等
    display_name  TEXT NOT NULL,               -- "あ行"
    group_name    TEXT NOT NULL,               -- "hiragana_basic"
    characters    TEXT[] NOT NULL,             -- ["あ", "い", "う", "え", "お"]
    sort_order    INTEGER NOT NULL DEFAULT 0
);

設計判断:

letter_session_log - セッションログ

CREATE TABLE letter_session_log (
    id                  BIGSERIAL PRIMARY KEY,
    student_id          UUID NOT NULL REFERENCES student(id) ON DELETE CASCADE,
    enrollment_id       BIGINT NOT NULL REFERENCES student_enrollment(id),
    category            TEXT NOT NULL CHECK (category IN ('screening', 'learning', 'posttest')),
    date                DATE NOT NULL,
    start_time          TIMESTAMPTZ NOT NULL,
    end_time            TIMESTAMPTZ,
    duration_seconds    INTEGER,
    device_model        TEXT,
    max_volume_input    REAL,
    button_mash_count   INTEGER NOT NULL DEFAULT 0,
    errant_tap_count    INTEGER NOT NULL DEFAULT 0,
    block_ids           TEXT[] NOT NULL DEFAULT '{}'
);

category:

設計判断:

letter_answer_log - 解答ログ

CREATE TABLE letter_answer_log (
    id               BIGSERIAL PRIMARY KEY,
    student_id       UUID NOT NULL REFERENCES student(id) ON DELETE CASCADE,
    session_id       BIGINT NOT NULL REFERENCES letter_session_log(id) ON DELETE CASCADE,
    enrollment_id    BIGINT NOT NULL REFERENCES student_enrollment(id),
    category         TEXT NOT NULL CHECK (category IN ('screening', 'learning', 'posttest')),
    timestamp        TIMESTAMPTZ NOT NULL,
    question_char    TEXT NOT NULL,
    recognized_text  TEXT,
    is_correct       BOOLEAN,    -- NULL = screening バッチ判定待ち / 判定不能
    response_time    REAL,
    is_aborted       BOOLEAN NOT NULL DEFAULT FALSE
);

設計判断:

letter_character_stat - 文字単位習熟度

CREATE TABLE letter_character_stat (
    student_id                       UUID NOT NULL REFERENCES student(id) ON DELETE CASCADE,
    char_id                          TEXT NOT NULL,
    correct_count                    INTEGER NOT NULL DEFAULT 0,
    incorrect_count                  INTEGER NOT NULL DEFAULT 0,
    accumulated_fast_correct_count   INTEGER NOT NULL DEFAULT 0,
    mastery_count                    INTEGER NOT NULL DEFAULT 0,
    status                           SMALLINT NOT NULL DEFAULT 0,
    total_response_time              DOUBLE PRECISION NOT NULL DEFAULT 0,
    last_3_response_times            DOUBLE PRECISION[] NOT NULL DEFAULT '{}',
    last_5_results                   BOOLEAN[] NOT NULL DEFAULT '{}',
    updated_at                       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (student_id, char_id)
);

設計判断:

letter_block_stat - ブロック単位習熟度

CREATE TABLE letter_block_stat (
    student_id    UUID NOT NULL REFERENCES student(id) ON DELETE CASCADE,
    block_id      TEXT NOT NULL REFERENCES letter_block_definition(id),
    clear_count   INTEGER NOT NULL DEFAULT 0,
    mastery_rate  NUMERIC(5, 4) NOT NULL DEFAULT 0,
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (student_id, block_id)
);

設計判断:

letter_daily_record - 日次集計

CREATE TABLE letter_daily_record (
    student_id           UUID NOT NULL REFERENCES student(id) ON DELETE CASCADE,
    date                 DATE NOT NULL,
    answer_count         INTEGER NOT NULL DEFAULT 0,
    total_play_duration  DOUBLE PRECISION NOT NULL DEFAULT 0,
    PRIMARY KEY (student_id, date)
);

設計判断:

letter_test_result - テスト結果

CREATE TABLE letter_test_result (
    session_id                  BIGINT PRIMARY KEY REFERENCES letter_session_log(id) ON DELETE CASCADE,
    student_id                  UUID NOT NULL,
    enrollment_id               BIGINT NOT NULL,
    test_type                   TEXT NOT NULL CHECK (test_type IN ('screening', 'posttest')),

    correct_count               INTEGER,
    total_count                 INTEGER,
    correct_rate                NUMERIC(5, 4),

    -- AI 判定 (アルゴリズムによる二値判定)
    ai_judgment                 TEXT,    -- 'normal' / 'support' (NULL=未算出)

    -- 教員判定 (教員レビュー後に確定、三値)
    teacher_judgment            TEXT,    -- 'normal' / 'watch' / 'support' (NULL=未レビュー)
    teacher_judgment_by         UUID,    -- teacher.id または system_admin.cognito_sub (FK なし、§設計判断参照)
    teacher_judgment_at         TIMESTAMPTZ,

    created_at                  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at                  TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CHECK (ai_judgment IS NULL OR ai_judgment IN ('normal', 'support')),
    CHECK (teacher_judgment IS NULL OR teacher_judgment IN ('normal', 'watch', 'support'))
);

test_type:

AI 判定 (ai_judgment) (二値):

教員判定 (teacher_judgment) (三値):

設計判断:

インデックス:

CREATE INDEX idx_letter_test_result_alert
    ON letter_test_result (enrollment_id)
    WHERE ai_judgment = 'support' AND teacher_judgment IS NULL;

クラス集計について

クラス全体のテスト結果集計 (例: 「3年1組の AI support 数」「judgment 分布」など) は、事前集計テーブルを持たず、動的クエリで算出する 方針。

理由:

代表的な集計クエリ例:

SELECT
    COUNT(*) AS total_students,
    COUNT(tr.session_id) AS submitted_count,
    COUNT(*) FILTER (WHERE tr.ai_judgment = 'support') AS ai_support_count,
    COUNT(*) FILTER (WHERE tr.teacher_judgment IS NOT NULL) AS teacher_reviewed_count,
    COUNT(*) FILTER (WHERE tr.teacher_judgment = 'support') AS teacher_support_count,
    COUNT(*) FILTER (WHERE tr.teacher_judgment = 'watch')   AS teacher_watch_count,
    COUNT(*) FILTER (WHERE tr.teacher_judgment = 'normal')  AS teacher_normal_count,
    COUNT(*) FILTER (WHERE tr.session_id IS NOT NULL AND tr.teacher_judgment IS NULL) AS teacher_unreviewed_count,
    AVG(tr.correct_rate) AS avg_correct_rate
FROM student s
LEFT JOIN letter_test_result tr ON tr.student_id = s.id
    AND tr.test_type = $2
    AND DATE(tr.created_at AT TIME ZONE 'Asia/Tokyo') BETWEEN $3 AND $4
WHERE s.class_id = $1;

将来データ規模が大きくなり集計コストが問題になれば、このタイミングで letter_class_daily_test_stat のような事前集計テーブルを追加することは可能。今は 必要になるまで作らない


将来の拡張: dyscalculia 領域

将来 dyscalculia (算数障害) を扱う際の設計方針:

別系統テーブル群を新設

number_problem_definition    -- 計算問題定義 (例: 1桁同士の足し算)
number_session_log           -- セッション
number_answer_log            -- 解答 (question_text, recognized_number)
number_skill_stat            -- スキル単位習熟度 (足し算 / 引き算 / 掛け算)
number_problem_type_stat     -- 問題タイプ単位習熟度
number_daily_record          -- 日次集計 (dyscalculia learning)
number_test_result           -- テスト結果
number_class_daily_test_stat -- クラス集計

共通テーブルへの影響

student_statuscompleted_dyscalculia_*_tutorial カラムを追加。それ以外の共通テーブルは影響なし。

共通すべきもの・分離すべきものの判断基準

概念 共通 / 分離 理由
ユーザー (student) 共通 1 人の生徒が両方を学習
学校・クラス 共通 学校単位の管理
ログイン状態 共通 (student_status) ログイン自体は subject 非依存
セッション・解答ログ 分離 データ構造が異なる
習熟度 分離 単位が異なる (文字 vs スキル)
テスト結果 分離 judgment の意味・基準が異なる

インデックス戦略

部分インデックス (PostgreSQL の強み)

NULL 許容のカラムや特定条件のクエリに最適化:

-- gakuseki_no が NULL でない学生のみ UNIQUE
CREATE UNIQUE INDEX idx_student_gakuseki
    ON student (school_id, gakuseki_no)
    WHERE gakuseki_no IS NOT NULL;

-- 未消費の招待のみ
CREATE UNIQUE INDEX idx_invitation_active_email
    ON invitation (LOWER(email))
    WHERE consumed_at IS NULL;

-- 教員アラート用 (AI が support と判定 & 教員未レビュー)
CREATE INDEX idx_letter_test_result_alert
    ON letter_test_result (enrollment_id)
    WHERE ai_judgment = 'support' AND teacher_judgment IS NULL;

複合インデックスの順序

時系列降順を含む複合インデックスは「前方一致 + ソート」で活躍:

-- (student_id, date DESC) で「特定生徒の最新セッション」が高速
CREATE INDEX idx_letter_session_student_date
    ON letter_session_log (student_id, date DESC);

関数インデックス (LOWER)

email は小文字正規化が前提だが、念のため LOWER でインデックス:

CREATE INDEX idx_email_send_log_email
    ON email_send_log (LOWER(email));

運用上の注意点

1. UUID v7 の生成

UUID v7 は Postgres 標準では生成できない。アプリ側で生成して INSERT する。

TypeScript なら uuidv7 パッケージ (npm install uuidv7):

import { uuidv7 } from 'uuidv7';
const id = uuidv7();  // 例 "01890a5d-ac96-7c3e-9b3a-8f0c4e5d6f7a"

2. ON DELETE CASCADE の方針

3. テキストフィールドの長さ制限

PostgreSQL では TEXT 型に長さ制限はないが、アプリ層でバリデーション:

カラム 上限
family_name / given_name 50 文字
family_name_kana / given_name_kana 50 文字
email 254 文字 (RFC 5321 準拠)
school.name 100 文字

4. データ保持期間

5. PII (個人情報) の扱い


関連ドキュメント