DEV Community

kanta13jp1
kanta13jp1

Posted on

Flutter Supabase でNotionライクなチームワークスペースを実装した話 — RLS行レベルセキュリティで実現

ブログ下書き 2026-03-30 — チームワークスペース実装

タイトル案

  1. FlutterとSupabaseでNotionライクなチームワークスペースを実装した話
  2. 招待コード方式のチーム共有機能をSupabase RLSで安全に作る方法
  3. B2Bエンタープライズ対応の第一歩:チームワークスペース機能の設計と実装

投稿先候補

  • [x] Zenn (技術実装詳細)
  • [x] Qiita (Supabase RLS実践)
  • [ ] note (エッセイ風)
  • [ ] dev.to (英語)
  • [ ] Hashnode

本文下書き

はじめに

「自分株式会社」を個人向けのAI統合ライフマネジメントアプリとして構築してきましたが、
B2B・チームでの導入を見据えて、チームワークスペース機能を実装しました。

Notionのチームワークスペースに相当する機能を、FlutterWebとSupabaseで
RLS(行レベルセキュリティ)を活用して安全に構築した実装を解説します。

設計方針

チームワークスペースに必要なのは以下の3テーブル:

teams               -- チーム本体(名前・説明・招待コード)
team_memberships    -- チームメンバーの所属管理
team_shared_notes   -- チームへの共有ノート
Enter fullscreen mode Exit fullscreen mode

招待コード方式を選んだ理由

メール招待はResend APIが必要で実装が複雑。
招待コード(8文字ランダム英数字)方式なら:

  • オーナーがコードをSlackやLINEで送れる
  • メール設定不要で即導入可能
  • スパム招待リスクが低い

Supabase RLS 設計

-- チームはオーナーまたはメンバーだけが閲覧可能
CREATE POLICY "teams_select" ON teams FOR SELECT
  USING (
    owner_id = auth.uid() OR
    EXISTS (
      SELECT 1 FROM team_memberships tm
      WHERE tm.team_id = teams.id AND tm.user_id = auth.uid()
    )
  );
Enter fullscreen mode Exit fullscreen mode

ポイントは EXISTS サブクエリで team_memberships を結合 することです。
これにより「そのチームのメンバー」だけがチーム情報を閲覧できます。

共有ノートのRLSも同様に:

CREATE POLICY "team_shared_notes_select" ON team_shared_notes FOR SELECT
  USING (
    shared_by = auth.uid() OR
    EXISTS (
      SELECT 1 FROM team_memberships tm
      WHERE tm.team_id = team_shared_notes.team_id AND tm.user_id = auth.uid()
    ) OR
    EXISTS (
      SELECT 1 FROM teams t
      WHERE t.id = team_shared_notes.team_id AND t.owner_id = auth.uid()
    )
  );
Enter fullscreen mode Exit fullscreen mode

Flutter 実装

TeamWorkspacePageTabController で「自分のチーム」「参加中」の2タブ構成。

招待コードのコピー機能:

InkWell(
  onTap: () {
    Clipboard.setData(ClipboardData(text: inviteCode));
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('招待コードをコピーしました')),
    );
  },
  child: const Icon(Icons.copy, size: 16, color: Colors.grey),
),
Enter fullscreen mode Exit fullscreen mode

詰まったポイント

Supabase の select('*, nested(*)') の型推論

// ❌ dynamic_calls エラー
final ownedIds = (owned as List).map((t) => t['id']).toSet();

// ✅ 明示的型変換
final ownedList = List<Map<String, dynamic>>.from(owned as List);
final ownedIds = ownedList.map((t) => t['id'] as String).toSet();
Enter fullscreen mode Exit fullscreen mode

Dartの flutter analyzeavoid_dynamic_calls ルールに引っかかるため、
Supabaseのレスポンスは必ず List<Map<String, dynamic>> に変換してから使います。

比較ページSEO改善も同時実装

14社の競合比較ページ(/vs-notion/vs-amazon)に個別のOGPメタタグを追加。
index.html のSEOシェルスクリプトを拡張し、URLパスに応じて動的に
og:title / og:description を切り替えるようにしました。

var vsMatch = path.match(/\/vs-([a-z0-9\-]+)$/);
if (vsMatch) {
  var competitor = vsMatch[1];
  var meta = competitorMeta[competitor];
  if (meta) {
    document.title = meta.title;
    setMeta('og:title', meta.title);
    setMeta('og:description', meta.desc);
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

まとめ

  • チームワークスペース: Supabase RLS + 招待コード方式で安全な共有基盤を構築
  • 比較ページOGP: 14社分の個別OGPをJSで動的切り替え
  • flutter analyze 0件維持: avoid_dynamic_calls に注意してSupabaseレスポンスを型変換

URL: https://my-web-app-b67f4.web.app/team-workspace

FlutterWeb #Supabase #buildinpublic #チームワーク #RLS

Top comments (0)