DELOGs
[管理画面フォーマット開発編 #1] Prisma × PostgreSQLで進めるDB設計

管理画面フォーマット開発編 #1
Prisma × 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管理)
DBPostgreSQL 13(さくらのクラウド・アプライアンス構成)
ORマッパーPrisma
UIのみ版で整えた土台に対し、今回の記事からはDB設計を開始し、次回以降でスキーマ実装やSeed投入へ進んでいきます。

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の初期化

zsh
1npx prisma init

.envの設定

ローカル環境からのDB接続に設定を行ってください。
env
1# 例 2DATABASE_URL="postgresql://<ユーザー名>:<パスワード>@localhost:5432/delogs_demo?schema=public"
これで準備完了です。次章から設計を行っていきます。

1. 設計原則 ─ 命名・共通カラム・論理削除

DB設計を進めるにあたり、まずは全テーブルに共通する「設計原則」を定義します。これらは、スキーマを一貫性のある形で保ち、将来的な拡張や保守を容易にするための基本ルールとなります。

命名規則

命名規則は、PrismaとPostgreSQLの両方において可読性と拡張性を両立するために定めます。基本方針は「モデル名は単数形・PascalCase」「カラムはcamelCase」です。
区分規則
テーブル(Prismaモデル名)単数形、PascalCaseUser, Account, Branch
テーブル(DB物理名)ダブルクオート付きPascalCase(Prisma既定)"User", "Account"
カラム名camelCasecreatedAt, updatedAt
外部キー<参照先名>IdaccountId, branchId
インデックス名<Table>_<Column>_idxUser_email_idx

共通カラム

すべての「主体テーブル」に共通して持たせるカラムを定義します。中間テーブルは対象外とします。
カラム説明
idUUIDプライマリキー
displayIdVARCHAR(10)2文字接頭辞 + 8桁ゼロ埋め(例: AC00000001)。主体テーブルのみ
createdAtTIMESTAMPレコード作成日時
updatedAtTIMESTAMPレコード更新日時
deletedAtTIMESTAMP NULLABLE論理削除を採用する場合に利用
これにより、すべての主要テーブルが共通の監査基盤を備え、画面やAPIで一貫した扱いが可能になります。

論理削除の扱い

データ削除の扱いは、システム運用上の要件に直結するため、明確にルールを定めます。
  • 論理削除(deletedAtを利用)を基本とする
    履歴管理や復元が容易になり、誤削除による影響を軽減できます。
  • 物理削除とするケース
    中間テーブル(UserRole, MenuRole など)は冗長データが少なく、再作成も容易なため物理削除を許容します。
区分採用方式理由
主体テーブル(Account, Branch, User, Menu など)論理削除監査性・復元性を優先
中間テーブル(UserRole, MenuRole など)物理削除再作成が容易で履歴保持の必要性が低い

以上をまとめると、主体テーブルは「UUID + displayId + 監査カラム」を必須とし、削除は論理削除を基本とする。一方で、中間テーブルはシンプルな構造を維持し、物理削除で運用する方針となります。

2. displayId 仕様

本章では、各テーブルで共通して利用する displayId の仕様を整理します。
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文字に固定します。
テーブル接頭辞サンプル
AccountACAC00000001
BranchBRBR00000001
DepartmentDPDP00000001
ContactCTCT00000001
SubscriptionSBSB00000001
UserUSUS00000001
RoleRLRL00000001
MenuMNMN00000001
UserRoleUUIDのみ
MenuRoleUUIDのみ

採番の仕組み

displayId は PostgreSQL のシーケンスと関数を組み合わせて採番します。
この仕組みにより、アプリ側から特別な処理を追加せずとも一貫した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)は部署単位で管理することを基本方針とします。

全体像

DELOGsプロジェクトでは法人利用を前提に、会社 → 支店 → 部署 → ユーザ/担当者という階層構造を採用します。
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 比較のみによって行います。

簡易版ER図

