ASP.NET Core 申請・承認機能を作る① 状態とDBを設計する
申請画面に「承認する」ボタンを置くだけなら、それほど難しくありません。ところが、実際に使える機能にするには、申請後の編集、承認する人の権限、同時に届く操作も考える必要があります。
今回は備品購入の申請を例に、ASP.NET Core、React、PostgreSQLで申請・承認機能を作ります。件名と購入理由を入力し、別の利用者が承認または却下する、小さな機能から始めます。
次の5記事で、同じデータとAPIを使って説明します。
最初に今回のルールを決める
申請の状態は、次の4種類にします。
| 状態 | 意味 |
|---|---|
Draft | 下書き。本人が内容を編集できる |
Submitted | 申請中。承認者の判断を待っている |
Approved | 承認済み。内容は変更しない |
Rejected | 却下。理由を確認できる |
「誰が何をできるか」も先に決めます。
- 組織の有効なメンバーは、自分の下書きを作成できる
- 下書きの閲覧と編集は、申請者本人だけができる
- 申請者本人は、自分の申請を申請後も閲覧できる
- 同じ組織で承認権限を持つ人は、申請後の内容を閲覧できる
- 承認権限を持つ人でも、自分の申請は承認・却下できない
- 一度申請した内容は編集できない
- 承認・却下は、申請中のものに一度だけ行う
今回の却下は、その申請の終了とします。修正して再提出する「差し戻し」は別の業務ルールなので含めません。必要になったら、誰が編集できる状態へ戻すか、再提出前後の内容をどう残すかを追加で設計します。
金額別の承認経路、複数段階の承認、添付ファイルも今回の範囲には含めません。まずは1人の判断で完了する流れを作ります。
状態遷移を表にする
状態がどう変わるかを、状態遷移と呼びます。ボタンごとに処理を書く前に、次の表を作っておくと判断がぶれにくくなります。
| 現在の状態 | 操作 | 次の状態 | 実行できる人 |
|---|---|---|---|
| まだ存在しない | 作成 | Draft | 組織の有効なメンバー |
Draft | 保存 | Draft | 申請者本人 |
Draft | 申請 | Submitted | 申請者本人 |
Submitted | 承認 | Approved | 本人以外の承認権限を持つ人 |
Submitted | 却下 | Rejected | 本人以外の承認権限を持つ人 |
表にない操作は許可しません。たとえば、下書きを直接承認する、承認済みを却下へ変更する、といった更新は拒否します。
申請後に本文を変更できると、「承認者が読んだ内容」と「最終的に保存された内容」が違う可能性があります。今回は、申請時点で編集を止めることで対応します。
状態を変更するAPIを操作ごとに分ける
次のような、任意の状態を指定できるAPIは作りません。
受け付けない更新形式{
"status": "Approved",
"applicantId": "任意の利用者ID"
}代わりに、「申請する」「承認する」という操作をAPIのURLで表します。申請者IDは、ログイン中の利用者からサーバーが決めます。
| メソッド | URLの末尾 | 操作 |
|---|---|---|
| POST | /approvals | 空の下書きを作る |
| GET | /approvals/{id} | 詳細を取得する |
| PUT | /approvals/{id} | 下書きの件名と内容を保存する |
| POST | /approvals/{id}/submit | 保存済みの内容を申請する |
| POST | /approvals/{id}/approve | 承認する |
| POST | /approvals/{id}/reject | 理由を付けて却下する |
共通の先頭部分は/api/organizations/{org}です。URLに組織IDがあるだけでは、所属している証明にはなりません。APIはログイン中の利用者がその組織の有効なメンバーかを確認します。
状態と操作可否をC#で表す
C#の例は.NET 8、C# 12を対象にしています。以下のファイルは同じプロジェクトの同じ名前空間へ置きます。掲載コードでは名前空間を省略します。
Approval.cspublic enum ApprovalStatus { Draft, Submitted, Approved, Rejected }
public sealed record ApprovalRequest(
Guid Id, Guid OrganizationId, Guid ApplicantId,
string Title, string Description, ApprovalStatus Status,
long Version, string? DecisionReason);
public sealed record Member(Guid UserId, Guid OrganizationId, bool CanApprove);
public sealed record AllowedActions(bool CanEdit, bool CanSubmit, bool CanApprove, bool CanReject);
public static class ApprovalPolicy
{
public static bool CanView(ApprovalRequest item, Member member) =>
item.OrganizationId == member.OrganizationId &&
(item.ApplicantId == member.UserId ||
(member.CanApprove && item.Status != ApprovalStatus.Draft));
public static AllowedActions GetActions(ApprovalRequest item, Member member)
{
var sameOrganization = item.OrganizationId == member.OrganizationId;
var ownDraft = sameOrganization && item.ApplicantId == member.UserId &&
item.Status == ApprovalStatus.Draft;
var canDecide = sameOrganization && member.CanApprove &&
item.ApplicantId != member.UserId && item.Status == ApprovalStatus.Submitted;
return new(ownDraft, ownDraft, canDecide, canDecide);
}
}
public sealed record ApprovalHistory(
Guid RequestId, long Version, Guid ActorId, string Action,
ApprovalStatus FromStatus, ApprovalStatus ToStatus,
string? Reason, DateTimeOffset OccurredAt);
public sealed class WorkflowError(int status, string code) : Exception(code)
{
public int Status { get; } = status;
public string Code { get; } = code;
}CanViewは内容を見てよいか、GetActionsは何を操作してよいかを返します。編集ボタンを隠すだけでは閲覧制限にはならないため、詳細取得APIでもCanViewを使います。
Memberはリクエスト本文から作りません。サーバーがDBで所属と現在の権限を調べ、有効なメンバーであることを確認した結果を入れます。
AllowedActionsは、詳細画面への表示と更新APIの両方で使用します。画面用と更新用で別々の条件を持たせず、同じ判定を使うためです。ただし、画面へ返した判定は取得時点のものなので、更新時にも判定し直します。
現在の内容と操作履歴を分けて保存する
DBには、現在の申請内容と、誰がどの操作をしたかを別々に保存します。
テーブルの例CREATE TABLE organization_member (
organization_id uuid NOT NULL,
user_id uuid NOT NULL,
is_active boolean NOT NULL DEFAULT true,
can_approve boolean NOT NULL DEFAULT false,
PRIMARY KEY (organization_id, user_id)
);
CREATE TABLE approval_request (
id uuid PRIMARY KEY,
organization_id uuid NOT NULL,
applicant_id uuid NOT NULL,
title text NOT NULL CHECK (char_length(title) <= 100),
description text NOT NULL CHECK (char_length(description) <= 2000),
status text NOT NULL CHECK (
status IN ('Draft', 'Submitted', 'Approved', 'Rejected')
),
version bigint NOT NULL CHECK (version > 0),
decision_reason text,
FOREIGN KEY (organization_id, applicant_id)
REFERENCES organization_member(organization_id, user_id),
CHECK (
(status = 'Rejected' AND decision_reason IS NOT NULL
AND char_length(btrim(decision_reason)) BETWEEN 1 AND 500)
OR (status <> 'Rejected' AND decision_reason IS NULL)
)
);
CREATE INDEX approval_request_org_status_idx
ON approval_request(organization_id, status);
CREATE TABLE approval_history (
request_id uuid NOT NULL REFERENCES approval_request(id),
version bigint NOT NULL,
actor_id uuid NOT NULL,
action text NOT NULL CHECK (
action IN ('created', 'edited', 'submitted', 'approved', 'rejected')
),
from_status text NOT NULL,
to_status text NOT NULL,
reason text,
occurred_at timestamptz NOT NULL,
PRIMARY KEY (request_id, version)
);organization_memberは説明用の所属テーブルです。既存の会員・組織テーブルがある場合は、そちらへ対応させます。退会時は所属レコードを物理削除せず、無効にする想定です。申請者との対応を残すためです。
versionは更新するたびに1増やす番号です。件名の保存でも、申請でも、承認でも増やします。画面が古い内容を見たまま操作していないかを判定するために使います。
履歴のversionには、更新後の番号を入れます。下書き作成をVersion 1とし、その後は1回の更新につき履歴も1件保存します。
この例で残すのは、操作した人、時刻、操作の種類、状態、却下理由です。下書き編集前の本文は保存しません。
過去の本文まで再現する必要がある場合は、本文の版を保存するテーブルなどを追加してください。操作履歴があるだけで、すべての変更内容を復元できるわけではありません。
ファイルの役割を決める
シリーズでは、次の単位でコードを分けます。
- backend
- Approvals
- Approval.cs
- ApprovalStore.cs
- ApprovalService.cs
- ApprovalService.Decisions.cs
- ApprovalsController.cs
- ApprovalsController.Decisions.cs
- CsrfController.cs
- Approvals
- frontend
- src
- features
- approvals
- approvalApi.ts
- ApprovalPage.tsx
- approvals
- features
- src
コントローラーはHTTPの受け渡し、サービスは操作の手順、保存層はDB接続とトランザクションを担当します。まずはこの3つを分ければ、画面表示の変更がDB処理へ混ざることを減らせます。
コードは既存のログイン機能へ組み込む例です。会員登録、所属管理、一覧画面は既存の機能を使います。保存層はインターフェースと実装に必要なSQL・手順を示し、DBドライバー固有の読み取り処理は省略します。掲載コードだけで起動するアプリ一式ではありません。
次は下書き保存と申請APIを作ります。状態変更を操作としてまとめる考え方は、状態と日時の更新をメソッドにまとめる方法でも説明しています。