はじめに
SaaS を運用していると、以下のような 業務継続に直結する強制アクション が必要になります:
- 規約・プライバシーポリシー変更時の再同意
- 課金失敗時の支払い方法更新 (オーナーのみ)
- 不正アクセス検知後の強制 2FA 再設定
- デバイス削除後の再認証
これらをすべて「通知システム (メール / Push / アプリ内 inbox)」だけで賄おうとすると メール埋もれ問題 が発生します。重要なものほど見逃されてしまいます。一方で「強制画面」だけだと、ログインしないユーザーには届かないままになります。
niyase では 通知システム と PendingActions を別レイヤーに分け、両者を補完させる設計に落ち着きました。本記事ではその責任分離の判断と実装パターンを共有します。
なぜ別レイヤーか
通知システム (notification system) の設計原則:
- オプショナル性: ユーザーが「このカテゴリは要らない」と OFF にできる (本人制御の原則)
- 冗長化前提: メールが届かないことを前提に、Push と inbox で多重化
- 「気づかせる」が目的: 読まれなくても、無視されてもよい
これに「対応するまで使わせない」を混ぜると:
- 強制 ON カテゴリが増殖 → 本人制御の原則が形骸化
- 判定タイミングがズレる (メール受信箱で気づく時刻 vs アプリで対応する時刻)
- 複雑さが急増 (通知の重複抑止ロジックと強制度ロジックが絡む)
よって、通知システムは「届ける / 気づかせる」、PendingActions は「強制する」と責任を分離 しました。
適用範囲 (PendingActions に乗せるべきもの)
| ✅ PendingActions に載せる | ❌ 通知のみで OK |
|---|---|
| 規約改定の同意 | 新機能のお知らせ |
| プライバシーポリシー変更 | 操作ガイドの紹介 |
| 緊急セキュリティ (強制 2FA 再設定) | 通常ログイン通知 |
| 課金失敗のオーナー確認 (猶予期間切れ間近) | 通常のサブスク更新通知 |
| サービス重大変更 (プラン廃止) | メンテナンス予告 |
判断基準: 「法令遵守または契約遵守上、ユーザの能動的な確認・操作が必要なケース」のみ。利便性向上目的の通知は対象外。
データモデル
// Central DB
export const pendingActions = pgTable("pending_action", {
id: uuid("id")
.primaryKey()
.default(sql`uuidv7()`),
userId: uuid("user_id").references(() => users.id),
workspaceId: uuid("workspace_id"), // スペース固有のとき (例: billing_grace_expiring)
type: varchar("type", { length: 32 }), // 'terms_acceptance' 等
status: varchar("status", { length: 16 }), // open | snoozed | resolved | expired
payload: jsonb("payload"), // 種別固有データ (規約 version 等)
blocksAppUsage: boolean("blocks_app_usage"),
dismissible: boolean("dismissible"),
snoozedUntil: timestamp("snoozed_until"),
dueAt: timestamp("due_at"), // dismissible=false に昇格するタイミング
resolvedAt: timestamp("resolved_at"),
resolvedBy: varchar("resolved_by"), // user | admin | auto
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
ポイント:
userId必須 (誰の pending か)workspaceId任意 (スペーススコープのとき)payload自由形式 (規約 version, billing account id 等)blocksAppUsage+dismissibleの組み合わせで強制度を表現dueAtで「いつから強制になるか」を明示
3 段階の強制度
1. blocksAppUsage=true, dismissible=false
→ 対応するまでアプリの全画面に進めない
例: 規約同意・緊急セキュリティ
2. blocksAppUsage=true, dismissible=true (snooze 可)
→ 一定期間まで dismiss 可、期限後は 1 に昇格
例: 課金失敗の予告 (最初の 7 日は snooze 可、残り 3 日で強制)
3. blocksAppUsage=false
→ バナー表示のみ。機能制限はあるが操作はできる
例: 過去データ同意未取得
dueAt 経過時に dismissible=false に自動昇格させることで、「最初は穏やかに、徐々に強制」を自然に表現できます。
6 type の使い分け
| type | 発行タイミング | UI | blocksAppUsage |
|---|---|---|---|
terms_acceptance | 規約改定時に全アクティブユーザに発行 | 同意画面 | true |
security_lockout | 不正アクセス検知 / MFA リセット要求 | MFA 再設定 + パスワード変更 | true |
billing_grace_expiring | 課金失敗の猶予期間が残り 3 日以内 | サブスク復旧モーダル (dismiss 不可) | true (オーナーのみ) |
device_re_verification | デバイス revoke 後の再ログイン時 | デバイス認証画面 | true |
mfa_required | MFA 必須化したスペース参加直後のメンバー | MFA 設定画面 | true (該当スペースのみ) |
legacy_data_consent | 個人データの取り扱い変更に伴う同意 | 同意画面 | false (dismiss 可・機能制限) |
Guard / Gate パターン (ログイン後の強制リダイレクト)
ユーザがログイン
↓
[AuthGuard] 通過
↓
[PendingActionsGate] が API GET /me/pending-actions を呼ぶ
├─ blocksAppUsage=true の未解消アクション あり
│ ↓
│ /pending-actions/:id に強制リダイレクト
│ ↓
│ ユーザが対応 → POST /me/pending-actions/:id/resolve
│ ↓
│ 元のページに復帰
│
└─ なし → 通常のページに進む
API リクエストの場合も同様で、未解消必須アクションがあれば 409 + pendingActionId を返し、frontend は 409 を catch して専用画面に遷移します。
通知システムとの連携
PendingActions と 通常の通知も併発 させます:
| PendingAction | 同時に発火する通知カテゴリ |
|---|---|
terms_acceptance | account.legal (メール) + Push |
security_lockout | account.security (メール + Push、bypassesQuietHours) |
billing_grace_expiring | ws.billing.failure (オーナー宛、強制 ON) |
これにより:
- メールが届く → ユーザが「何かあった」と気づく
- アプリに戻ったとき PendingActions が強制 → 確実に対応
通知だけだと「メール埋もれて気づかなかった」、PendingActions だけだと「ログインしないと気づかない」になる。両者で補完します。
自動 expire / dedup ロジック
Service 側で 2 つの自動処理を実装:
snoozed → open 自動昇格
async listActive(userId: string) {
// snoozedUntil <= now の row を open に戻す
await db.update(pendingActions)
.set({ status: "open", snoozedUntil: null })
.where(and(
eq(pendingActions.userId, userId),
eq(pendingActions.status, "snoozed"),
lte(pendingActions.snoozedUntil, new Date()),
));
// ... 取得
}
dueAt 経過時に dismissible=false 強制
await db
.update(pendingActions)
.set({ dismissible: false })
.where(
and(
eq(pendingActions.dismissible, true),
lte(pendingActions.dueAt, new Date()),
),
);
dedup (同じ規約版で重複 issue を避ける)
async issue(input) {
const dedupKey = this.dedupKey(input.type, input.payload);
// terms_acceptance なら payload.documentType, billing_grace_expiring なら payload.billingAccountId
if (dedupKey) {
const existing = await this.findExistingOpen(input.userId, input.type, dedupKey);
if (existing) return existing; // 既存を返す (二重 issue しない)
}
return this.db.insert(...).returning();
}
これにより、規約改定を 2 回 trigger しても 1 ユーザーに対して terms_acceptance の row は 1 個しか作られません。
未解決の論点 (継続検討)
- 多端末同時対応: 複数デバイスで同じ PendingAction に同時対応した場合の競合
- オフライン desktop / mobile: ローカルファースト環境では PendingAction を強制できない (同期復帰時にチェック)
- 通知と PendingActions の dedup: 同じ規約変更で
account.legal通知と PendingAction が両方届く重複感 - 国際化: 規約変更時の言語別表示
- 削除アカウントの扱い:
deletedAtセット時に未解消の PendingActions を cascade で消すか
これらは β 期間中に運用しながら調整する想定です。
まとめ
| 責任 | システム | 設計原則 |
|---|---|---|
| 気づかせる | 通知システム | オプショナル + 冗長化 |
| 強制する | PendingActions | 強制 + 階層的強制度 + 自動 expire |
両者を分離することで、それぞれの設計原則を純化でき、複雑さの急増を回避できます。「通知でも強制でもある」というカテゴリを許すと、両者のロジックが絡み合い、保守が困難になります。
実装時は 6 type の payload key 設計を最初に決めておくと、dedup ロジックが整理しやすくなります。payload key (terms_acceptance=documentType, billing_grace_expiring=billingAccountId 等) は type ごとに 1 つに固定し、Service 内の helper 関数で集約管理することをおすすめします。