DEV Community

kanta13jp1
kanta13jp1

Posted on

Notion-Style Team Workspace in Flutter Web: Supabase RLS EXISTS Subquery + Invite Code Pattern

Notion-Style Team Workspace in Flutter Web: Supabase RLS EXISTS Subquery + Invite Code Pattern

What We Built

A team collaboration layer for 自分株式会社:

  • Team creation with invite codes (8-char alphanumeric)
  • Join via invite code (no email required)
  • Shared notes within a team
  • Supabase RLS enforces per-team isolation

Schema: 3 Tables

-- Teams
CREATE TABLE teams (
  id          uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  owner_id    uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  name        text NOT NULL,
  description text,
  invite_code text NOT NULL UNIQUE DEFAULT upper(substring(gen_random_uuid()::text, 1, 8)),
  created_at  timestamptz DEFAULT now()
);

-- Memberships
CREATE TABLE team_memberships (
  id         uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  team_id    uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  user_id    uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  role       text NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'member')),
  joined_at  timestamptz DEFAULT now(),
  UNIQUE (team_id, user_id)
);

-- Shared Notes
CREATE TABLE team_shared_notes (
  id          uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  team_id     uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  shared_by   uuid NOT NULL REFERENCES auth.users(id),
  note_id     uuid NOT NULL,
  shared_at   timestamptz DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

RLS: EXISTS Subquery Pattern

Teams are visible to owners AND members — the key is EXISTS on the membership table:

-- Users see teams they own OR are a member of
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

No JOIN in the policy — EXISTS is more efficient (short-circuits on first match).

Shared notes have a three-way policy (sharer, member, or team owner):

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

Why Invite Code Over Email Invite

Email Invite Invite Code
Setup required Resend API + email templates None
User experience Click link in email Share 8-char code via any channel
Spam risk High (email can be harvested) Low (code expires or is revoked)
Dev complexity ~2 hours ~20 minutes

The invite code is generated with:

DEFAULT upper(substring(gen_random_uuid()::text, 1, 8))
Enter fullscreen mode Exit fullscreen mode

8 uppercase alphanumeric characters from a UUID. Collision probability at 100 teams: negligible.


Flutter: One-Tap Copy for Invite Code

InkWell(
  onTap: () {
    Clipboard.setData(ClipboardData(text: team['invite_code']));
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Invite code copied')),
    );
  },
  child: const Icon(Icons.copy, size: 16, color: Colors.grey),
),
Enter fullscreen mode Exit fullscreen mode

The icon is a tap target — no separate "Copy" button. The snackbar confirms the copy.


avoid_dynamic_calls with Supabase List Responses

Supabase returns List<dynamic>. Accessing properties directly triggers avoid_dynamic_calls:

// WRONG — lint error
final ownedIds = (owned as List).map((t) => t['id']).toSet();

// CORRECT — explicit cast to typed list
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

List<Map<String, dynamic>>.from() converts List<dynamic> to a typed list. Every subsequent .map() call is then lint-clean.


Dynamic OGP for Competitor Comparison Pages

Same session: added per-competitor Open Graph tags to index.html. /vs-notion gets a different og:title and og:description than /vs-slack:

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

Flutter Web is a SPA — index.html is the entry point for all routes. JavaScript reading window.location.pathname and setting meta tags runs before the Flutter app boots, so crawlers see the correct OGP.


Try it: 自分株式会社

buildinpublic #Flutter #Supabase #Dart #security

Top comments (0)