以下に主要テーブルの関係を文字図で示します。
txt
1Account (AC) 1 ──── * Branch (BR) 1 ──── * Department (DP) 1 ──── * User (US) ──── 1 Role (RL) 23 └──── * Contact (CT) 4 5Department (DP) 1 ──── * Subscription (SB) 6 7Role (RL: priority, canEditData, canDownloadData) 89 └─── 判定 ───> Menu (MN: minPriority, visible)

スコープの考え方

スコープとは「どの単位で制御をかけるか」を指します。
本システムでは Department を最小スコープと定義し、契約・ログイン・RBAC の全てをこの単位に揃えます。
項目スコープ補足
契約(Subscription)Department 単位部署ごとに契約期間・状態・プランを管理
ログインDepartment 単位ログイン時に入力される「アカウントID」は department.code
RBACDepartment 単位User は必ず departmentIdroleId を持ち、権限判定に利用

このように 部署を最小単位とする設計により、ログイン・契約・権限を統一的に扱うことが可能となります。
次章からは、Account 系の各テーブル設計を順に詳細化していきます。

4. Account系 正規化設計

Account 系は「会社 → 支店 → 部署 → 担当者 → 契約」という法人組織の構造を表す基盤です。以降は 主体テーブルに共通のカラムとして id(UUID) / displayId / isActive(BOOLEAN) / createdAt / updatedAt / deletedAt(NULLABLE) を前提にします(※中間テーブルは isActive を持ちません)。

Account(会社マスタ)

Account は最上位のエンティティで、会社そのものを表します。支店や部署のツリーの根となるため、最小限の会社情報を保持します。
カラム必須説明
idUUID✔︎主キー
displayIdVARCHAR(10)✔︎AC00000001
isActiveBOOLEAN✔︎有効/無効(既定: true)
nameTEXT✔︎会社名
headquartersAddressTEXT本店所在地
invoiceNumberTEXTインボイス登録番号
remarksTEXT備考(任意メモ)
createdAt / updatedAt / deletedAtTIMESTAMP✔︎ / ✔︎ /共通カラム(論理削除は任意)

Branch(支店)

Branch は Account に属する支店を表します。
カラム必須説明
idUUID✔︎主キー
displayIdVARCHAR(10)✔︎BR00000001
isActiveBOOLEAN✔︎有効/無効(既定: true)
accountIdUUID✔︎Account へのFK
nameTEXT✔︎支店名
addressTEXT支店所在地
remarksTEXT備考
createdAt / updatedAt / deletedAtTIMESTAMP✔︎ / ✔︎ /共通カラム

Department(部署)

Department は Branch に属し、User は必ず Department に所属します。
カラム必須説明
idUUID✔︎主キー
displayIdVARCHAR(10)✔︎DP00000001
isActiveBOOLEAN✔︎有効/無効(既定: true)
codeTEXT✔︎ログイン時に使用
branchIdUUID✔︎Branch へのFK
nameTEXT✔︎部署名
phoneTEXT代表電話
remarksTEXT備考
createdAt / updatedAt / deletedAtTIMESTAMP✔︎ / ✔︎ /共通カラム

Contact(担当者)

契約・運用に関わる窓口情報です。部署に紐づけます。
カラム必須説明
idUUID✔︎主キー
displayIdVARCHAR(10)✔︎CT00000001
isActiveBOOLEAN✔︎有効/無効(既定: true)
departmentIdUUID✔︎Department へのFK
nameTEXT✔︎担当者名
emailTEXTメール
phoneTEXT電話
dutyTEXT担当区分(例: 契約窓口/請求窓口/技術窓口 など)
remarksTEXT備考
createdAt / updatedAt / deletedAtTIMESTAMP✔︎ / ✔︎ /共通カラム

Subscription(契約:部署単位)

部署(Department)単位で、申込み〜利用終了までを管理します。ログインスコープと契約スコープを一致させます。
カラム必須説明
idUUID✔︎主キー
displayIdVARCHAR(10)✔︎SB00000001
isActiveBOOLEAN✔︎有効/無効(既定: true)
departmentIdUUID✔︎Department へのFK
statusIdUUID✔︎SubscriptionStatus へのFK
planIdUUID✔︎SubscriptionPlan へのFK
startDateDATE✔︎利用開始日
endDateDATE利用終了日
remarksTEXT備考
createdAt / updatedAt / deletedAtTIMESTAMP✔︎ / ✔︎ /共通カラム

