![[管理画面フォーマット開発編 #1] Prisma × PostgreSQLで進めるDB設計](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-prisma-db-design%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット開発編 #1Prisma × PostgreSQLで進めるDB設計
管理画面フォーマット(UIのみ版)を土台に、バックエンドの第一弾としてのDB設計
初回公開日
最終更新日
0. はじめに
本記事は、これまでに公開してきた 管理画面フォーマット(UIのみ版) を土台に、バックエンド開発へ進む最初のステップです。具体的には、Prisma × PostgreSQL を用いた DB設計 に焦点を当てます。
◯ 管理画面フォーマット(UIのみ版)については下記を参照ください
本記事の位置づけ
これまでのシリーズでは、ログイン画面・ユーザー管理・サイドバーなど、UI部分を中心に整備してきました。その成果をまとめた「UIデモ」までを完了した今、UIに接続するデータの基盤を設計することが次のステップとなります。
DB設計は単なるデータ格納の定義ではなく、サービスの将来像を見据えたスキーマの骨格を決める重要工程です。とりわけDELOGsプロジェクトでは、複数のWebサービスや法人利用を前提とした開発を目指しています。
また、ソースコードは上記の 【管理画面フォーマット制作編 #9】 Shadcn/ui で作る管理画面フォーマット ─ デモ公開とカスタマイズ方法 に記載したGithubにあるソースコードを改変していきます。
本記事のゴール
本記事では、以下の2点をゴールとします。
| ゴール | 内容 |
|---|---|
| Account関連の正規化 | 企業ID(Account)を起点に、支店(Branch)、部署(Department)、担当者(Contact)、利用契約(Subscription)を正規化して設計する |
| displayIdルール確立 | 2文字接頭辞 + 8桁ゼロ埋めの一貫した形式を定義し、主体テーブルにのみ採用する |
| 管理画面フォーマットで利用する各テーブルの設計 | ユーザ(User)、ロール(Role)、メニュー(Menu) |
これにより、ユーザーやロール、メニューといった管理画面の主要要素を 法人利用に耐えうる形で整理し、今後の記事で取り扱うAPIやRBAC設計へとつなげます。
前提環境と採用技術
DELOGsプロジェクトにおける開発環境・技術スタックは下記の通りです。
| 区分 | 採用技術・環境 |
|---|---|
| フロント | Next.js 15、shadcn/ui、Tailwind CSS |
| サーバ | Ubuntu 24.04 LTS、Nginx、Node.js(PM2管理) |
| DB | PostgreSQL 13(さくらのクラウド・アプライアンス構成) |
| ORマッパー | Prisma |
UIのみ版で整えた土台に対し、今回の記事からはDB設計を開始し、次回以降でスキーマ実装やSeed投入へ進んでいきます。
管理画面フォーマット開発編の完成形(デモとGithubリポジトリ)

「管理画面フォーマット開発編 #1〜#11」の記事内容についてのデモページです。下記より確認可能です。
管理画面フォーマット開発編の完成版デモ: https://delogs.jp/demo/dashboard-format
管理画面フォーマット開発編の完成版デモ: https://delogs.jp/demo/dashboard-format
Githubにも公開済みです。
管理画面フォーマット開発編のGihubリポジトリ: https://github.com/delogs-jp/dashboard-format-fullstack
管理画面フォーマット開発編のGihubリポジトリ: https://github.com/delogs-jp/dashboard-format-fullstack
Prismaの準備
【管理画面フォーマット制作編 #9】 Shadcn/ui で作る管理画面フォーマット ─ デモ公開とカスタマイズ方法 の段階では、まだプロジェクトに Prisma をインストールしていません。
過去記事 Prisma × PostgreSQLで始めるユーザー・ロール管理 を参考にインストールを実行します。
DB作成
zsh
1psql -h 192.168.XX.XX -p 5432 -U your_user 上記でDBの接続して、下記コマンドで管理画面フォーマット用のDBを作成します。
zsh
1CREATE DATABASE delogs_demo
2 WITH ENCODING 'UTF8'
3 LC_COLLATE='ja_JP.UTF-8'
4 LC_CTYPE='ja_JP.UTF-8'
5 TEMPLATE=template0;delogs_demoがデータベース名ですので、任意の名称に置き換えてください。Prismaのインストール
zsh
1npm install prisma --save-dev
2npm install @prisma/client @prisma/extension-read-replicas@prisma/extension-read-replicasはPrismaでリードレプリカDBを扱うためのプラグインです。リード用DBがある環境では、インストールすると便利です。
詳細は、 【自サーバでNext.jsアプリを動かす#5】リードレプリカ(スレーブ)DBの利用 を参照ください。Prismaの初期化
zsh
1npx prisma init.envの設定
ローカル環境からのDB接続に設定を行ってください。
env
1# 例
2DATABASE_URL="postgresql://dbuser:dbuserpassword@127.0.0.1:5433/delogs_demo?schema=public"
3DATABASE_URL_REPLICA="postgresql://dbuser:dbuserpassword@127.0.0.1:5434/delogs_demo?schema=public"上記はローカルPCからSSHトンネルでDBサーバへ接続する場合の例になっています。
これで準備完了です。次章から設計を行っていきます。
1. 設計原則 ─ 命名・共通カラム・論理削除
DB設計を進めるにあたり、まずは全テーブルに共通する「設計原則」を定義します。これらは、スキーマを一貫性のある形で保ち、将来的な拡張や保守を容易にするための基本ルールとなります。
命名規則
命名規則は、PrismaとPostgreSQLの両方において可読性と拡張性を両立するために定めます。基本方針は「モデル名は単数形・PascalCase」「カラムはcamelCase」です。
| 区分 | 規則 | 例 |
|---|---|---|
| テーブル(Prismaモデル名) | 単数形、PascalCase | User, Account, Branch |
| テーブル(DB物理名) | ダブルクオート付きPascalCase(Prisma既定) | "User", "Account" |
| カラム名 | camelCase | createdAt, updatedAt |
| 外部キー | <参照先名>Id | accountId, branchId |
| インデックス名 | <Table>_<Column>_idx | User_email_idx |
共通カラム
すべての「主体テーブル」に共通して持たせるカラムを定義します。中間テーブルは対象外とします。
| カラム | 型 | 説明 |
|---|---|---|
| id | UUID | プライマリキー |
| displayId | VARCHAR(10) | 2文字接頭辞 + 8桁ゼロ埋め(例: AC00000001)。主体テーブルのみ |
| createdAt | TIMESTAMP | レコード作成日時 |
| updatedAt | TIMESTAMP | レコード更新日時 |
| deletedAt | TIMESTAMP NULLABLE | 論理削除を採用する場合に利用 |
これにより、すべての主要テーブルが共通の監査基盤を備え、画面やAPIで一貫した扱いが可能になります。
論理削除の扱い
データ削除の扱いは、システム運用上の要件に直結するため、明確にルールを定めます。
- 論理削除(deletedAtを利用)を基本とする
履歴管理や復元が容易になり、誤削除による影響を軽減できます。 - 物理削除とするケース
中間テーブル(UserRole, MenuRole など)は冗長データが少なく、再作成も容易なため物理削除を許容します。
| 区分 | 採用方式 | 理由 |
|---|---|---|
| 主体テーブル(Account, Branch, User, Menu など) | 論理削除 | 監査性・復元性を優先 |
| 中間テーブル(UserRole, MenuRole など) | 物理削除 | 再作成が容易で履歴保持の必要性が低い |
以上をまとめると、主体テーブルは「UUID + displayId + 監査カラム」を必須とし、削除は論理削除を基本とする。一方で、中間テーブルはシンプルな構造を維持し、物理削除で運用する方針となります。
2. displayId 仕様
本章では、各テーブルで共通して利用する displayId の仕様を整理します。
displayId は「人がUI上で確認・識別するためのID」であり、UUIDとは別に発行されます。
displayId は「人がUI上で確認・識別するためのID」であり、UUIDとは別に発行されます。
基本仕様
displayId は以下のルールに統一します。
| 項目 | 仕様 |
|---|---|
| 形式 | 2文字接頭辞 + 8桁ゼロ埋めの数値(ハイフンなし) |
| 長さ | 固定10文字 |
| 対象テーブル | 主体テーブル(Account, Branch, Department, Contact, Subscription, User, Role, Menu) |
| 非対象テーブル | 中間テーブル(UserRole, MenuRoleなど) |
| 採番方式 | PostgreSQLのシーケンスを利用し、専用関数でprefixと連結 |
| 表示用途 | UI上の識別、検索、管理画面の一覧表示など |
例)
AC00000001, US00000042, MN00001000対象テーブルと接頭辞一覧
displayId の接頭辞は、テーブルを直感的に識別できる2文字に固定します。
| テーブル | 接頭辞 | サンプル |
|---|---|---|
| Account | AC | AC00000001 |
| Branch | BR | BR00000001 |
| Department | DP | DP00000001 |
| Contact | CT | CT00000001 |
| Subscription | SB | SB00000001 |
| User | US | US00000001 |
| Role | RL | RL00000001 |
| Menu | MN | MN00000001 |
| UserRole | ― | UUIDのみ |
| MenuRole | ― | UUIDのみ |
採番の仕組み
displayId は PostgreSQL のシーケンスと関数を組み合わせて採番します。
この仕組みにより、アプリ側から特別な処理を追加せずとも一貫したID発行が可能です。
この仕組みにより、アプリ側から特別な処理を追加せずとも一貫したID発行が可能です。
- シーケンス:テーブルごとに専用シーケンスを用意
例:account_display_id_seq,user_display_id_seq - 関数:共通関数
generate_display_id(seq_name, prefix)を利用 - Prisma定義:
@default(dbgenerated(...))でDB側関数を呼び出す
運用上の留意点
- UUIDと併用:内部処理や外部キー参照はUUIDを利用し、displayIdはUI/検索用途に限定する
- 変更不可:一度採番されたdisplayIdは更新禁止。ユニーク制約を必須とする
- 検索最適化:displayId列にはユニークインデックスを付与して検索性能を確保する
- 桁数の余裕:8桁採番で最大9,999万件まで対応可能。将来的な拡張にも十分な余地がある
以上により、displayId は「人が読みやすい内部識別子」として標準化され、UUIDと併用することで安全性と可読性を両立させます。
3. ER概観 ─ マルチテナントとスコープ
本章では、本システムのER(Entity-Relationship)の全体像を示し、マルチテナント対応とスコープの考え方を整理します。
User は departmentId と roleId が必須であり、契約(Subscription)は部署単位で管理することを基本方針とします。
User は departmentId と roleId が必須であり、契約(Subscription)は部署単位で管理することを基本方針とします。
全体像
DELOGsプロジェクトでは法人利用を前提に、会社 → 支店 → 部署 → ユーザ/担当者という階層構造を採用します。
Userは必ず部署に属し、さらにロールを1つだけ持ちます。契約は部署単位で結ばれ、ログイン認証・利用制御・契約管理が同一スコープで一致するように設計します。
Userは必ず部署に属し、さらにロールを1つだけ持ちます。契約は部署単位で結ばれ、ログイン認証・利用制御・契約管理が同一スコープで一致するように設計します。
主体テーブルと関係性
| テーブル | 説明 | 主な関係 |
|---|---|---|
| Account | 会社マスタ | 複数の Branch を持つ |
| Branch | 支店 | Account に属し、複数の Department を持つ |
| Department | 部署 | Branch に属し、複数の User と Contact を持つ |
| Contact | 担当者 | Department に属する(業務上の窓口や責任者) |
| Subscription | 契約 | Department 単位で契約期間・プラン・状態を管理 |
| User | 利用ユーザー | Department に必ず所属し、1つの Role を持つ |
| Role | 権限ロール | User に付与され、priority・権限フラグで可視や機能を制御 |
| Menu | 管理画面メニュー | Role.priority と Menu.minPriority の比較で可視制御 |
注:
User は単一ロール前提、Menu 可視は minPriority 比較のみによって行います。
User は単一ロール前提、Menu 可視は minPriority 比較のみによって行います。
簡易版ER図
以下に主要テーブルの関係を文字図で示します。
txt
1Account (AC) 1 ──── * Branch (BR) 1 ──── * Department (DP) 1 ──── * User (US) ──── 1 Role (RL)
2 │
3 └──── * Contact (CT)
4
5Department (DP) 1 ──── * Subscription (SB)
6
7Role (RL: priority, canEditData, canDownloadData)
8 │
9 └─── 判定 ───> Menu (MN: minPriority, visible)スコープの考え方
スコープとは「どの単位で制御をかけるか」を指します。
本システムでは Department を最小スコープと定義し、契約・ログイン・RBAC の全てをこの単位に揃えます。
本システムでは Department を最小スコープと定義し、契約・ログイン・RBAC の全てをこの単位に揃えます。
| 項目 | スコープ | 補足 |
|---|---|---|
| 契約(Subscription) | Department 単位 | 部署ごとに契約期間・状態・プランを管理 |
| ログイン | Department 単位 | ログイン時に入力される「アカウントID」は department.code |
| RBAC | Department 単位 | User は必ず departmentId と roleId を持ち、権限判定に利用 |
このように 部署を最小単位とする設計により、ログイン・契約・権限を統一的に扱うことが可能となります。
次章からは、Account 系の各テーブル設計を順に詳細化していきます。
次章からは、Account 系の各テーブル設計を順に詳細化していきます。
4. Account系 正規化設計
Account 系は「会社 → 支店 → 部署 → 担当者 → 契約」という法人組織の構造を表す基盤です。以降は 主体テーブルに共通のカラムとして
id(UUID) / displayId / isActive(BOOLEAN) / createdAt / updatedAt / deletedAt(NULLABLE) を前提にします(※中間テーブルは isActive を持ちません)。Account(会社マスタ)
Account は最上位のエンティティで、会社そのものを表します。支店や部署のツリーの根となるため、最小限の会社情報を保持します。
| カラム | 型 | 必須 | 説明 |
|---|---|---|---|
| id | UUID | ✔︎ | 主キー |
| displayId | VARCHAR(10) | ✔︎ | AC00000001 |
| isActive | BOOLEAN | ✔︎ | 有効/無効(既定: true) |
| name | TEXT | ✔︎ | 会社名 |
| headquartersAddress | TEXT | 本店所在地 | |
| invoiceNumber | TEXT | インボイス登録番号 | |
| remarks | TEXT | 備考(任意メモ) | |
| createdAt / updatedAt / deletedAt | TIMESTAMP | ✔︎ / ✔︎ / | 共通カラム(論理削除は任意) |
Branch(支店)
Branch は Account に属する支店を表します。
| カラム | 型 | 必須 | 説明 |
|---|---|---|---|
| id | UUID | ✔︎ | 主キー |
| displayId | VARCHAR(10) | ✔︎ | BR00000001 |
| isActive | BOOLEAN | ✔︎ | 有効/無効(既定: true) |
| accountId | UUID | ✔︎ | Account へのFK |
| name | TEXT | ✔︎ | 支店名 |
| address | TEXT | 支店所在地 | |
| remarks | TEXT | 備考 | |
| createdAt / updatedAt / deletedAt | TIMESTAMP | ✔︎ / ✔︎ / | 共通カラム |
Department(部署)
Department は Branch に属し、User は必ず Department に所属します。
| カラム | 型 | 必須 | 説明 |
|---|---|---|---|
| id | UUID | ✔︎ | 主キー |
| displayId | VARCHAR(10) | ✔︎ | DP00000001 |
| isActive | BOOLEAN | ✔︎ | 有効/無効(既定: true) |
| code | TEXT | ✔︎ | ログイン時に使用 |
| branchId | UUID | ✔︎ | Branch へのFK |
| name | TEXT | ✔︎ | 部署名 |
| phone | TEXT | 代表電話 | |
| remarks | TEXT | 備考 | |
| createdAt / updatedAt / deletedAt | TIMESTAMP | ✔︎ / ✔︎ / | 共通カラム |
Contact(担当者)
契約・運用に関わる窓口情報です。部署に紐づけます。
| カラム | 型 | 必須 | 説明 |
|---|---|---|---|
| id | UUID | ✔︎ | 主キー |
| displayId | VARCHAR(10) | ✔︎ | CT00000001 |
| isActive | BOOLEAN | ✔︎ | 有効/無効(既定: true) |
| departmentId | UUID | ✔︎ | Department へのFK |
| name | TEXT | ✔︎ | 担当者名 |
| TEXT | メール | ||
| phone | TEXT | 電話 | |
| duty | TEXT | 担当区分(例: 契約窓口/請求窓口/技術窓口 など) | |
| remarks | TEXT | 備考 | |
| createdAt / updatedAt / deletedAt | TIMESTAMP | ✔︎ / ✔︎ / | 共通カラム |
Subscription(契約:部署単位)
部署(Department)単位で、申込み〜利用終了までを管理します。ログインスコープと契約スコープを一致させます。
| カラム | 型 | 必須 | 説明 |
|---|---|---|---|
| id | UUID | ✔︎ | 主キー |
| displayId | VARCHAR(10) | ✔︎ | SB00000001 |
| isActive | BOOLEAN | ✔︎ | 有効/無効(既定: true) |
| departmentId | UUID | ✔︎ | Department へのFK |
| statusId | UUID | ✔︎ | SubscriptionStatus へのFK |
| planId | UUID | ✔︎ | SubscriptionPlan へのFK |
| startDate | DATE | ✔︎ | 利用開始日 |
| endDate | DATE | 利用終了日 | |
| remarks | TEXT | 備考 | |
| createdAt / updatedAt / deletedAt | TIMESTAMP | ✔︎ / ✔︎ / | 共通カラム |
SubscriptionStatus(契約ステータス・マスタ)
Subscription(契約:部署単位)で利用する契約ステータスを管理します。
| カラム | 型 | 必須 | 説明 |
|---|---|---|---|
| id | UUID | ✔︎ | 主キー |
| code | TEXT | ✔︎ | 一意コード(例: active, expired, pending) |
| name | TEXT | ✔︎ | 表示名 |
| description | TEXT | 説明 | |
| isActive | BOOLEAN | ✔︎ | 有効/無効 |
| createdAt / updatedAt / deletedAt | TIMESTAMP | ✔︎ / ✔︎ / | 共通カラム |
SubscriptionPlan(契約プラン・マスタ)
Subscription(契約:部署単位)で利用する契約プランを管理します。
| カラム | 型 | 必須 | 説明 |
|---|---|---|---|
| id | UUID | ✔︎ | 主キー |
| code | TEXT | ✔︎ | 一意コード(例: basic, pro, enterprise) |
| name | TEXT | ✔︎ | 表示名 |
| description | TEXT | 説明 | |
| monthlyPrice | NUMERIC | 月額(必要なら通貨管理は別途) | |
| isActive | BOOLEAN | ✔︎ | 有効/無効 |
| createdAt / updatedAt / deletedAt | TIMESTAMP | ✔︎ / ✔︎ / | 共通カラム |
Account系の関係図
以下に、Account 系エンティティの関係を示した文字図を掲載します。
txt
1Account (AC)
2 └─ Branch (BR)
3 └─ Department (DP)
4 ├─ Contact (CT)
5 └─ Subscription (SB) ──┬─ SubscriptionStatus (master)
6 └─ SubscriptionPlan (master)設計のポイント
- User の所属は Department 必須:契約・ログイン・権限の最小単位を揃える。
- Subscription は部署単位:契約管理と利用制御を直結。
- Contact は任意:実運用の窓口情報として保持。
- displayId は接頭辞 + 8桁ゼロ埋め:
AC/BR/DP/CT/SBを採用。 isActiveは主体テーブルで必須:UIの有効/無効切替・論理削除と併用しやすい。- 契約は部署単位 + マスタ参照:
statusIdとplanIdを最初からFK化。 schema.prisma(9章で作成します)は、上記を前提に FK/ユニーク/インデックス を定義します。
5. User 設計(US)
User テーブルは 認証・権限・契約 の三要素をつなぐ中心的なエンティティです。
特に本設計では、必ず Department(部署)に所属させることで、契約スコープ(Subscription)とログインスコープを一致させます。 ログインは
特に本設計では、必ず Department(部署)に所属させることで、契約スコープ(Subscription)とログインスコープを一致させます。 ログインは
departmentId + email + hashedPassword の3要素で行います。User(ユーザー)
| カラム | 型 | 必須 | 説明 |
|---|---|---|---|
| id | UUID | ✔︎ | 主キー |
| displayId | VARCHAR(10) | ✔︎ | US00000001 |
| isActive | BOOLEAN | ✔︎ | 有効/無効(既定: true) |
| departmentId | UUID | ✔︎ | Department FK(ログイン時のスコープ) |
| roleId | UUID | ✔︎ | Role FK(ユーザーは常に1ロール) |
| TEXT | ✔︎ | ログイン・通知用メール(部署内で一意) | |
| hashedPassword | TEXT | ✔︎ | パスワード(ハッシュ) |
| name | TEXT | ✔︎ | 氏名(表示名) |
| phone | TEXT | 電話番号 | |
| remarks | TEXT | 備考 | |
| createdAt / updatedAt / deletedAt | TIMESTAMP | ✔︎ / ✔︎ / | 共通カラム |
制約・運用ポイント
- 一意制約:
@@unique([departmentId, email])(部署内でメールを一意)- 同一人物が部署をまたぐ場合は、部署ごとに別ユーザとして登録可能。
- 正規化・正規化:
emailは小文字化・トリム等で正規化して保存(衝突回避)。 - セキュリティ:
hashedPasswordは PBKDF2/argon2/bcrypt 等を採用。パスワード再発行は Server Action + 一時トークンで実装。 - アバター:DB でパスは管理せず、
/var/www/private/avatars/{userId}.extのなどの実ファイル存在で判定(専用 Route Handler で配信予定)。
User の関係図
以下に、User と関連エンティティの関係を文字図で示します。
txt
1Department (DP)
2 └─ User (US) ── 1 ── Role (RL)
3 ↑
4 login key: (departmentId + email) + password認証フロー(要旨)
- 入力:
department.code+email+password - 解決:
department.code→departmentIdを解決 →UserをdepartmentId+emailで検索 →hashedPassword照合 - 結果:成功時に User と Role をセッションへ格納(httpOnly Cookie)、以降の可視・機能判定に利用
権限判定の位置づけ
- メニュー可視:
Role.priority >= Menu.minPriority(親の minPriority 継承後の実効値) - 機能可否:
Role.canDownloadData/Role.canEditDataを参照 - 補足:User は中間テーブルを介さず
roleId直付け。複数ロールや例外可視が必要になった場合は将来拡張で中間を増設
インデックス指針
| 種別 | 対象 | 目的 |
|---|---|---|
| UNIQUE | (departmentId, email) | ログイン検索の一意性保証 |
| INDEX | departmentId | 部署内一覧・JOIN 最適化 |
| INDEX | roleId | ロール別集計・抽出 |
| INDEX | isActive | 有効ユーザ抽出 |
| INDEX | createdAt | 作成日時ソート(管理UI) |
この構成により、部署スコープに根ざしたログインと、ロールに基づく単調な可視・機能判定をシンプルに実現します。
6. Role 設計(RL)
Role テーブルは、ユーザーの権限を一元的に管理するためのエンティティです。
本設計では、ユーザーは必ず 1つの Role を持ち、
本設計では、ユーザーは必ず 1つの Role を持ち、
priority に基づく階層判定と、canDownloadData / canEditData による機能別の制御を組み合わせます。Role(ロール)
| カラム | 型 | 必須 | 説明 |
|---|---|---|---|
| id | UUID | ✔︎ | 主キー |
| displayId | VARCHAR(10) | ✔︎ | RL00000001 |
| isActive | BOOLEAN | ✔︎ | 有効/無効(既定: true) |
| code | TEXT | ✔︎ | 一意コード(例: ADMIN, EDITOR) |
| name | TEXT | ✔︎ | 表示名(UI 表示用) |
| priority | INT | ✔︎ | 権限レベル(数値が大きいほど強い権限) |
| badgeColor | TEXT | UI 表示用カラーコード | |
| isSystem | BOOLEAN | ✔︎ | システム定義ロールかどうか |
| canDownloadData | BOOLEAN | ✔︎ | データダウンロード可否 |
| canEditData | BOOLEAN | ✔︎ | データ編集可否 |
| remarks | TEXT | 備考 | |
| createdAt / updatedAt / deletedAt | TIMESTAMP | ✔︎ / ✔︎ / | 共通カラム |
権限設計の考え方
- 階層制御:
priorityによって、メニューや機能の最低必要権限(minPriority)と比較し、可視性や操作可否を判定します。 - 機能制御:同じ
priorityレベルのロールでも、canDownloadData/canEditDataの値によって細かな違いを設けられます。 - システムロール:
isSystem = trueのものは、削除・無効化不可の初期ロールとして保持します。
Role の関係図
txt
1Role (RL)
2 └─ User (US) が必ず1つ参照
3 ├─ priority >= Menu.minPriority でメニュー可視
4 ├─ canDownloadData = true ならデータ出力可
5 └─ canEditData = true なら編集操作可設計のポイント
- 単一ロール付与:ユーザーは必ず1つのロールを持ち、複数ロールは想定しない。
- メニュー制御:Menu 側の
minPriorityと Role のpriorityを比較して表示判定。 - 機能制御:Role 内部のフラグで補足制御(編集/ダウンロード)。
- UI連動:
badgeColorにより一覧やプロフィール画面での視認性を高める。 - マスタ性:ロールは運用上頻繁に増減させないことを前提に設計。
このように Role 設計を定義することで、メニュー可視性と機能操作の両面を一元管理し、UI 側の権限制御をシンプルに実装できます。
7. Menu(MN)の設計
Menu テーブルは、管理画面におけるナビゲーション構造を管理するエンティティです。
UI 層では
Menu は 階層構造を持ち、さらに Role.priority × Menu.minPriority の比較により可視制御を行います。
UI 層では
menu.mock.ts に定義されていた情報を起点にしていましたが、本設計ではそれを正規化し、DB テーブルとして永続化します。Menu は 階層構造を持ち、さらに Role.priority × Menu.minPriority の比較により可視制御を行います。
Menu(メニュー本体)
Menu は階層型のメニュー情報を表し、表示名・遷移先・アイコン・優先度などを保持します。
| カラム | 型 | 必須 | 説明 |
|---|---|---|---|
| id | UUID | ✔︎ | 主キー |
| displayId | VARCHAR(10) | ✔︎ | MN00000001 |
| isActive | BOOLEAN | ✔︎ | 有効/無効(既定: true) |
| parentId | UUID | 親メニューへの FK(NULL ならルート) | |
| title | TEXT | ✔︎ | 表示名 |
| href | TEXT | 遷移先パス(内部/外部を含む) | |
| isExternal | BOOLEAN | 外部 URL かどうか | |
| iconName | TEXT | アイコン名(UI で Lucide 等に解決) | |
| match | TEXT(ENUM想定) | ✔︎ | マッチ方法(exact or prefix) |
| pattern | TEXT | 追加のマッチパターン | |
| minPriority | INT | 可視化に必要な最小権限(親から継承、上書き不可) | |
| isSection | BOOLEAN | ✔︎ | セクション見出しかどうか |
| sortOrder | INT | ✔︎ | 同階層内での並び順 |
| remarks | TEXT | 備考 | |
| createdAt / updatedAt / deletedAt | TIMESTAMP | ✔︎ / ✔︎ / | 共通カラム |
可視制御の仕組み
Menu 可視判定は Role.priority と Menu.minPriority の比較で行います。
これにより、上位ロールは下位ロールの可視範囲を包含する単調性が保証されます。
また、親メニューの minPriority は子メニューに継承され、子での上書きは不可とします。
これにより、上位ロールは下位ロールの可視範囲を包含する単調性が保証されます。
また、親メニューの minPriority は子メニューに継承され、子での上書きは不可とします。
Menu の関係図
以下に、Menu と Role による可視制御の関係を文字図で示します。
txt
1Role (RL: priority)
2 │
3 └─── priority >= Menu.minPriority → Menu (MN)
4 │
5 └─ parentId により階層構造を構築設計のポイント
| 項目 | 内容 |
|---|---|
| 階層構造 | parentId により多階層(ルート → サブ → サブサブ)を表現 |
| 並び順 | 同階層内は sortOrder ASC で表示 |
| 可視制御 | Role.priority >= Menu.minPriority で表示可否を判定 |
| 継承ルール | 親の minPriority は子に強制継承。子での下げは不可 |
| UI要素 | iconName や isSection で UI レンダリングを補助 |
| 外部リンク対応 | href + isExternal により外部 URL も管理可能 |
この設計により、Menu は 階層構造・並び順・可視制御を一元的に管理でき、UI 層は「ログインユーザの Role.priority を渡して、可視メニューを取得 → sortOrder 順で描画」するだけで済みます。
8. インデックス・ユニーク制約設計
この章では、各テーブルの ユニークキー と 推奨インデックス を体系化します。
設計方針は「検索・照合で多用するキーに最小限のインデックス」「UI要件(並び順・可視判定)を高速化」「将来拡張に耐える制約の粒度」です。
設計方針は「検索・照合で多用するキーに最小限のインデックス」「UI要件(並び順・可視判定)を高速化」「将来拡張に耐える制約の粒度」です。
基本方針
本プロジェクトのクエリは「部署スコープ」「表示ID検索」「メニュー表示」「ログイン照合」が中心です。
したがって以下を必須とします。
したがって以下を必須とします。
- displayId は主体テーブルで UNIQUE(人が扱う識別子の一意性担保)
- 外部キー列は基本的に INDEX(JOIN/絞り込みを高速化)
- 並び順に使う列は複合INDEX(例:
(parentId, sortOrder)) - よく使うフラグは単体INDEX(例:
isActive、minPriority)
ユニークキー設計(横断)
各テーブルのユニーク性を一覧化します。
| テーブル | ユニークキー | 用途/理由 |
|---|---|---|
| Account | displayId | 人間可読IDの一意性 |
| Branch | displayId | 同上 |
| Department | displayId | 同上 |
| Contact | displayId | 同上 |
| Subscription | displayId | 同上 |
| SubscriptionStatus | code | マスタの論理一意名 |
| SubscriptionPlan | code | 同上 |
| User | displayId | 人間可読IDの一意性 |
| User | (departmentId, email) | ログイン照合キー(部署内メール一意) |
| Role | displayId | 人間可読IDの一意性 |
| Role | code | システム内の論理一意名(ADMIN など) |
| Menu | displayId | 人間可読IDの一意性 |
| Menu | (parentId, sortOrder) | 同階層で表示順の一意性(重複防止) |
外部キー・参照整合性
UIとSeedの運用を考慮し、外部キーの 参照整合性 は次の原則を推奨します。
| FK 先 | 参照元 | onUpdate | onDelete | 理由 |
|---|---|---|---|---|
| Account | Branch | CASCADE | RESTRICT | 名前変更伝播、安易な削除を防ぐ |
| Branch | Department | CASCADE | RESTRICT | 同上 |
| Department | User | CASCADE | RESTRICT | ユーザ孤児化を防ぐ |
| Department | Contact | CASCADE | SET NULL/RESTRICT | 運用方針で選択(窓口一時未設定可否) |
| Department | Subscription | CASCADE | RESTRICT | 契約履歴保持を優先 |
| Role | User | CASCADE | RESTRICT | ロール削除を厳格に |
| Menu(親) | Menu(子) | CASCADE | RESTRICT | ツリーの整合性保持 |
補足:物理削除は最小限。主体テーブルは原則「論理削除(
deletedAt)」で扱います。テーブル別インデックス
性能上の効果が高いものに絞って記載しています。必要に応じて運用で追加します。
Account / Branch / Department(組織階層)
| テーブル | 推奨 INDEX | 目的 |
|---|---|---|
| Account | displayId UNIQUE | UI検索 |
isActive | 有効一覧 | |
| Branch | displayId UNIQUE | UI検索 |
accountId | JOIN高速化 | |
isActive | 有効一覧 | |
| Department | code UNIQUE | UI検索・ログイン前解決 |
branchId | JOIN高速化 | |
isActive | 有効一覧 |
Contact
| 推奨 INDEX | 目的 |
|---|---|
displayId UNIQUE | UI検索 |
departmentId | 組織別一覧 |
isActive | 有効一覧 |
(オプション) DBレベル部分一致 LOWER(email) | 窓口検索の高速化(Prismaは functional index を SQL で追加) |
Subscription / SubscriptionStatus / SubscriptionPlan
| テーブル | 推奨 INDEX | 目的 |
|---|---|---|
| Subscription | displayId UNIQUE | UI検索 |
departmentId | 組織別契約参照 | |
(departmentId, startDate DESC) | 直近契約の高速取得 | |
statusId, planId | マスタ絞り込み | |
| SubscriptionStatus | code UNIQUE | 論理一意 |
isActive | 有効一覧 | |
| SubscriptionPlan | code UNIQUE | 論理一意 |
isActive | 有効一覧 |
発展:部署単位の契約期間 重複禁止 は PostgreSQL の EXCLUDE 制約(
USING gist + daterange)で表現可能(Prismaは直接未対応のため SQL で追加)。User
| 推奨 INDEX | 目的 |
|---|---|
displayId UNIQUE | UI検索 |
(departmentId, email) UNIQUE | ログインキー |
departmentId | 部署別一覧 |
roleId | ロール別集計 |
isActive | 有効ユーザ抽出 |
createdAt | 新着ユーザ表示 |
補足:メールは
LOWER(email) を保存時に正規化し、比較の一貫性を担保します。Role
| 推奨 INDEX | 目的 |
|---|---|
displayId UNIQUE | UI検索 |
code UNIQUE | 論理一意 |
isActive | 有効一覧 |
priority | 並び替え・可視閾値比較の補助 |
Menu
| 推奨 INDEX | 目的 |
|---|---|
displayId UNIQUE | UI検索 |
(parentId, sortOrder) UNIQUE | 階層内の表示順重複防止 |
parentId | ツリー構築 JOIN |
isActive | 有効メニュー抽出 |
minPriority | ロール閾値での可視判定前処理 |
(ENUM) match | exact/prefix のフィルタ(ENUMは内部的に高速) |
パフォーマンス運用の要点
| 項目 | 推奨 | 補足 |
|---|---|---|
| 低選択性フラグ | isActive に単体INDEX | 1/0 フラグでもフィルタ初期段階に有効 |
| 並び替え | (parentId, sortOrder) 複合 | Menu の描画を 1 クエリで完結 |
| ログイン | (departmentId, email) UNIQUE | WHERE + UNIQUE で即ヒット |
| 可視判定 | minPriority 単体INDEX | ロール閾値前処理に寄与 |
| 正規化 | LOWER(email) 保存 | 大文字小文字の不一致解消 |
| 分析 | createdAt INDEX | 新着/期間集計の定番 |
| 監査 | deletedAt に INDEX は不要 | 論理削除は「IS NULL」主体のため状況に応じて |
注意:インデックスは 書き込みコスト とトレードオフ。測定(
EXPLAIN ANALYZE)の上、最小構成から段階的に追加します。9. Prismaスキーマの作成(モデル一覧)
ここでは、これまでに設計してきた Account系 / User系 / Menu系 の要件を、実運用可能な
本スキーマは PostgreSQL を対象とし、主体テーブルのみ
prisma/schema.prisma として 完成版 まで落とし込みます。本スキーマは PostgreSQL を対象とし、主体テーブルのみ
displayId(接頭辞2文字 + 8桁) を持ちます。ログインは departmentId + email + hashedPassword 方式、RBAC は 単一ロール で、メニュー可視は Role.priority >= Menu.minPriority により判定します。txt
1(文字図)モデル相互の関係・採番
2Account(AC)
3 └─ Branch(BR)
4 └─ Department(DP)
5 ├─ Contact(CT)
6 ├─ Subscription(SB) ──┬─ SubscriptionStatus(master)
7 │ └─ SubscriptionPlan(master)
8 └─ User(US) ── 1 ── Role(RL)
9 │
10 └─ priority >= Menu.minPriority → Menu(MN)
11
12displayId: 主体テーブルのみ(AC/BR/DP/CT/SB/US/RL/MN)
13採番 : DB側関数 `generate_display_id(seq, prefix)` を @default(dbgenerated(...)) で呼び出しschema.prismaへの追記
prisma/schema.prismaを下記の内容で作成します。prisma
1// prisma/schema.prisma
2
3// ==============================
4// Generator / DataSource
5// ==============================
6generator client {
7 provider = "prisma-client-js"
8}
9
10datasource db {
11 provider = "postgresql"
12 url = env("DATABASE_URL")
13}
14
15// ==============================
16// Enums
17// ==============================
18enum MenuMatchMode {
19 exact
20 prefix
21 regex
22}
23
24// ==============================
25// Account 系
26// ==============================
27model Account {
28 // 主キー / 識別
29 id String @id @default(uuid())
30 displayId String @unique @default(dbgenerated("generate_display_id('account_display_id_seq','AC')")) @db.VarChar(10)
31 // 共通
32 isActive Boolean @default(true)
33 createdAt DateTime @default(now()) @db.Timestamptz
34 updatedAt DateTime @updatedAt @db.Timestamptz
35 deletedAt DateTime? @db.Timestamptz
36
37 // 本体
38 name String
39 headquartersAddress String?
40 invoiceNumber String?
41 remarks String?
42
43 // リレーション
44 branches Branch[]
45
46 @@index([isActive])
47 @@index([createdAt])
48}
49
50model Branch {
51 id String @id @default(uuid())
52 displayId String @unique @default(dbgenerated("generate_display_id('branch_display_id_seq','BR')")) @db.VarChar(10)
53 isActive Boolean @default(true)
54 createdAt DateTime @default(now()) @db.Timestamptz
55 updatedAt DateTime @updatedAt @db.Timestamptz
56 deletedAt DateTime? @db.Timestamptz
57
58 accountId String
59 name String
60 address String?
61 remarks String?
62
63 // リレーション
64 account Account @relation(fields: [accountId], references: [id], onDelete: Restrict)
65 departments Department[]
66
67 @@index([accountId])
68 @@index([isActive])
69 @@index([createdAt])
70}
71
72model Department {
73 id String @id @default(uuid())
74 displayId String @unique @default(dbgenerated("generate_display_id('department_display_id_seq','DP')")) @db.VarChar(10)
75 isActive Boolean @default(true)
76 createdAt DateTime @default(now()) @db.Timestamptz
77 updatedAt DateTime @updatedAt @db.Timestamptz
78 deletedAt DateTime? @db.Timestamptz
79
80 // ★ ログイン用コード(人間入力・推測困難な固定文字列)
81 code String @unique
82
83 branchId String
84 name String
85 phone String?
86 remarks String?
87
88 // リレーション
89 branch Branch @relation(fields: [branchId], references: [id], onDelete: Restrict)
90 contacts Contact[]
91 subscriptions Subscription[]
92 users User[]
93
94 @@index([branchId])
95 @@index([isActive])
96 @@index([createdAt])
97}
98
99model Contact {
100 id String @id @default(uuid())
101 displayId String @unique @default(dbgenerated("generate_display_id('contact_display_id_seq','CT')")) @db.VarChar(10)
102 isActive Boolean @default(true)
103 createdAt DateTime @default(now()) @db.Timestamptz
104 updatedAt DateTime @updatedAt @db.Timestamptz
105 deletedAt DateTime? @db.Timestamptz
106
107 departmentId String
108 name String
109 email String?
110 phone String?
111 duty String? // 担当区分(契約/請求/技術 等)
112 remarks String?
113
114 // リレーション
115 department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict)
116
117 @@index([departmentId])
118 @@index([isActive])
119 @@index([createdAt])
120}
121
122model Subscription {
123 id String @id @default(uuid())
124 displayId String @unique @default(dbgenerated("generate_display_id('subscription_display_id_seq','SB')")) @db.VarChar(10)
125 isActive Boolean @default(true)
126 createdAt DateTime @default(now()) @db.Timestamptz
127 updatedAt DateTime @updatedAt @db.Timestamptz
128 deletedAt DateTime? @db.Timestamptz
129
130 departmentId String
131 statusId String
132 planId String
133 startDate DateTime @db.Date
134 endDate DateTime? @db.Date
135 remarks String?
136
137 // リレーション
138 department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict)
139 status SubscriptionStatus @relation(fields: [statusId], references: [id], onDelete: Restrict)
140 plan SubscriptionPlan @relation(fields: [planId], references: [id], onDelete: Restrict)
141
142 @@index([departmentId])
143 @@index([statusId])
144 @@index([planId])
145 @@index([startDate])
146 @@index([isActive])
147}
148
149model SubscriptionStatus {
150 id String @id @default(uuid())
151 isActive Boolean @default(true)
152 createdAt DateTime @default(now()) @db.Timestamptz
153 updatedAt DateTime @updatedAt @db.Timestamptz
154 deletedAt DateTime? @db.Timestamptz
155
156 code String @unique
157 name String
158 description String?
159
160 subscriptions Subscription[]
161
162 @@index([isActive])
163}
164
165model SubscriptionPlan {
166 id String @id @default(uuid())
167 isActive Boolean @default(true)
168 createdAt DateTime @default(now()) @db.Timestamptz
169 updatedAt DateTime @updatedAt @db.Timestamptz
170 deletedAt DateTime? @db.Timestamptz
171
172 code String @unique
173 name String
174 description String?
175 monthlyPrice Decimal?
176
177 subscriptions Subscription[]
178
179 @@index([isActive])
180}
181
182// ==============================
183// User / Role
184// ==============================
185model Role {
186 id String @id @default(uuid())
187 displayId String @unique @default(dbgenerated("generate_display_id('role_display_id_seq','RL')")) @db.VarChar(10)
188 isActive Boolean @default(true)
189 createdAt DateTime @default(now()) @db.Timestamptz
190 updatedAt DateTime @updatedAt @db.Timestamptz
191 deletedAt DateTime? @db.Timestamptz
192
193 code String @unique // 例: ADMIN / EDITOR / VIEWER ...
194 name String
195 priority Int
196 badgeColor String?
197 isSystem Boolean @default(false)
198 canDownloadData Boolean
199 canEditData Boolean
200 remarks String?
201
202 users User[]
203
204 @@index([priority])
205 @@index([isActive])
206}
207
208model User {
209 id String @id @default(uuid())
210 displayId String @unique @default(dbgenerated("generate_display_id('user_display_id_seq','US')")) @db.VarChar(10)
211 isActive Boolean @default(true)
212 createdAt DateTime @default(now()) @db.Timestamptz
213 updatedAt DateTime @updatedAt @db.Timestamptz
214 deletedAt DateTime? @db.Timestamptz
215
216 departmentId String
217 roleId String
218 email String // login & notice
219 hashedPassword String
220 name String
221 phone String?
222 remarks String?
223
224 // ログイン失敗ロック
225 failedLoginCount Int @default(0)
226 lockedUntil DateTime? @db.Timestamptz
227
228 // リレーション
229 department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict)
230 role Role @relation(fields: [roleId], references: [id], onDelete: Restrict)
231
232 // Session への逆側(双方向)リレーション
233 sessions Session[] @relation("UserSessions")
234
235 // 部署内メール一意(ログインキー)
236 @@unique([departmentId, email])
237 @@index([departmentId])
238 @@index([roleId])
239 @@index([isActive])
240 @@index([createdAt])
241}
242
243// Session 定義
244model Session {
245 id String @id @default(uuid())
246 userId String
247 user User @relation("UserSessions", fields: [userId], references: [id], onDelete: Cascade)
248
249 expiresAt DateTime @db.Timestamptz
250 createdAt DateTime @default(now()) @db.Timestamptz
251 revokedAt DateTime? @db.Timestamptz
252
253 ip String?
254 userAgent String?
255
256 @@index([userId])
257 @@index([expiresAt])
258 @@index([revokedAt])
259}
260
261// ==============================
262// Menu
263// ==============================
264model Menu {
265 id String @id @default(uuid())
266 displayId String @unique @default(dbgenerated("generate_display_id('menu_display_id_seq','MN')")) @db.VarChar(10)
267 isActive Boolean @default(true)
268 createdAt DateTime @default(now()) @db.Timestamptz
269 updatedAt DateTime @updatedAt @db.Timestamptz
270 deletedAt DateTime? @db.Timestamptz
271
272 parentId String?
273 title String
274 href String?
275 isExternal Boolean? // 外部URLか
276 iconName String?
277 match MenuMatchMode
278 pattern String?
279 minPriority Int? // 親から継承(アプリ層で強制する)
280 isSection Boolean
281 sortOrder Int
282 remarks String?
283
284 // リレーション(自己参照)
285 parent Menu? @relation("MenuToMenu", fields: [parentId], references: [id], onDelete: Restrict)
286 children Menu[] @relation("MenuToMenu")
287
288 @@index([parentId])
289 @@index([minPriority])
290 @@index([isActive])
291 @@index([sortOrder])
292 @@index([createdAt])
293}スキーマのポイント(実装メモ)
- displayId 採番:
@default(dbgenerated("generate_display_id('...','XX')"))は DB側の関数 を呼び出します。本文中の関数名・シーケンス名は想定です(10章の実装で PostgreSQL に作成)。 - 論理削除:主体テーブルは
isActive+deletedAtを保持。中間テーブルが存在しないため、削除時は原則「論理削除」を運用(外部キーはonDelete: Restrict)。 - ログイン:
Userは@@unique([departmentId, email])により部署内でメール一意。入力されたdepartment.code→departmentId解決後に照合。 - RBAC:
User.roleIdで単一ロール紐付け。メニュー可視はRole.priority >= Menu.minPriority。 - Menu 継承ルール:
minPriorityは親から継承(アプリ層で解決)。DB上はNULL許容で、親に依存しない定義も可能に。 - 索引:8章の指針どおり、
displayIdのユニーク・FK・isActive・createdAtを中心にインデックスを付与済み。 - 型の粒度:
displayIdは@db.VarChar(10)、startDate/endDateは@db.Dateを明示。monthlyPriceはDecimal(必要に応じて精度/通貨設計を別章で)。
これで
prisma migrate dev による初期マイグレーションが可能です。次章では PostgreSQL のシーケンス & 関数(generate_display_id)と、seed の雛形を実装します。10. Prismaマイグレーションと migration.sql 編集
本章では、前章で定義した
最終的に
schema.prisma をもとに 初回マイグレーション を作成し、生成された migration.sql を編集して PostgreSQL に自動採番関数とシーケンスを組み込みます。最終的に
npx prisma migrate dev を実行するだけで、UUID + displayId の二重識別子を備えた堅牢なDBスキーマが完成します。マイグレーションファイルの生成
まずは
--create-only オプションでマイグレーションファイルを生成します。下記のコマンドを実行します。zsh
1npx prisma migrate dev --create-only -n initこれで、DB適用されない状態で、マイグレーション用のsqlファイルが生成されます。
この時点では、Prisma は
displayId の dbgenerated(...) をそのまま出力するため、PostgreSQL 側に関数やシーケンスを定義する必要があります。prisma/migrations/タイムスタンプ_init/migration.sqlが生成されているので、これに若干の追記をしていきます。共通関数の追加
migration.sql の 冒頭 に、displayId 自動採番のための共通関数を追加します。sql
1-- 共通関数:シーケンスと接頭辞を受け取り、2文字 + 8桁ゼロ埋めのIDを生成
2CREATE OR REPLACE FUNCTION public.generate_display_id(seq_name TEXT, prefix TEXT)
3RETURNS TEXT AS $$
4DECLARE
5 new_val BIGINT;
6BEGIN
7 EXECUTE format('SELECT nextval(%L)', seq_name) INTO new_val;
8 RETURN prefix || lpad(new_val::TEXT, 8, '0');
9END;
10$$ LANGUAGE plpgsql;この関数により、
generate_display_id('user_display_id_seq','US') のように呼び出すと US00000001 が返されます。シーケンス設定の追加
続いて、主体テーブルごとにシーケンスを作成します。
先程の共通関数の直後に下記の記述を挿入します。
sql
1$$ LANGUAGE plpgsql;
2
3-- ===========================
4-- displayId 用シーケンス作成
5-- ===========================
6CREATE SEQUENCE IF NOT EXISTS public.account_display_id_seq;
7CREATE SEQUENCE IF NOT EXISTS public.branch_display_id_seq;
8CREATE SEQUENCE IF NOT EXISTS public.department_display_id_seq;
9CREATE SEQUENCE IF NOT EXISTS public.contact_display_id_seq;
10CREATE SEQUENCE IF NOT EXISTS public.subscription_display_id_seq;
11CREATE SEQUENCE IF NOT EXISTS public.user_display_id_seq;
12CREATE SEQUENCE IF NOT EXISTS public.role_display_id_seq;
13CREATE SEQUENCE IF NOT EXISTS public.menu_display_id_seq;
14
15-- CreateEnum
16CREATE TYPE "public"."MenuMatchMode" AS ENUM ('exact', 'prefix', 'regex');
17
18-- CreateTable
19CREATE TABLE "public"."Account" (
20これで、PostgreSQL が account_display_id_seq 等のカウンタを永続的に保持してくれます。
マイグレーションの実行
migration.sqlの編集が完了したら、マイグレーションを実行します。zsh
1npx prisma migrate devこれで、DBにテーブルが作成されたことが確認できると思います。
npx prisma studioの実行画面は下図の通りです。
11. データ投入戦略(Seed戦略)
本章では、設計したデータベースに 初期データを投入する方法 を整理します。
「Seed(シード)」という言葉はやや専門的ですが、要は 開発・検証のために事前に準備するデータ投入の仕組み のことです。
DB設計を行った直後に「最低限のマスタデータ」や「テストユーザー」を投入することで、管理画面フォーマットを即座に動作確認できる状態にします。
「Seed(シード)」という言葉はやや専門的ですが、要は 開発・検証のために事前に準備するデータ投入の仕組み のことです。
DB設計を行った直後に「最低限のマスタデータ」や「テストユーザー」を投入することで、管理画面フォーマットを即座に動作確認できる状態にします。
データ投入の目的
初期データ投入の目的は、大きく以下の3つに分類されます。
| 区分 | 目的 | 具体例 |
|---|---|---|
| マスタデータ | システム全体で参照する基準情報を登録 | Role、SubscriptionPlan、SubscriptionStatus、Menu |
| テストデータ | 開発・E2Eテストで必要な動作確認データを準備 | ダミーのAccount、Branch、Department、User |
| 運用開始補助 | 管理者ユーザーや最初のログインアカウントを登録 | admin@example.com の初期ユーザー |
これにより「アプリを起動してすぐログイン・画面操作ができる」状態をつくり出します。
パスワード暗号化の準備
パスワードは
argon2で暗号化します。下記でプロジェクトにインストールしておきます。zsh
1npm install argon2Prisma Seed の仕組み
これは最近知ったのすが、Prisma には
prisma db seed という公式の仕組みがあります。 かなり便利です。
package.json にスクリプトを定義し、prisma/seed.ts に投入処理を記述する流れです。- 投入対象:Prisma Client を経由してテーブルへ登録
- 実行タイミング:
npx prisma migrate dev実行後、または手動でnpx prisma db seed - 特徴:型安全に投入可能、再実行が容易
txt
1文字図:Seed投入の流れ
2
3prisma/schema.prisma ──▶ migrate dev ──▶ DB作成
4 │
5 ▼
6 seed.ts(Prisma Client)
7 │
8 ▼
9 初期データ(Role, Menu, User…)データ投入ファイルの作成
ts
1// prisma/seed.ts
2import { PrismaClient } from "@prisma/client";
3import argon2 from "argon2";
4
5const prisma = new PrismaClient();
6
7// ▼ 小道具:メールの正規化(小文字・trim)
8const normalizeEmail = (email: string) => email.trim().toLowerCase();
9
10// ▼ 今回の初期データ(必要に応じて .env 化)
11const ORG = {
12 accountName: "DELOGs株式会社",
13 branchName: "本社",
14 departmentName: "システム管理部",
15 // 仕様: 15文字以上 / 大文字・小文字・数字を各1以上(推測困難)
16 // 例: "Aa2024-Dept-Admin-01"(19文字、要件充足)
17 departmentCode: "Aa2024-Dept-Admin-01",
18};
19
20const ADMIN = {
21 email: "admin@example.com",
22 password: "AdminPassword012345", // seed用の仮パスワード(本番は発行フローで都度生成)
23 name: "管理者",
24 roleCode: "ADMIN",
25};
26
27async function main() {
28 // =====================================================
29 // 1) Role(upsert)
30 // =====================================================
31 const roles = [
32 {
33 code: "ADMIN",
34 name: "管理者",
35 priority: 100,
36 canEditData: true,
37 canDownloadData: true,
38 isSystem: true,
39 },
40 {
41 code: "EDITOR",
42 name: "編集者",
43 priority: 50,
44 canEditData: true,
45 canDownloadData: false,
46 },
47 {
48 code: "VIEWER",
49 name: "閲覧者",
50 priority: 10,
51 canEditData: false,
52 canDownloadData: false,
53 },
54 ];
55 for (const r of roles) {
56 await prisma.role.upsert({
57 where: { code: r.code },
58 update: {
59 name: r.name,
60 priority: r.priority,
61 canEditData: r.canEditData,
62 canDownloadData: r.canDownloadData,
63 isSystem: !!r.isSystem,
64 isActive: true,
65 },
66 create: {
67 code: r.code,
68 name: r.name,
69 priority: r.priority,
70 canEditData: r.canEditData,
71 canDownloadData: r.canDownloadData,
72 isSystem: !!r.isSystem,
73 isActive: true,
74 },
75 });
76 }
77
78 // =====================================================
79 // 2) SubscriptionPlan(upsert)
80 // =====================================================
81 const plans = [
82 { code: "basic", name: "ベーシックプラン", monthlyPrice: 1000 },
83 { code: "pro", name: "プロプラン", monthlyPrice: 5000 },
84 ];
85 for (const p of plans) {
86 await prisma.subscriptionPlan.upsert({
87 where: { code: p.code },
88 update: { name: p.name, monthlyPrice: p.monthlyPrice, isActive: true },
89 create: {
90 code: p.code,
91 name: p.name,
92 monthlyPrice: p.monthlyPrice,
93 isActive: true,
94 },
95 });
96 }
97
98 // =====================================================
99 // 3) SubscriptionStatus(upsert)
100 // =====================================================
101 const statuses = [
102 { code: "active", name: "有効" },
103 { code: "expired", name: "期限切れ" },
104 { code: "pending", name: "審査中" },
105 ];
106 for (const s of statuses) {
107 await prisma.subscriptionStatus.upsert({
108 where: { code: s.code },
109 update: { name: s.name, isActive: true },
110 create: { code: s.code, name: s.name, isActive: true },
111 });
112 }
113
114 // =====================================================
115 // 4) 組織階層の用意(Account → Branch → Department)
116 // - name は UNIQUE ではないので、findFirst で再利用を試みる
117 // - Department は code UNIQUE を利用して upsert
118 // =====================================================
119 const account =
120 (await prisma.account.findFirst({ where: { name: ORG.accountName } })) ??
121 (await prisma.account.create({
122 data: { name: ORG.accountName, isActive: true },
123 }));
124
125 const branch =
126 (await prisma.branch.findFirst({
127 where: { name: ORG.branchName, accountId: account.id },
128 })) ??
129 (await prisma.branch.create({
130 data: { name: ORG.branchName, accountId: account.id, isActive: true },
131 }));
132
133 const department = await prisma.department.upsert({
134 where: { code: ORG.departmentCode }, // UNIQUE
135 update: {
136 name: ORG.departmentName,
137 branchId: branch.id,
138 isActive: true,
139 },
140 create: {
141 code: ORG.departmentCode,
142 name: ORG.departmentName,
143 branchId: branch.id,
144 isActive: true,
145 },
146 });
147
148 // =====================================================
149 // 5) Department 契約(Subscription)を 1 件用意(status=active, plan=pro)
150 // =====================================================
151 const statusActive = await prisma.subscriptionStatus.findUnique({
152 where: { code: "active" },
153 });
154 const planPro = await prisma.subscriptionPlan.findUnique({
155 where: { code: "pro" },
156 });
157
158 if (!statusActive || !planPro) {
159 throw new Error(
160 "SubscriptionStatus(active) または SubscriptionPlan(pro) が見つかりません。",
161 );
162 }
163
164 // 既に部署に active の購読が存在するなら作成スキップ(簡易判定)
165 const existingSub = await prisma.subscription.findFirst({
166 where: { departmentId: department.id, statusId: statusActive.id },
167 });
168
169 if (!existingSub) {
170 await prisma.subscription.create({
171 data: {
172 departmentId: department.id,
173 statusId: statusActive.id,
174 planId: planPro.id,
175 startDate: new Date(),
176 isActive: true,
177 },
178 });
179 }
180
181 // =====================================================
182 // 6) 初期管理ユーザー(argon2 でハッシュ)
183 // - ログインは Department.code + User.email + password
184 // =====================================================
185 const adminRole = await prisma.role.findUnique({
186 where: { code: ADMIN.roleCode },
187 });
188 if (!adminRole) throw new Error("ADMIN ロールが見つかりません。");
189
190 const adminEmail = normalizeEmail(ADMIN.email);
191 const adminHash = await argon2.hash(ADMIN.password);
192
193 // 部署内メール一意(@@unique([departmentId, email]))で upsert
194 // Prisma の upsert は複合Uniqueには直接使えないため、事前検索 → update or create の流れにします
195 const existingAdmin = await prisma.user.findFirst({
196 where: { departmentId: department.id, email: adminEmail },
197 });
198
199 if (existingAdmin) {
200 await prisma.user.update({
201 where: { id: existingAdmin.id },
202 data: {
203 name: ADMIN.name,
204 roleId: adminRole.id,
205 hashedPassword: adminHash,
206 isActive: true,
207 },
208 });
209 } else {
210 await prisma.user.create({
211 data: {
212 departmentId: department.id,
213 roleId: adminRole.id,
214 email: adminEmail,
215 hashedPassword: adminHash,
216 name: ADMIN.name,
217 isActive: true,
218 },
219 });
220 }
221
222 // (任意)Menu は UI 実装側の要件に合わせて別 Seed で投入すると管理しやすいです
223}
224
225main()
226 .then(async () => {
227 console.log("Seeding completed.");
228 await prisma.$disconnect();
229 })
230 .catch(async (e) => {
231 console.error("Seeding failed:", e);
232 await prisma.$disconnect();
233 process.exit(1);
234 });上記スクリプトでは、以下の流れでデータを投入します。
- Role(ロールマスタ)の作成 ── ADMIN / EDITOR / VIEWER を投入
- SubscriptionPlan / SubscriptionStatus の作成 ── 契約マスタを登録
- 最小の Account / Branch / Department を作成
- 部署に紐づく「管理者ユーザー」を作成し、ログイン可能に設定
package.jsonでパスを通す
まず、
tsx で seedコマンドが実行できるようにします。zsh
1npm i -D tsx typescript @types/node次に、 プロジェクト直下 の
package.jsonに下記を追記します。json
1 "prisma": {
2 "seed": "tsx prisma/seed.ts"
3 },どこに記載してもよいのですが、下記のように
scripts{}の下に追記しました。json
1 "scripts": {
2 "dev": "next dev",
3 "build": "next build",
4 "start": "next start",
5 "lint": "next lint"
6 },
7 "prisma": {
8 "seed": "tsx prisma/seed.ts"
9 },
10 "dependencies": {
11 "@hookform/resolvers": "^5.2.1",データ投入を実行
ここまで完了したら、下記のコマンドでデータを投入します。
zsh
1npx prisma db seed実行時のワーニングについて
上記のコマンドを実行すると、
上記のコマンドを実行すると、
warn The configuration property package.json#prismais deprecated and will be removed in Prisma 7. Please migrate to a Prisma config file (e.g.,prisma.config.ts).というワーニングが表示されます。
これは Prisma 7 以降で package.json 内の prisma.seed 設定が廃止予定 であるために表示されるものです。
現時点(Prisma 6 系)では従来の書き方で問題なく動作しますが、将来を見据えて prisma.config.ts へ移行することが推奨されています。ts
1 // prisma.config.ts
2 import { defineConfig } from "@prisma/config";
3
4 export default defineConfig({
5 seed: {
6 command: "tsx prisma/seed.ts",
7 },
8});npx prisma studioで各テーブルを参照すると下図のようにデータが投入されていることが確認できると思います。
運用上の工夫
初期データ投入は一度で終わりではなく、運用の中で再投入・追加が必要になる場合があります。
以下の工夫を取り入れると運用が楽になります。
以下の工夫を取り入れると運用が楽になります。
| 工夫 | 内容 | メリット |
|---|---|---|
| idempotent 設計 | upsert を利用して「存在すれば更新、なければ作成」 | Seed を何度でも実行可能 |
| 複数ファイル化 | prisma/seed/roles.ts のように分割 | 責務分離・保守性向上 |
| 環境別Seed | dev/prod で投入データを分岐 | 本番は最小マスタのみ投入、開発はダミーデータ多数投入 |
| faker利用 | @faker-js/faker を使いダミーユーザー生成 | E2Eテストや画面確認に便利 |
これらを組み合わせることで、 Seedは「初期投入スクリプト」ではなく「開発・運用の基盤」 として活用できます。まだ、使いこなせていないですが、しっかり勉強していきたいと思います。
Seedのまとめ
- 「Seed」はデータベースに投入する 初期データの仕組み を指す
- マスタデータ(Role, Menu, Plan, Status)と最小限の管理者ユーザーを投入することが重要
- PrismaのSeed機能を使うことで、型安全・再実行可能・環境別に柔軟な投入ができる
- 運用の工夫として
upsertやファイル分割を活用する
12. まとめと次回予告
本稿では、管理画面フォーマットのデータ基盤として Prisma × PostgreSQL によるスキーマ設計を固め、
Seed は「開発環境を即座に再現できるための装置」であり、
displayId の自動採番、インデックス設計、そして 初期データ投入(Seed) までを整備しました。Seed は「開発環境を即座に再現できるための装置」であり、
upsert による冪等性・環境別の分岐・メニューの分割投入などを取り入れることで、運用の手戻りを抑えられます。⚠️ Prisma 7 以降では
package.json#prisma.seed が非推奨となるため、将来的に prisma.config.ts へ移行します(本稿の手順でも現状は問題なく動作します)。次回は ログイン機能 を実装します。
department.code + email + password を入力とし、argon2 によるハッシュ照合、セッション管理、レート制限・ロックアウトの導入までを段階的に解説します。UI と RBAC(Role.priority × Menu.minPriority)の接続まで含めて、**「ログインして実際の画面が動く」**ところまで到達します。参考文献
本章では、本記事の内容を補強するために参照した外部資料をまとめます。
Prisma、argon2、TypeScript など、実装に直結する公式ドキュメントを中心に整理しました。
Prisma、argon2、TypeScript など、実装に直結する公式ドキュメントを中心に整理しました。
Prisma 関連
Prisma を利用する上で参照すべき公式ドキュメントや議論スレッドです。
マイグレーションや Seed 戦略に関して、基本とベストプラクティスを確認できます。
マイグレーションや Seed 戦略に関して、基本とベストプラクティスを確認できます。
| 分類 | リンク |
|---|---|
| Prisma Migrate | Prisma 公式ドキュメント - Prisma Migrate |
| Prisma Seeding | Prisma 公式ドキュメント - Seeding データベース |
| ベストプラクティス | Prisma GitHub Discussions - Seeding best practices |
セキュリティ関連
ユーザーパスワードの保存に利用する
アルゴリズムの特性や利用方法を確認する際に役立ちます。
argon2 に関する参考資料です。アルゴリズムの特性や利用方法を確認する際に役立ちます。
TypeScript 関連
Prisma や Node.js 環境で型安全にコードを書くための基礎資料です。
特に「Declaration Merging」に関する知識はプロジェクトの拡張に有効です。
特に「Declaration Merging」に関する知識はプロジェクトの拡張に有効です。
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
この記事の更新履歴
2025/11/6
デモ環境とGithubリポジトリについての記述を追加
2025/9/12
リードレプリカを扱うために@prisma/extension-read-replicasのインストールについて追記
2025/9/11
Prismaスキーマの各テーブルモデルについて、DateTime型のカラムは@db.Timestamptzをつけるように修正。PostgreSQLなら、あえて設定しなくてもUTC時間になりますが、念のため。
2025/9/10
初回公開
▼ 関連記事
[管理画面フォーマット開発編 #11] パスワード再発行依頼とメールテンプレート統合
管理画面で受け付けたパスワード再発行依頼を、Server Action・Shadcn/uiのデータテーブル・メール送信を組み合わせて運用可能なワークフローに統合
2025/10/15公開
[管理画面フォーマット開発編 #10] メニュー管理UIをDB連携する
グローバルで一貫したMenuテーブルを保ちながら、部署ごとにメニュー表示をカスタマイズ
2025/10/12公開
[管理画面フォーマット開発編 #9 後編] 部署別ロール対応 ─ プロフィール管理の改修
DepartmentRole導入に伴い、プロフィール管理で「実効ロール」を参照するように修正と一部ついでの変更
2025/10/8公開
[管理画面フォーマット開発編 #9 前編] 部署別ロール対応 ─ ユーザ管理の改修
DepartmentRole導入に伴い、ユーザ管理で「実効ロール」を参照するように修正
2025/10/5公開
[管理画面フォーマット開発編 #8 後編] 部署別ロール ─ 管理UIとServer Action実装
部署ごとのロールを実際に操作できるように、Server Actionと管理画面UIを構築
2025/10/2公開


