ブログ一覧に戻る
SaaS設計通知UX

PendingActions: 通知システムとは別レイヤーで強制アクションを管理する設計

はじめに

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発行タイミングUIblocksAppUsage
terms_acceptance規約改定時に全アクティブユーザに発行同意画面true
security_lockout不正アクセス検知 / MFA リセット要求MFA 再設定 + パスワード変更true
billing_grace_expiring課金失敗の猶予期間が残り 3 日以内サブスク復旧モーダル (dismiss 不可)true (オーナーのみ)
device_re_verificationデバイス revoke 後の再ログイン時デバイス認証画面true
mfa_requiredMFA 必須化したスペース参加直後のメンバー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_acceptanceaccount.legal (メール) + Push
security_lockoutaccount.security (メール + Push、bypassesQuietHours)
billing_grace_expiringws.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 関数で集約管理することをおすすめします。