SubscriptionStatus(契約ステータス・マスタ)

Subscription(契約:部署単位)で利用する契約ステータスを管理します。
カラム必須説明
idUUID✔︎主キー
codeTEXT✔︎一意コード(例: active, expired, pending
nameTEXT✔︎表示名
descriptionTEXT説明
isActiveBOOLEAN✔︎有効/無効
createdAt / updatedAt / deletedAtTIMESTAMP✔︎ / ✔︎ /共通カラム

SubscriptionPlan(契約プラン・マスタ)

Subscription(契約:部署単位)で利用する契約プランを管理します。
カラム必須説明
idUUID✔︎主キー
codeTEXT✔︎一意コード(例: basic, pro, enterprise
nameTEXT✔︎表示名
descriptionTEXT説明
monthlyPriceNUMERIC月額(必要なら通貨管理は別途)
isActiveBOOLEAN✔︎有効/無効
createdAt / updatedAt / deletedAtTIMESTAMP✔︎ / ✔︎ /共通カラム

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の有効/無効切替・論理削除と併用しやすい。
  • 契約は部署単位 + マスタ参照statusIdplanId を最初からFK化。
  • schema.prisma(9章で作成します)は、上記を前提に FK/ユニーク/インデックス を定義します。

5. User 設計(US)

User テーブルは 認証・権限・契約 の三要素をつなぐ中心的なエンティティです。
特に本設計では、必ず Department(部署)に所属させることで、契約スコープ(Subscription)とログインスコープを一致させます。 ログインは departmentId + email + hashedPassword の3要素で行います。

User(ユーザー)

カラム必須説明
idUUID✔︎主キー
displayIdVARCHAR(10)✔︎US00000001
isActiveBOOLEAN✔︎有効/無効(既定: true)
departmentIdUUID✔︎Department FK(ログイン時のスコープ
roleIdUUID✔︎Role FK(ユーザーは常に1ロール)
emailTEXT✔︎ログイン・通知用メール(部署内で一意
hashedPasswordTEXT✔︎パスワード(ハッシュ)
nameTEXT✔︎氏名(表示名)
phoneTEXT電話番号
remarksTEXT備考
createdAt / updatedAt / deletedAtTIMESTAMP✔︎ / ✔︎ /共通カラム

制約・運用ポイント

  • 一意制約@@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) 34 login key: (departmentId + email) + password

認証フロー(要旨)

  • 入力:department.code + email + password
  • 解決:department.codedepartmentIdを解決 → UserdepartmentId + email で検索 → hashedPassword 照合
  • 結果:成功時に User と Role をセッションへ格納(httpOnly Cookie)、以降の可視・機能判定に利用

権限判定の位置づけ

  • メニュー可視Role.priority >= Menu.minPriority(親の minPriority 継承後の実効値)
  • 機能可否Role.canDownloadDataRole.canEditData を参照
  • 補足:User は中間テーブルを介さず roleId 直付け。複数ロールや例外可視が必要になった場合は将来拡張で中間を増設

インデックス指針

種別対象目的
UNIQUE(departmentId, email)ログイン検索の一意性保証
INDEXdepartmentId部署内一覧・JOIN 最適化
INDEXroleIdロール別集計・抽出
INDEXisActive有効ユーザ抽出
INDEXcreatedAt作成日時ソート(管理UI)
この構成により、部署スコープに根ざしたログインと、ロールに基づく単調な可視・機能判定をシンプルに実現します。

6. Role 設計(RL)

Role テーブルは、ユーザーの権限を一元的に管理するためのエンティティです。
本設計では、ユーザーは必ず 1つの Role を持ち、priority に基づく階層判定と、canDownloadData / canEditData による機能別の制御を組み合わせます。

Role(ロール)

カラム必須説明
idUUID✔︎主キー
displayIdVARCHAR(10)✔︎RL00000001
isActiveBOOLEAN✔︎有効/無効(既定: true)
codeTEXT✔︎一意コード(例: ADMIN, EDITOR
nameTEXT✔︎表示名(UI 表示用)
priorityINT✔︎権限レベル(数値が大きいほど強い権限)
badgeColorTEXTUI 表示用カラーコード
isSystemBOOLEAN✔︎システム定義ロールかどうか
canDownloadDataBOOLEAN✔︎データダウンロード可否
canEditDataBOOLEAN✔︎データ編集可否
remarksTEXT備考
createdAt / updatedAt / deletedAtTIMESTAMP✔︎ / ✔︎ /共通カラム

権限設計の考え方

  • 階層制御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.mock.ts に定義されていた情報を起点にしていましたが、本設計ではそれを正規化し、DB テーブルとして永続化します。
Menu は 階層構造を持ち、さらに Role.priority × Menu.minPriority の比較により可視制御を行います。

Menu(メニュー本体)

Menu は階層型のメニュー情報を表し、表示名・遷移先・アイコン・優先度などを保持します。
カラム必須説明
idUUID✔︎主キー
displayIdVARCHAR(10)✔︎MN00000001
isActiveBOOLEAN✔︎有効/無効(既定: true)
parentIdUUID親メニューへの FK(NULL ならルート)
titleTEXT✔︎表示名
hrefTEXT遷移先パス(内部/外部を含む)
isExternalBOOLEAN外部 URL かどうか
iconNameTEXTアイコン名(UI で Lucide 等に解決)
matchTEXT(ENUM想定)✔︎マッチ方法(exact or prefix
patternTEXT追加のマッチパターン
minPriorityINT可視化に必要な最小権限(親から継承、上書き不可)
isSectionBOOLEAN✔︎セクション見出しかどうか
sortOrderINT✔︎同階層内での並び順
remarksTEXT備考
createdAt / updatedAt / deletedAtTIMESTAMP✔︎ / ✔︎ /共通カラム

可視制御の仕組み

Menu 可視判定は Role.priority と Menu.minPriority の比較で行います。
これにより、上位ロールは下位ロールの可視範囲を包含する単調性が保証されます。
また、親メニューの minPriority は子メニューに継承され、子での上書きは不可とします。

Menu の関係図

以下に、Menu と Role による可視制御の関係を文字図で示します。
txt
1Role (RL: priority) 23 └─── priority >= Menu.minPriority → Menu (MN) 45 └─ parentId により階層構造を構築

設計のポイント

項目内容
階層構造parentId により多階層(ルート → サブ → サブサブ)を表現
並び順同階層内は sortOrder ASC で表示
可視制御Role.priority >= Menu.minPriority で表示可否を判定
継承ルール親の minPriority は子に強制継承。子での下げは不可
UI要素iconNameisSection で UI レンダリングを補助
外部リンク対応href + isExternal により外部 URL も管理可能

この設計により、Menu は 階層構造・並び順・可視制御を一元的に管理でき、UI 層は「ログインユーザの Role.priority を渡して、可視メニューを取得 → sortOrder 順で描画」するだけで済みます。

8. インデックス・ユニーク制約設計

この章では、各テーブルの ユニークキー推奨インデックス を体系化します。
設計方針は「検索・照合で多用するキーに最小限のインデックス」「UI要件(並び順・可視判定)を高速化」「将来拡張に耐える制約の粒度」です。

基本方針

本プロジェクトのクエリは「部署スコープ」「表示ID検索」「メニュー表示」「ログイン照合」が中心です。
したがって以下を必須とします。
  • displayId は主体テーブルで UNIQUE(人が扱う識別子の一意性担保)
  • 外部キー列は基本的に INDEX(JOIN/絞り込みを高速化)
  • 並び順に使う列は複合INDEX(例:(parentId, sortOrder)
  • よく使うフラグは単体INDEX(例:isActiveminPriority

ユニークキー設計(横断)

各テーブルのユニーク性を一覧化します。
テーブルユニークキー用途/理由
AccountdisplayId人間可読IDの一意性
BranchdisplayId同上
DepartmentdisplayId同上
ContactdisplayId同上
SubscriptiondisplayId同上
SubscriptionStatuscodeマスタの論理一意名
SubscriptionPlancode同上
UserdisplayId人間可読IDの一意性
User(departmentId, email)ログイン照合キー(部署内メール一意)
RoledisplayId人間可読IDの一意性
Rolecodeシステム内の論理一意名(ADMIN など)
MenudisplayId人間可読IDの一意性
Menu(parentId, sortOrder)同階層で表示順の一意性(重複防止)

外部キー・参照整合性

UIとSeedの運用を考慮し、外部キーの 参照整合性 は次の原則を推奨します。
FK 先参照元onUpdateonDelete理由
AccountBranchCASCADERESTRICT名前変更伝播、安易な削除を防ぐ
BranchDepartmentCASCADERESTRICT同上
DepartmentUserCASCADERESTRICTユーザ孤児化を防ぐ
DepartmentContactCASCADESET NULL/RESTRICT運用方針で選択(窓口一時未設定可否)
DepartmentSubscriptionCASCADERESTRICT契約履歴保持を優先
RoleUserCASCADERESTRICTロール削除を厳格に
Menu(親)Menu(子)CASCADERESTRICTツリーの整合性保持
補足:物理削除は最小限。主体テーブルは原則「論理削除(deletedAt)」で扱います。

8.4 テーブル別インデックス

性能上の効果が高いものに絞って記載しています。必要に応じて運用で追加します。

Account / Branch / Department(組織階層)

テーブル推奨 INDEX目的
AccountdisplayId UNIQUEUI検索
isActive有効一覧
BranchdisplayId UNIQUEUI検索
accountIdJOIN高速化
isActive有効一覧
Departmentcode UNIQUEUI検索・ログイン前解決
branchIdJOIN高速化
isActive有効一覧

Contact

推奨 INDEX目的
displayId UNIQUEUI検索
departmentId組織別一覧
isActive有効一覧
(オプション) DBレベル部分一致 LOWER(email)窓口検索の高速化(Prismaは functional index を SQL で追加)

Subscription / SubscriptionStatus / SubscriptionPlan

テーブル推奨 INDEX目的
SubscriptiondisplayId UNIQUEUI検索
departmentId組織別契約参照
(departmentId, startDate DESC)直近契約の高速取得
statusId, planIdマスタ絞り込み
SubscriptionStatuscode UNIQUE論理一意
isActive有効一覧
SubscriptionPlancode UNIQUE論理一意
isActive有効一覧
発展:部署単位の契約期間 重複禁止 は PostgreSQL の EXCLUDE 制約USING gist + daterange)で表現可能(Prismaは直接未対応のため SQL で追加)。

User

推奨 INDEX目的
displayId UNIQUEUI検索
(departmentId, email) UNIQUEログインキー
departmentId部署別一覧
roleIdロール別集計
isActive有効ユーザ抽出
createdAt新着ユーザ表示
補足:メールは LOWER(email) を保存時に正規化し、比較の一貫性を担保します。

Role

推奨 INDEX目的
displayId UNIQUEUI検索
code UNIQUE論理一意
isActive有効一覧
priority並び替え・可視閾値比較の補助

Menu

推奨 INDEX目的
displayId UNIQUEUI検索
(parentId, sortOrder) UNIQUE階層内の表示順重複防止
parentIdツリー構築 JOIN
isActive有効メニュー抽出
minPriorityロール閾値での可視判定前処理
(ENUM) matchexact/prefix のフィルタ(ENUMは内部的に高速)

パフォーマンス運用の要点

項目推奨補足
低選択性フラグisActive に単体INDEX1/0 フラグでもフィルタ初期段階に有効
並び替え(parentId, sortOrder) 複合Menu の描画を 1 クエリで完結
ログイン(departmentId, email) UNIQUEWHERE + UNIQUE で即ヒット
可視判定minPriority 単体INDEXロール閾値前処理に寄与
正規化LOWER(email) 保存大文字小文字の不一致解消
分析createdAt INDEX新着/期間集計の定番
監査deletedAt に INDEX は不要論理削除は「IS NULL」主体のため状況に応じて
注意:インデックスは 書き込みコスト とトレードオフ。測定(EXPLAIN ANALYZE)の上、最小構成から段階的に追加します。

9. Prismaスキーマの作成(モデル一覧)

ここでは、これまでに設計してきた Account系 / User系 / Menu系 の要件を、実運用可能な 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) 910 └─ 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 @db.VarChar(10) @default(dbgenerated("generate_display_id('account_display_id_seq','AC')")) 31 // 共通 32 isActive Boolean @default(true) 33 createdAt DateTime @default(now()) 34 updatedAt DateTime @updatedAt 35 deletedAt DateTime? 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 @db.VarChar(10) @default(dbgenerated("generate_display_id('branch_display_id_seq','BR')")) 53 isActive Boolean @default(true) 54 createdAt DateTime @default(now()) 55 updatedAt DateTime @updatedAt 56 deletedAt DateTime? 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 @db.VarChar(10) @default(dbgenerated("generate_display_id('department_display_id_seq','DP')")) 75 isActive Boolean @default(true) 76 createdAt DateTime @default(now()) 77 updatedAt DateTime @updatedAt 78 deletedAt DateTime? 79 80 // ★ ログイン用コード(人間入力・推測困難な固定文字列) 81 // - 仕様: 15文字以上 / 大文字・小文字・数字を各1文字以上含む 82 // - 一意: 全体で UNIQUE(部署横断で重複不可) 83 code String @unique 84 85 branchId String 86 name String 87 phone String? 88 remarks String? 89 90 // リレーション 91 branch Branch @relation(fields: [branchId], references: [id], onDelete: Restrict) 92 contacts Contact[] 93 subscriptions Subscription[] 94 users User[] 95 96 @@index([branchId]) 97 @@index([isActive]) 98 @@index([createdAt]) 99} 100 101model Contact { 102 id String @id @default(uuid()) 103 displayId String @unique @db.VarChar(10) @default(dbgenerated("generate_display_id('contact_display_id_seq','CT')")) 104 isActive Boolean @default(true) 105 createdAt DateTime @default(now()) 106 updatedAt DateTime @updatedAt 107 deletedAt DateTime? 108 109 departmentId String 110 name String 111 email String? 112 phone String? 113 duty String? // 担当区分(契約/請求/技術 等) 114 remarks String? 115 116 // リレーション 117 department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict) 118 119 @@index([departmentId]) 120 @@index([isActive]) 121 @@index([createdAt]) 122} 123 124model Subscription { 125 id String @id @default(uuid()) 126 displayId String @unique @db.VarChar(10) @default(dbgenerated("generate_display_id('subscription_display_id_seq','SB')")) 127 isActive Boolean @default(true) 128 createdAt DateTime @default(now()) 129 updatedAt DateTime @updatedAt 130 deletedAt DateTime? 131 132 departmentId String 133 statusId String 134 planId String 135 startDate DateTime @db.Date 136 endDate DateTime? @db.Date 137 remarks String? 138 139 // リレーション 140 department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict) 141 status SubscriptionStatus @relation(fields: [statusId], references: [id], onDelete: Restrict) 142 plan SubscriptionPlan @relation(fields: [planId], references: [id], onDelete: Restrict) 143 144 @@index([departmentId]) 145 @@index([statusId]) 146 @@index([planId]) 147 @@index([startDate]) 148 @@index([isActive]) 149} 150 151model SubscriptionStatus { 152 id String @id @default(uuid()) 153 isActive Boolean @default(true) 154 createdAt DateTime @default(now()) 155 updatedAt DateTime @updatedAt 156 deletedAt DateTime? 157 158 code String @unique 159 name String 160 description String? 161 162 subscriptions Subscription[] 163 164 @@index([isActive]) 165} 166 167model SubscriptionPlan { 168 id String @id @default(uuid()) 169 isActive Boolean @default(true) 170 createdAt DateTime @default(now()) 171 updatedAt DateTime @updatedAt 172 deletedAt DateTime? 173 174 code String @unique 175 name String 176 description String? 177 monthlyPrice Decimal? 178 179 subscriptions Subscription[] 180 181 @@index([isActive]) 182} 183 184// ============================== 185// User / Role 186// ============================== 187model Role { 188 id String @id @default(uuid()) 189 displayId String @unique @db.VarChar(10) @default(dbgenerated("generate_display_id('role_display_id_seq','RL')")) 190 isActive Boolean @default(true) 191 createdAt DateTime @default(now()) 192 updatedAt DateTime @updatedAt 193 deletedAt DateTime? 194 195 code String @unique // 例: ADMIN / EDITOR / VIEWER ... 196 name String 197 priority Int 198 badgeColor String? 199 isSystem Boolean @default(false) 200 canDownloadData Boolean 201 canEditData Boolean 202 remarks String? 203 204 users User[] 205 206 @@index([priority]) 207 @@index([isActive]) 208} 209 210model User { 211 id String @id @default(uuid()) 212 displayId String @unique @db.VarChar(10) @default(dbgenerated("generate_display_id('user_display_id_seq','US')")) 213 isActive Boolean @default(true) 214 createdAt DateTime @default(now()) 215 updatedAt DateTime @updatedAt 216 deletedAt DateTime? 217 218 departmentId String 219 roleId String 220 email String // login & notice 221 hashedPassword String 222 name String 223 phone String? 224 remarks String? 225 226 // リレーション 227 department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict) 228 role Role @relation(fields: [roleId], references: [id], onDelete: Restrict) 229 230 // 部署内メール一意(ログインキー) 231 @@unique([departmentId, email]) 232 @@index([departmentId]) 233 @@index([roleId]) 234 @@index([isActive]) 235 @@index([createdAt]) 236} 237 238// ============================== 239// Menu 240// ============================== 241model Menu { 242 id String @id @default(uuid()) 243 displayId String @unique @db.VarChar(10) @default(dbgenerated("generate_display_id('menu_display_id_seq','MN')")) 244 isActive Boolean @default(true) 245 createdAt DateTime @default(now()) 246 updatedAt DateTime @updatedAt 247 deletedAt DateTime? 248 249 parentId String? 250 title String 251 href String? 252 isExternal Boolean? // 外部URLか 253 iconName String? 254 match MenuMatchMode 255 pattern String? 256 minPriority Int? // 親から継承(アプリ層で強制する) 257 isSection Boolean 258 sortOrder Int 259 remarks String? 260 261 // リレーション(自己参照) 262 parent Menu? @relation("MenuToMenu", fields: [parentId], references: [id], onDelete: Restrict) 263 children Menu[] @relation("MenuToMenu") 264 265 @@index([parentId]) 266 @@index([minPriority]) 267 @@index([isActive]) 268 @@index([sortOrder]) 269 @@index([createdAt]) 270}

スキーマのポイント(実装メモ)

  • displayId 採番@default(dbgenerated("generate_display_id('...','XX')"))DB側の関数 を呼び出します。本文中の関数名・シーケンス名は想定です(10章の実装で PostgreSQL に作成)。
  • 論理削除:主体テーブルは isActive + deletedAt を保持。中間テーブルが存在しないため、削除時は原則「論理削除」を運用(外部キーは onDelete: Restrict)。
  • ログインUser@@unique([departmentId, email]) により部署内でメール一意。入力された department.codedepartmentId 解決後に照合。
  • RBACUser.roleId で単一ロール紐付け。メニュー可視は Role.priority >= Menu.minPriority
  • Menu 継承ルールminPriority は親から継承(アプリ層で解決)。DB上は NULL 許容で、親に依存しない定義も可能に。
  • 索引:8章の指針どおり、displayId のユニーク・FK・isActivecreatedAt を中心にインデックスを付与済み。
  • 型の粒度displayId@db.VarChar(10)startDate/endDate@db.Date を明示。monthlyPriceDecimal(必要に応じて精度/通貨設計を別章で)。
これで 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 は displayIddbgenerated(...) をそのまま出力するため、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
1prisma migrate dev
これで、DBにテーブルが作成されたことが確認できると思います。npx prisma studioの実行画面は下図の通りです。
Prisma Studioの実行画面

11. データ投入戦略(Seed戦略)

本章では、設計したデータベースに 初期データを投入する方法 を整理します。
「Seed(シード)」という言葉はやや専門的ですが、要は 開発・検証のために事前に準備するデータ投入の仕組み のことです。
DB設計を行った直後に「最低限のマスタデータ」や「テストユーザー」を投入することで、管理画面フォーマットを即座に動作確認できる状態にします。

データ投入の目的

初期データ投入の目的は、大きく以下の3つに分類されます。
区分目的具体例
マスタデータシステム全体で参照する基準情報を登録Role、SubscriptionPlan、SubscriptionStatus、Menu
テストデータ開発・E2Eテストで必要な動作確認データを準備ダミーのAccount、Branch、Department、User
運用開始補助管理者ユーザーや最初のログインアカウントを登録admin@example.com の初期ユーザー
これにより「アプリを起動してすぐログイン・画面操作ができる」状態をつくり出します。

パスワード暗号化の準備

パスワードはargon2で暗号化します。下記でプロジェクトにインストールしておきます。
zsh
1npm install argon2

Prisma 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作成 456 seed.ts(Prisma Client) 789 初期データ(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 });
上記スクリプトでは、以下の流れでデータを投入します。
  1. Role(ロールマスタ)の作成 ── ADMIN / EDITOR / VIEWER を投入
  2. SubscriptionPlan / SubscriptionStatus の作成 ── 契約マスタを登録
  3. 最小の Account / Branch / Department を作成
  4. 部署に紐づく「管理者ユーザー」を作成し、ログイン可能に設定

package.jsonでパスを通す

まず、tsxseedコマンドが実行できるようにします。
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で各テーブルを参照すると下図のようにデータが投入されていることが確認できると思います。
データ投入後のPrisma Studioの実行画面

運用上の工夫

初期データ投入は一度で終わりではなく、運用の中で再投入・追加が必要になる場合があります。
以下の工夫を取り入れると運用が楽になります。
工夫内容メリット
idempotent 設計upsert を利用して「存在すれば更新、なければ作成」Seed を何度でも実行可能
複数ファイル化prisma/seed/roles.ts のように分割責務分離・保守性向上
環境別Seeddev/prod で投入データを分岐本番は最小マスタのみ投入、開発はダミーデータ多数投入
faker利用@faker-js/faker を使いダミーユーザー生成E2Eテストや画面確認に便利
これらを組み合わせることで、 Seedは「初期投入スクリプト」ではなく「開発・運用の基盤」 として活用できます。まだ、使いこなせていないですが、しっかり勉強していきたいと思います。

Seedのまとめ

  • 「Seed」はデータベースに投入する 初期データの仕組み を指す
  • マスタデータ(Role, Menu, Plan, Status)と最小限の管理者ユーザーを投入することが重要
  • PrismaのSeed機能を使うことで、型安全・再実行可能・環境別に柔軟な投入ができる
  • 運用の工夫として upsert やファイル分割を活用する

12. まとめと次回予告

本稿では、管理画面フォーマットのデータ基盤として Prisma × PostgreSQL によるスキーマ設計を固め、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 関連

Prisma を利用する上で参照すべき公式ドキュメントや議論スレッドです。
マイグレーションや Seed 戦略に関して、基本とベストプラクティスを確認できます。

セキュリティ関連

ユーザーパスワードの保存に利用する argon2 に関する参考資料です。
アルゴリズムの特性や利用方法を確認する際に役立ちます。

TypeScript 関連

Prisma や Node.js 環境で型安全にコードを書くための基礎資料です。
特に「Declaration Merging」に関する知識はプロジェクトの拡張に有効です。
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。