Skip to content

Instantly share code, notes, and snippets.

@vqc1909a
Last active March 19, 2026 06:11
Show Gist options
  • Select an option

  • Save vqc1909a/29cf75ee6293c493cc1a9322e9c1b08e to your computer and use it in GitHub Desktop.

Select an option

Save vqc1909a/29cf75ee6293c493cc1a9322e9c1b08e to your computer and use it in GitHub Desktop.
TEAM_ACCOUNTS_JIRA_ROADMAP.md

Team Accounts - Jira Roadmap

Complete Jira roadmap for the Team Accounts feature. Contains everything needed to create and manage the issues manually.


Project Structure

  • Project: SCRUM (FanIQ One)
  • Epics: 2 (+ Epic 3 future)
  • Epic 1 Phase 1: 42 tasks — Dev 1 solo
  • Epic 1 Phase 2: 5 module reviews — Dev 1 + Dev 2 in parallel
  • Epic 2: 8 tasks — both devs (after Epic 1 ships)
  • Developers: Dev 1 (Phase 1 solo, Phase 2 P.1 + P.5), Dev 2 (Phase 2 P.2 + P.3 + P.4, Epic 2 frontend)

Delivery Phases

Phase 1 — Dev 1 solo (Steps 1–4)

Dev 2 is free for other product work while Phase 1 is in progress.

Step 1: Schema migrations (do first — everything blocks on this)
  |
  +-- Step 2: Team creation & invitations (parallel once Step 1 merged)
  |
  +-- Step 3: TeamContext, switcher & member management (parallel)
  |
  +-- Step 4: Link Accounts edge function changes (parallel, depends on 1.1 + 1.4)

Phase 2 — Dev 1 + Dev 2 in parallel (after Phase 1 merged)

Dev 1:                          Dev 2:
  P.1 Ad Deployment               P.2 Optimize Campaigns
  P.5 Link Accounts verify        P.3 Events Feed
                                  P.4 Media Library

Epic 2 — Both devs (after Phase 2 ships)

Dev 1 (backend):                Dev 2 (frontend):
  E2.1 RLS upgrades               E2.5 PermissionGate component
  E2.2 _shared/permissions.ts     E2.6 Apply gates to all routes
  E2.3 Team mgmt fn guards        E2.7 Nav items by role
  E2.4 Campaign/media fn guards   E2.8 Upgrade hasPermission()

Dependency Diagram

Step 1 (Schema migrations)
  |
  +----------------------------+----------------------------+
  |                            |                            |
Step 2                     Step 3                       Step 4
(Invitations)              (TeamContext +            (Link Accounts
                            Switcher +                edge fn changes)
                            Member Mgmt)
  |                            |                            |
  +----------------------------+----------------------------+
                               |
                  Phase 2 (Dev 1 + Dev 2 parallel)
                  P.1  P.2  P.3  P.4  P.5
                               |
                           Epic 2
                  E2.1  E2.2  E2.3  E2.4  E2.5  E2.6  E2.7  E2.8

Developer Assignment Summary

Dev 1 Dev 2
All of Phase 1 (Steps 1–4, 41 tasks) Phase 2: P.2 (Optimize), P.3 (Events), P.4 (Media)
Phase 2: P.1 (Ad Deployment), P.5 (Link Accounts verify) Epic 2: E2.5, E2.6, E2.7, E2.8
Epic 2: E2.1, E2.2, E2.3, E2.4

Epic 1: Team Workspaces & Collaboration (Full Access)

Labels: team-accounts

Goal: Any invited team member can do everything the team owner can do across all product pages. Delivered in two phases.

  • Phase 1 (Dev 1 solo): Build the entire team infrastructure — schema, invitations, TeamContext, switcher, member management, and Link Accounts edge function changes.
  • Phase 2 (Dev 1 + Dev 2): Review and fix every product module so it works correctly in both team and personal workspace.

RLS policies check team membership only (no role conditions in Epic 1). TeamContext.hasPermission() always returns true. Roles are stored in team_members so Epic 2 is a smooth upgrade with no data migration.

Note: The audit of existing infrastructure was already completed. Tables to keep & modify: teams, team_members, team_invitations, account_mappings, active_audiences. All legacy tables dropped via migrations. Full details in docs/LEGACY_TEAM_ACCOUNTS_DISADVANTAGES.md.

Edge functions to deprecate (delete file + remove from config.toml): team-invite (multi-action router — replaced by separate functions in S2), accept-team-invitation-complete, create-team-user, create-team-sub-account, join-team-with-code (old), get-team-access-context. Frontend to deprecate: TeamLogin.tsx.


Epic 1 — Phase 1: Team Infrastructure (Dev 1 solo)

Story S1: Schema Migrations

Owner: Dev 1 | No blockers (start immediately) Labels: team-accounts, phase-1 Implementation plan ref: Steps 1.1–1.10

Description

Create Supabase migrations to update team-related schema. Must be merged first — all other Phase 1 stories depend on these changes.

Agency model: The owner is the one person with platform credentials. They manage multiple team workspaces (clients), each with different ad accounts selected. meta_selected_accounts, tiktok_selected_accounts, snapchat_selected_accounts now have team_id to support this: NULL = personal selections, team_x = that team's selections.

Migration 1: Clean up teams table

-- Step 1: Add new columns first (before dropping team_id so we can copy data)
ALTER TABLE teams
  ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '',
  ADD COLUMN IF NOT EXISTS description TEXT;

-- Step 2: Copy existing display name from team_id → name (preserves data)
UPDATE teams SET name = team_id WHERE name = '';

-- Step 3: Drop old RLS policies that reference user_id BEFORE dropping the column
-- (Postgres blocks column drops when policies depend on them)
DROP POLICY IF EXISTS "Users can create their own teams" ON teams;
DROP POLICY IF EXISTS "Users can delete their own teams" ON teams;
DROP POLICY IF EXISTS "Users can update their own teams" ON teams;
DROP POLICY IF EXISTS "Users can view their own teams" ON teams;

-- Step 4: Drop redundant columns
ALTER TABLE teams
  DROP COLUMN IF EXISTS user_id,
  DROP COLUMN IF EXISTS account_mapping_ids,  -- replaced by team_id FK on account_mappings
  DROP COLUMN IF EXISTS member_count,          -- derive from COUNT query
  DROP COLUMN IF EXISTS team_id;               -- was the display name, now in name column

Final teams structure: id, name, description, owner_id, invite_code, created_at, updated_at

Migration 2: Update team_members role constraint

-- Step 1: Drop old constraints
-- role_check: old enum was ('owner','admin','member') — being replaced
-- user_id_team_id_key: exists with column order (user_id, team_id) — replacing with (team_id, user_id)
--   for better team-scoped query performance
ALTER TABLE team_members
  DROP CONSTRAINT IF EXISTS team_members_role_check;

ALTER TABLE team_members
  DROP CONSTRAINT IF EXISTS team_members_user_id_team_id_key;

-- Step 2: Migrate existing role values to new enum BEFORE adding constraint
-- (constraint addition will fail if any rows contain invalid values)
-- 'owner' and 'admin' → 'admin' (owner is now tracked via teams.owner_id)
UPDATE team_members
  SET role = 'admin'
  WHERE role IN ('owner', 'admin');

-- 'member' and any other unknown values → 'contributor'
UPDATE team_members
  SET role = 'contributor'
  WHERE role NOT IN ('admin', 'manager', 'contributor', 'read_only');

-- Step 3: Add new constraints
ALTER TABLE team_members
  ADD CONSTRAINT team_members_role_check
  CHECK (role IN ('admin', 'manager', 'contributor', 'read_only'));

ALTER TABLE team_members
  ADD CONSTRAINT team_members_team_user_unique
  UNIQUE (team_id, user_id);

Migration 3: Update team_invitations constraints

-- Step 1: Drop old constraints
-- role_check: old enum was ('admin','member','viewer') — being replaced
-- status_check: status enum already matches ('pending','accepted','expired','cancelled') — safe to replace
-- team_id_email_key: UNCONDITIONAL unique on (team_id, email) — must drop and replace with
--   a PARTIAL index (WHERE status='pending') so expired/cancelled invitations allow re-invite
ALTER TABLE team_invitations
  DROP CONSTRAINT IF EXISTS team_invitations_role_check;

ALTER TABLE team_invitations
  DROP CONSTRAINT IF EXISTS team_invitations_status_check;

ALTER TABLE team_invitations
  DROP CONSTRAINT IF EXISTS team_invitations_team_id_email_key;

-- Step 2: Migrate existing role values to new enum BEFORE adding constraint
-- 'admin' stays; 'owner' (if any) → 'admin'
UPDATE team_invitations
  SET role = 'admin'
  WHERE role IN ('owner', 'admin');

-- 'viewer' (old read-only role) → 'read_only'
UPDATE team_invitations
  SET role = 'read_only'
  WHERE role = 'viewer';

-- 'member' and any other unknown values → 'contributor'
UPDATE team_invitations
  SET role = 'contributor'
  WHERE role NOT IN ('admin', 'manager', 'contributor', 'read_only');

UPDATE team_invitations
  SET status = 'cancelled'
  WHERE status NOT IN ('pending', 'accepted', 'expired', 'cancelled');

-- Step 3: Add new constraints
ALTER TABLE team_invitations
  ADD CONSTRAINT team_invitations_role_check
  CHECK (role IN ('admin', 'manager', 'contributor', 'read_only'));

ALTER TABLE team_invitations
  ADD CONSTRAINT team_invitations_status_check
  CHECK (status IN ('pending', 'accepted', 'expired', 'cancelled'));

-- Partial unique index: only one PENDING invite per (team_id, email)
-- Allows re-inviting after accepted/expired/cancelled
CREATE UNIQUE INDEX IF NOT EXISTS idx_team_invitations_pending_unique
  ON team_invitations(team_id, email)
  WHERE status = 'pending';

Migration 4: Add team_id FK on account_mappings and selected accounts tables

ALTER TABLE account_mappings
  ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;

CREATE INDEX IF NOT EXISTS idx_account_mappings_team_id
  ON account_mappings(team_id)
  WHERE team_id IS NOT NULL;

-- Selected accounts: owner can have different ad accounts per workspace
ALTER TABLE meta_selected_accounts
  ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;

ALTER TABLE tiktok_selected_accounts
  ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;

ALTER TABLE snapchat_selected_accounts
  ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;

CREATE INDEX IF NOT EXISTS idx_meta_selected_accounts_team_id
  ON meta_selected_accounts(user_id, team_id);

CREATE INDEX IF NOT EXISTS idx_tiktok_selected_accounts_team_id
  ON tiktok_selected_accounts(user_id, team_id);

CREATE INDEX IF NOT EXISTS idx_snapchat_selected_accounts_team_id
  ON snapchat_selected_accounts(user_id, team_id);

-- Update unified_selected_accounts VIEW to expose team_id
-- Must use CREATE OR REPLACE VIEW (ALTER VIEW ... AS is NOT valid PostgreSQL)
-- Note: tiktok_selected_accounts uses advertiser_id/advertiser_name column names
CREATE OR REPLACE VIEW unified_selected_accounts WITH (security_invoker = on) AS
  SELECT 'TikTok' AS platform, t.advertiser_id AS account_id, t.advertiser_name AS name, t.user_id, t.team_id
  FROM tiktok_selected_accounts t
UNION ALL
  SELECT 'Snapchat' AS platform, s.account_id, s.name, s.user_id, s.team_id
  FROM snapchat_selected_accounts s
UNION ALL
  SELECT 'Meta' AS platform, m.account_id, m.name, m.user_id, m.team_id
  FROM meta_selected_accounts m;

NULL = personal workspace. Set = team workspace. Existing data stays NULL (unaffected). This allows the owner (the one person with platform credentials) to have different ad accounts activated per team — e.g., Client A's Meta ad account in Team A, Client B's in Team B, all from a single OAuth connection.

Migration 5: Add account_mapping_id on active_audiences

ALTER TABLE active_audiences
  ADD COLUMN IF NOT EXISTS account_mapping_id UUID REFERENCES account_mappings(id);

-- NOTE: active_audiences.group_campaign_id is TEXT; campaigns.campaign_group_id is UUID
-- Cast UUID → text for the join (safe; always valid)
UPDATE active_audiences aa
SET account_mapping_id = c.account_mapping_id
FROM campaigns c
WHERE aa.group_campaign_id = c.campaign_group_id::text
  AND aa.account_mapping_id IS NULL;

CREATE INDEX IF NOT EXISTS idx_active_audiences_account_mapping
  ON active_audiences(account_mapping_id);

Migration 6: Add indexes

CREATE INDEX IF NOT EXISTS idx_team_members_user_team
  ON team_members(user_id, team_id);

Migration 7: RLS — teams, team_members, team_invitations, account_mappings

RLS policies use membership-only checks (no role conditions in Epic 1):

-- Enable RLS on teams (was never enabled — required before policies take effect)
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;

-- Note: "Users can create/delete/update/view their own teams" policies already
-- dropped in Migration 1 (required before dropping teams.user_id column)

-- Drop old single-owner policies on team_members (replaced below)
DROP POLICY IF EXISTS "Users can join teams" ON team_members;
DROP POLICY IF EXISTS "Users can view team memberships" ON team_members;
DROP POLICY IF EXISTS "Team owners can delete memberships or users can leave" ON team_members;
DROP POLICY IF EXISTS "Team owners can manage memberships" ON team_members;

-- Drop dangerous testing policy on team_invitations (allows ALL operations for ALL users)
DROP POLICY IF EXISTS "Allow all operations for testing" ON team_invitations;

-- Drop old duplicate teams SELECT policy
DROP POLICY IF EXISTS "Team members can view their teams" ON teams;

-- Drop old duplicate/overlapping account_mappings policies (replaced by new scoped policies below)
DROP POLICY IF EXISTS "Users access own mappings only" ON account_mappings;
DROP POLICY IF EXISTS "Users can access their own account mappings" ON account_mappings;
DROP POLICY IF EXISTS "Users can delete their own account mappings" ON account_mappings;
DROP POLICY IF EXISTS "Users can insert their own account mappings" ON account_mappings;
DROP POLICY IF EXISTS "Users can update their own account mappings" ON account_mappings;

-- Drop new policies too before re-creating (idempotent re-run safety)
DROP POLICY IF EXISTS "select_teams_for_members" ON teams;
DROP POLICY IF EXISTS "update_teams_for_admins" ON teams;
DROP POLICY IF EXISTS "select_team_members" ON team_members;
DROP POLICY IF EXISTS "select_account_mappings" ON account_mappings;
DROP POLICY IF EXISTS "insert_account_mappings" ON account_mappings;
DROP POLICY IF EXISTS "update_account_mappings" ON account_mappings;
DROP POLICY IF EXISTS "delete_account_mappings" ON account_mappings;

-- IMPORTANT: RLS policies on team_members CANNOT subquery team_members directly —
-- doing so causes "infinite recursion detected in policy for relation team_members".
-- This also breaks any other table's policy that subqueries team_members (e.g. account_mappings).
-- Fix: use a SECURITY DEFINER function that bypasses RLS for the membership lookup.
CREATE OR REPLACE FUNCTION get_my_team_ids()
RETURNS SETOF uuid
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public AS $$
  SELECT team_id FROM team_members WHERE user_id = auth.uid();
$$;

-- teams: members can read; owner can update
CREATE POLICY "select_teams_for_members" ON teams
  FOR SELECT USING (
    id IN (SELECT get_my_team_ids())
    OR owner_id = auth.uid()
  );

CREATE POLICY "update_teams_for_admins" ON teams
  FOR UPDATE USING (
    id IN (SELECT get_my_team_ids())
  );

-- team_members: members can read all rows in their teams; can always read own row
-- Uses get_my_team_ids() (SECURITY DEFINER) to avoid infinite recursion
CREATE POLICY "select_team_members" ON team_members
  FOR SELECT USING (
    team_id IN (SELECT get_my_team_ids())
    OR user_id = auth.uid()
  );

-- account_mappings: owner can manage their own; team members can read team mappings
-- NOTE: Migration 8 drops the old personal-only policies. These replacements must cover
-- all operations so existing personal users are not locked out.
CREATE POLICY "select_account_mappings" ON account_mappings
  FOR SELECT USING (
    user_id = auth.uid()
    OR team_id IN (SELECT get_my_team_ids())
  );

-- INSERT: personal users create their own mappings; team admins create team mappings
-- (team_id = NULL for personal, team_id = team's id for team mappings)
CREATE POLICY "insert_account_mappings" ON account_mappings
  FOR INSERT WITH CHECK (user_id = auth.uid());

-- UPDATE/DELETE: only the mapping owner (user_id) can modify their mappings
CREATE POLICY "update_account_mappings" ON account_mappings
  FOR UPDATE USING (user_id = auth.uid());

CREATE POLICY "delete_account_mappings" ON account_mappings
  FOR DELETE USING (user_id = auth.uid());

Migration 8: RLS — downstream tables (12 tables, Epic 1 membership-only)

For all 12 downstream tables (campaigns, media_files, active_creatives, facebook_creatives, snapchat_creatives, tiktok_creatives, temporary_audiences, temporary_exclusions, active_audiences, faniq_audiences_draft, campaign_drafts, campaign_payloads):

-- Enable RLS on tables that don't have it yet
-- (campaigns, media_files, active_creatives, facebook_creatives, snapchat_creatives,
--  tiktok_creatives, active_audiences, campaign_drafts, campaign_payloads already enabled)
ALTER TABLE temporary_audiences ENABLE ROW LEVEL SECURITY;
ALTER TABLE temporary_exclusions ENABLE ROW LEVEL SECURITY;
ALTER TABLE faniq_audiences_draft ENABLE ROW LEVEL SECURITY;
-- Template for 11 tables (all except faniq_audiences_draft):
-- SELECT: own data OR any team member can read via account_mapping chain
CREATE POLICY "select_own_or_team_data" ON [TABLE_NAME]
  FOR SELECT USING (
    user_id = auth.uid()
    OR account_mapping_id IN (
      SELECT am.id FROM account_mappings am
      JOIN team_members tm ON tm.team_id = am.team_id
      WHERE tm.user_id = auth.uid()
    )
  );

-- INSERT/UPDATE/DELETE: any team member (no role gate in Epic 1)
-- Epic 2 will add role conditions
CREATE POLICY "insert_own_or_team_data" ON [TABLE_NAME]
  FOR INSERT WITH CHECK (
    user_id = auth.uid()
    OR account_mapping_id IN (
      SELECT am.id FROM account_mappings am
      JOIN team_members tm ON tm.team_id = am.team_id
      WHERE tm.user_id = auth.uid()
    )
  );

CREATE POLICY "update_own_or_team_data" ON [TABLE_NAME]
  FOR UPDATE USING (
    user_id = auth.uid()
    OR account_mapping_id IN (
      SELECT am.id FROM account_mappings am
      JOIN team_members tm ON tm.team_id = am.team_id
      WHERE tm.user_id = auth.uid()
    )
  );

CREATE POLICY "delete_own_or_team_data" ON [TABLE_NAME]
  FOR DELETE USING (
    user_id = auth.uid()
    OR account_mapping_id IN (
      SELECT am.id FROM account_mappings am
      JOIN team_members tm ON tm.team_id = am.team_id
      WHERE tm.user_id = auth.uid()
    )
  );
-- ⚠️ EXCEPTION: faniq_audiences_draft
-- Has NO user_id column — uses created_by (uuid) instead.
-- Also: account_mapping_id is TEXT not UUID — requires ::uuid cast.
-- Replace user_id = auth.uid() with created_by = auth.uid() on all 4 policies.
CREATE POLICY "select_own_or_team_data" ON faniq_audiences_draft
  FOR SELECT USING (
    created_by = auth.uid()
    OR account_mapping_id::uuid IN (
      SELECT am.id FROM account_mappings am
      JOIN team_members tm ON tm.team_id = am.team_id
      WHERE tm.user_id = auth.uid()
    )
  );

CREATE POLICY "insert_own_or_team_data" ON faniq_audiences_draft
  FOR INSERT WITH CHECK (
    created_by = auth.uid()
    OR account_mapping_id::uuid IN (
      SELECT am.id FROM account_mappings am
      JOIN team_members tm ON tm.team_id = am.team_id
      WHERE tm.user_id = auth.uid()
    )
  );

CREATE POLICY "update_own_or_team_data" ON faniq_audiences_draft
  FOR UPDATE USING (
    created_by = auth.uid()
    OR account_mapping_id::uuid IN (
      SELECT am.id FROM account_mappings am
      JOIN team_members tm ON tm.team_id = am.team_id
      WHERE tm.user_id = auth.uid()
    )
  );

CREATE POLICY "delete_own_or_team_data" ON faniq_audiences_draft
  FOR DELETE USING (
    created_by = auth.uid()
    OR account_mapping_id::uuid IN (
      SELECT am.id FROM account_mappings am
      JOIN team_members tm ON tm.team_id = am.team_id
      WHERE tm.user_id = auth.uid()
    )
  );

Also drop legacy squad + sub-account system and duplicate policies in the same migration:

-- Drop squad RLS policies on other tables
DROP POLICY IF EXISTS "Squad members can access admin account mappings" ON account_mappings;
DROP POLICY IF EXISTS "Squad members can access admin active creatives" ON active_creatives;
DROP POLICY IF EXISTS "Squad members can access admin campaigns" ON campaigns;
DROP POLICY IF EXISTS "Squad members can access admin media files" ON media_files;
DROP POLICY IF EXISTS "Squad members can access admin meta tokens" ON meta_tokens;
DROP POLICY IF EXISTS "Squad members can access admin snapchat tokens" ON snapchat_tokens;
DROP POLICY IF EXISTS "Squad members can access admin tiktok tokens" ON tiktok_tokens;

-- Drop squad table policies
DROP POLICY IF EXISTS "Allow squad invitation joins" ON squad_members;
DROP POLICY IF EXISTS "Squad admins can add members" ON squad_members;
DROP POLICY IF EXISTS "Squad admins can manage squad members" ON squad_members;
DROP POLICY IF EXISTS "Squad admins can remove members" ON squad_members;
DROP POLICY IF EXISTS "Squad admins can view all members of their squads" ON squad_members;
DROP POLICY IF EXISTS "Squad members can view their own memberships" ON squad_members;
DROP POLICY IF EXISTS "Squad members can view their squads" ON squads;
DROP POLICY IF EXISTS "Squad admins can delete their squads" ON squads;
DROP POLICY IF EXISTS "Squad admins can manage their own squads" ON squads;
DROP POLICY IF EXISTS "Squad admins can update their squads" ON squads;
DROP POLICY IF EXISTS "Users can create their own squads" ON squads;
DROP POLICY IF EXISTS "Users can join squads" ON squad_members;
DROP POLICY IF EXISTS "Users can view squad members of squads they belong to" ON squad_members;
DROP POLICY IF EXISTS "Users can view squads they admin or are members of" ON squads;
DROP POLICY IF EXISTS "Users can view their own squad memberships" ON squad_members;

-- Drop squad functions and tables (squad_members first due to FK)
DROP FUNCTION IF EXISTS get_squad_admin_ids(uuid);
DROP FUNCTION IF EXISTS get_user_squad_ids(uuid);
DROP FUNCTION IF EXISTS get_user_squads(uuid);
DROP FUNCTION IF EXISTS is_squad_admin(uuid, uuid);
DROP TABLE IF EXISTS squad_members;
DROP TABLE IF EXISTS squads;

-- Drop legacy sub-account functions and tables (order matters for FK)
DROP FUNCTION IF EXISTS get_parent_user_id(uuid);
DROP FUNCTION IF EXISTS has_mapping_access(uuid, uuid, text);
DROP FUNCTION IF EXISTS has_platform_access(uuid, uuid, text);
DROP FUNCTION IF EXISTS is_sub_account(uuid);
DROP TABLE IF EXISTS sub_account_mapping_access;
DROP TABLE IF EXISTS sub_account_platform_access;
DROP TABLE IF EXISTS sub_account_invitations;
DROP TABLE IF EXISTS sub_accounts;

-- Drop duplicate/legacy account_mappings policies
DROP POLICY IF EXISTS "Users access own mappings only" ON account_mappings;
DROP POLICY IF EXISTS "Users can access their own account mappings" ON account_mappings;
DROP POLICY IF EXISTS "Users can delete their own account mappings" ON account_mappings;
DROP POLICY IF EXISTS "Users can insert their own account mappings" ON account_mappings;
DROP POLICY IF EXISTS "Users can update their own account mappings" ON account_mappings;

-- Drop wide-open temporary_audiences INSERT policy (security fix)
DROP POLICY IF EXISTS "Allow authenticated users to insert into temporary_audiences" ON temporary_audiences;

Acceptance Criteria

  • All 8 migrations run cleanly on fresh and existing databases
  • Existing account_mappings data unaffected (team_id defaults to NULL)
  • Role enums: admin, manager, contributor, read_only
  • Existing team_members rows with role='owner'/'member' migrated to 'admin'/'contributor' before constraint added
  • Existing team_invitations rows with old role/status values migrated before constraints added
  • UNIQUE constraint on team_members(team_id, user_id)
  • active_audiences.account_mapping_id backfilled from linked campaigns
  • teams.account_mapping_ids, user_id, member_count, team_id (TEXT) columns dropped; name and description columns added; existing team names copied from team_idname before drop (Step 2 and Step 4 depend on this)
  • No existing meta_access_token, tiktok_access_token, snapchat_access_token columns to add — tokens stay in their own tables
  • meta_selected_accounts, tiktok_selected_accounts, snapchat_selected_accounts have team_id column added; existing rows stay NULL (personal)
  • unified_selected_accounts VIEW updated to include team_id column (using CREATE OR REPLACE VIEW, not ALTER VIEW)
  • Legacy squad + sub-account tables, policies, and functions fully removed
  • All duplicate account_mappings policies dropped
  • Wide-open temporary_audiences INSERT policy dropped
  • Downstream table RLS allows any team member to read/write (no role gate, Epic 1)
  • generate_team_invite_code RPC: already tracked in 20251127173352_remote_schema.sql — no action needed

Ref: docs/TEAM_ACCOUNTS_IMPLEMENTATION_PLAN.md — Steps 1.1–1.10


Story S2: Team Creation & Invitations ✅ Complete

Owner: Dev 1 | Blocked by: Story S1 | Status: ✅ Done Labels: team-accounts, phase-1 Implementation plan ref: Steps 2.0–2.16

Description

Complete invitation lifecycle: edge functions (create team, invitations CRUD, email), frontend invitation form, pending invitations list, and accept/decline page. All 18 tasks completed (2.0–2.16 + 2.11b).

Edge Functions (Tasks 2.0–2.11b)

2.0 Deprecate team-invite — The existing team-invite multi-action edge function (action: 'send_invitation' | 'accept_invitation') is fully replaced by the separate functions in tasks 2.4 and 2.6. Delete supabase/functions/team-invite/ and remove its entry from supabase/config.toml. Also delete accept-team-invitation-complete, create-team-user, create-team-sub-account, get-team-access-context (all replaced). Do this after the new functions are working.

2.1 create-team — Three fixes required. (a) Fix Deno import version. (b) Fix owner inserted into team_members with role='owner'invalid enum value (valid: admin, manager, contributor, read_only). Fix: change to role='admin'. (c) Fix teams INSERT: current code does { team_id: team_name } — after migration 1.1, teams.team_id (TEXT display name column) is dropped and teams.name is added, so this must become { name: team_name }. Also fix response that returns team.team_idteam.name. Hard dependency on migration 1.1. Exists but needs cleanup.

2.2 get-user-teams — Update to work with new schema after migration 1.1 drops account_mapping_ids, member_count, user_id, team_id (display name) columns from teams. Replace account_mapping_ids in response with account_mapping_count (COUNT from account_mappings WHERE team_id = team.id) and member_count (COUNT from team_members WHERE team_id = team.id). Hard dependency on migration 1.1. Exists but breaks after migration.

2.3 delete-team (new)

Body: { team_id: UUID }
Auth: Owner only (teams.owner_id)
Logic:
  1. Verify caller is the team owner
  2. Fetch all account_mapping IDs for the team
  3. Delete platform campaigns (facebook_campaigns, instagram_campaigns, snapchat_campaigns,
     tiktok_campaigns, meta_blended_campaigns) that belong to those mappings
  4. Delete blocking dependents with no CASCADE FK:
     active_creatives, facebook_creatives, snapchat_creatives, tiktok_creatives,
     media_files, active_audiences, video_renders, sync_queue
  5. Delete campaigns for those mappings
  6. Delete account_mappings (CASCADE handles remaining dependents)
  7. Delete team-scoped selected accounts (meta/tiktok/snapchat_selected_accounts WHERE team_id)
  8. Delete team (CASCADE removes team_members, team_invitations)

2.4 send-team-invitation — Four fixes: (a) Move hardcoded Resend API key to RESEND_API_KEY env var. (b) Add email validation, check for duplicate pending invite. (c) Fix teams query: currently does .select('team_id') and reads team.team_id as the team name — after migration 1.1 this returns undefined. Fix: .select('name')const teamName = team.name. (d) Fix email link URL: currently sends to /team-login?token=... — that route does not exist. Fix: send to /team-invite/${invitationToken}. Hard dependency on migration 1.1. Exists, has security issue.

2.5 send-invitation-email — Extract email sending into _shared/, called by send + resend functions. Logic exists inline, needs extraction.

2.6 accept-team-invitation (registered users)

Body: { invitation_token: UUID }
Auth: Required (invited user must be logged in)
Logic:
  1. Validate token, check status = 'pending' and not expired
  2. Verify auth user's email matches invitation email
  3. Insert into team_members with role from invitation
  4. Update invitation status to 'accepted'

2.7 accept-invite-and-register (unregistered users)

Body: { invitation_token: UUID, email: string, password: string }
Auth: None (public endpoint)
Logic (atomic):
  1. Validate token, status = 'pending', not expired
  2. Verify email matches invitation
  3. Create auth user (email_confirm: true, NO access code required)
  4. Create profile record
  5. Insert into team_members with role from invitation
  6. Update invitation status to 'accepted'
  7. On ANY failure: rollback all changes

2.8 decline-team-invitation — mark invitation cancelled 2.9 cancel-team-invitation — admin cancels a pending invite 2.10 list-team-invitations — return all invitations for a team (admin + manager) 2.11 resend-team-invitation — reset expires_at to +30 days, re-send email 2.11b validate-team-invitation (new — added during implementation) — public endpoint to validate a token and return invitation details for the frontend; always returns HTTP 200 so data is always populated (avoids FunctionsHttpError on the client side)

Frontend (Tasks 2.12–2.16)

2.12 /team-invite/:token — registered user flow

TeamInvite.tsx exists, needs wiring to accept-team-invitation. Show team name, role, inviter. Accept/Decline buttons.

2.13 /team-invite/:token — unregistered user flow (new)

Signup form: email pre-filled and locked, password + confirm password, no access code field. Calls accept-invite-and-register. Auto-signs user in and redirects to dashboard in team context.

Error states: expired token, already accepted, invalid token, email mismatch.

2.14 Invite form UI

Inside team management page. Email input + role dropdown (Admin, Manager, Contributor, Read-Only) + Send button. Calls send-team-invitation.

2.15 Pending invitations list UI

Table: email, role, sent date, status badges (Pending/Accepted/Expired/Cancelled). Cancel + Resend action buttons. Auto-refreshes after actions.

2.16 join-team-with-code (new edge function)

Look up team by invite_code, verify code exists and team is active, insert caller into team_members with default role contributor. generate_team_invite_code RPC and teams.invite_code column already exist. This is the code-based join path — distinct from the email invitation token flow. Note: Teams.tsx was deleted (it was built against the old schema); it will be rebuilt from scratch in task 3.11.

Acceptance Criteria

  • create-team creates team with name column (not old team_id TEXT column) + adds creator as admin in team_members
  • get-user-teams returns account_mapping_count and member_count (not account_mapping_ids) after migration 1.1
  • delete-team only accessible to team owner; cascades cleanup
  • send-team-invitation: Resend API key from env var, duplicate check, email validation; accessible to owner or admin
  • accept-invite-and-register: NO access code required, atomic operation with rollback
  • accept-team-invitation: email match verified before inserting team_members
  • Invitation email sent on create + resend; delivery failure doesn't block invitation creation
  • /team-invite registered flow: Accept/Decline buttons, redirect to dashboard in team context
  • /team-invite unregistered flow: email locked, no access code field, auto-login after registration
  • Invite form accessible to owner and admin only (hidden for Manager/Contributor/Read-Only)
  • Pending invitations list shows all statuses with correct color coding; Cancel (pending only) and Resend (pending/expired/cancelled) actions
  • validate-team-invitation (2.11b) public endpoint added; always HTTP 200 so client never hits FunctionsHttpError
  • join-team-with-code implemented; Teams.tsx deleted (will be rebuilt as fresh in 3.11)
  • Legacy team-invite, accept-team-invitation-complete, create-team-user, create-team-sub-account, get-team-access-context functions deleted and removed from config.toml

Ref: docs/TEAM_ACCOUNTS_IMPLEMENTATION_PLAN.md — Steps 2.0–2.16


Story S3: TeamContext, Switcher & Member Management

Owner: Dev 1 | Blocked by: Story S1 Labels: team-accounts, phase-1 Implementation plan ref: Steps 3.1–3.11

Description

Create TeamContext, team switcher dropdown, create team modal, all team management edge functions, and the team management page.

TeamContext (Task 3.1)

// src/context/TeamContext.tsx

interface TeamContextType {
  currentTeam: Team | null;               // null = personal mode
  userTeams: TeamMembership[];            // all teams user belongs to
  currentRole: string | null;             // null in personal mode
  isPersonalMode: boolean;
  isLoading: boolean;
  switchTeam: (teamId: string | null) => void;
  hasPermission: (action: string) => boolean;  // always true in Epic 1
  refreshTeams: () => Promise<void>;
}

Fetch teams on login via team_members JOIN teams WHERE user_id = auth.uid(). Default to Personal Account. Persist active team ID to localStorage. hasPermission() always returns true in Epic 1 (upgraded in Epic 2).

Team Switcher (Task 3.2)

Dropdown in DashboardNavbar.tsx: Personal Account option + list of teams with role badge + Create Team entry at bottom. Calls switchTeam() from TeamContext.

Create Team Modal (Task 3.3)

Opens from team switcher. Team name input (required, max 100 chars). Calls create-team. On success: refreshes team list, auto-switches to new team.

Edge Functions (Tasks 3.4–3.9)

3.4 get-team-membersget_team_members_simple RPC exists as fallback but edge function preferred.

Params: ?team_id=UUID
Returns: { members: [{ user_id, name, email, role, joined_at, is_owner }] }

3.5 remove-team-member — admin only, cannot remove owner 3.6 update-team-member-role — admin only, cannot change owner's role, validate role enum 3.7 leave-team — owner cannot leave (must transfer or delete) 3.8 update-team — rename team, admin only 3.9 assign-account-mapping-to-team — Removed from scope. Team workspaces start fresh. Owner connects OAuth in team context (creates new account_mapping with team_id set). No assign-from-personal flow needed.

# REMOVED — kept for reference only
Body: { account_mapping_id: UUID, team_id: UUID | null }
Auth: Admin only
Logic:
  1. Verify caller is admin of the team
  2. Verify account_mapping belongs to caller (user_id = auth.uid())
  3. Set account_mappings.team_id = team_id (null = return to personal)

Team Management Page (Task 3.10)

Route: /teams/:teamId/settings — consolidate TeamManagement.tsx, TeamManage.tsx, TeamDetail.tsx.

Tabs: Members (member list with role edit + remove), Invitations (invite form + pending list from S2), Settings (rename, invite code, delete team). Note: no Accounts tab — account linking is done via /link-accounts page (task 3.9 removed from scope).

Teams List Page (Task 3.11)

Route: /teamsTeams.tsx. Four fixes:

(a) Fix broken join-team-with-code call (function never existed), wire to new join-team-with-code edge function (task 2.16).

(b) Remove the loadAccountMappingNames helper which calls non-existent get-account-mapping-names edge function — after task 2.2, get-user-teams returns account_mapping_count (number) instead of account_mapping_ids (array), so the whole secondary fetch is gone.

(c) Wire Create Team button to create-team. List user's teams from get-user-teams. This is the main team entry point before the workspace switcher is available.

(d) Update TypeScript interfaces: Team interface must use name (not team_id), remove user_id/account_mapping_ids fields; TeamMembership.role must be "admin"|"manager"|"contributor"|"read_only" (not "owner"|"admin"|"member"). Hard dependency on migration 1.1.

Acceptance Criteria

  • TeamContext provides currentTeam, userTeams, switchTeam, isPersonalMode, hasPermission (always true)
  • Active team persisted to localStorage; restored on reload
  • Team switcher shows all teams + Personal Account + Create Team option
  • Role badge shown next to each team name in switcher
  • Switching team triggers re-fetch of all data queries
  • Create team modal with validation; auto-switches to new team on success
  • All 6 team management edge functions working and tested
  • Team management page at /teams/:teamId/settings with 3 tabs: Members, Invitations, Settings (no Accounts tab)
  • Team owner row has no action buttons (cannot be removed or have role changed)

Ref: docs/TEAM_ACCOUNTS_IMPLEMENTATION_PLAN.md — Steps 3.1–3.11


Story S4: Link Accounts Edge Function Changes

Owner: Dev 1 | Blocked by: Story S1 (specifically 1.1 + 1.4) Labels: team-accounts, phase-1 Implementation plan ref: Steps 4.1–4.4

Description

Fix the edge functions and frontend code involved in account linking and campaign deployment so they work correctly in team context. Must be done in Phase 1 because teams.account_mapping_ids is dropped in S1 (breaking get-accessible-account-mappings) and the deploy functions are currently broken for all users due to reading a non-existent column.

Task 4.1: Rewrite get-accessible-account-mappings

Four bugs to fix:

Bug (a): Currently does .in('id', teamData.account_mapping_ids)account_mapping_ids is dropped in S1 migration 1.1. Replace with SELECT * FROM account_mappings WHERE team_id = $team_id.

// Before (broken after migration 1.1):
const { data: teamData } = await supabase
  .from('teams').select('account_mapping_ids').eq('id', teamId).single();
const mappings = await supabase
  .from('account_mappings').select('*').in('id', teamData.account_mapping_ids);

// After:
const mappings = await supabase
  .from('account_mappings').select('*').eq('team_id', teamId);

Bug (b): Teams select currently includes team_id (TEXT display name, dropped in 1.1) and account_mapping_ids (dropped in 1.1):

// Before: .select('id, team_id, owner_id, account_mapping_ids')
// After:  .select('id, name, owner_id')

Bug (c): Function tries to read meta_access_token, tiktok_access_token, snapchat_access_token from account_mappings — these columns do not exist. Drop _ownerTokens from the response entirely (deploy functions query meta_tokens directly after task 4.3).

Bug (d): Response line _teamName: teamData.team_id reads the dropped TEXT display column. After the select fix in bug (b), change to _teamName: teamData.name.

Keep returning _teamOwnerId from teams.owner_id.

Task 4.2: meta-accounts, tiktok-accounts, snapchat-accounts — Edge function + frontend

These functions read from meta_selected_accounts / tiktok_selected_accounts / snapchat_selected_accounts to pre-populate checkboxes in the select-accounts UI (mark which accounts are already selected). Now that those tables have team_id, the functions must also scope by it.

Edge function changes — accept teamId as an optional body parameter:

// Before:
const { userId } = await req.json();
.from("meta_selected_accounts").select("account_id").eq("user_id", userId)

// After:
const { userId, teamId } = await req.json();
const query = supabase.from("meta_selected_accounts").select("account_id").eq("user_id", userId);
teamId ? query.eq("team_id", teamId) : query.is("team_id", null);

Apply the same fix to tiktok-accounts and snapchat-accounts.

Frontend changes in LinkAccounts.tsx and select-accounts pages — pass owner_id + teamId:

// Before:
{ userId: user.id }

// After:
{ userId: currentTeam ? currentTeam.owner_id : user.id, teamId: currentTeam?.id ?? null }

Critical fix in SelectAccounts.tsx, SelectTikTokAccounts.tsx, SelectSnapchatAccounts.tsx — the current delete-all-and-replace pattern wipes ALL selections for the user, destroying other workspaces' selections:

// Before (deletes ALL workspaces' selections — WRONG):
.delete().eq("user_id", user.id)

// After (scoped to current workspace only):
const deleteQuery = supabase.from("meta_selected_accounts").delete().eq("user_id", ownerId);
currentTeam ? deleteQuery.eq("team_id", currentTeam.id) : deleteQuery.is("team_id", null);

Also set team_id on insert:

selectedAccounts.map((account) => ({
  user_id: ownerId,
  account_id: account.account_id,
  name: account.name,
  team_id: currentTeam?.id ?? null,   // ← new
}))

Task 4.3: Fix deploy functions (deploy-meta-ads, deploy-tiktok-ads, deploy-snapchat-ads)

Two bugs — both affect all users today, not just team users:

Bug (a) — Token lookup column doesn't exist:

// Current (broken — column does not exist in schema or types):
.select("meta_access_token, meta_ad_account_id, name").eq("id", account_mapping_id)

// Fix — look up token from meta_tokens table:
// Step 1: fetch account_mapping to get user_id + ad_account_id
const { data: mapping } = await supabase
  .from('account_mappings')
  .select('user_id, meta_ad_account_id, name')
  .eq('id', account_mapping_id).single();

// Step 2: fetch token using account_mapping.user_id
// For team mappings: user_id = owner_id → automatically gets owner's token
const { data: tokenData } = await supabase
  .from('meta_tokens')
  .select('access_token')
  .eq('user_id', mapping.user_id).single();

Bug (b) — Dual user_id filter on active_creatives:

// Current (breaks for team members — creator's user_id ≠ owner_id):
.in("id", creative_ids).eq("user_id", user_id)

// Fix — scope by account_mapping_id only:
.in("id", creative_ids)
// RLS handles access; no user_id filter needed

Apply the same fixes to deploy-tiktok-ads and deploy-snapchat-ads.

Task 4.4: LinkAccounts.tsx + select-accounts pages — team context awareness

Five changes:

  1. Hide OAuth connect buttons for non-owners — OAuth connects a user's own platform account. Only the workspace owner can connect accounts. Check currentTeam?.owner_id === user.id before showing connect buttons.

  2. "Already connected" badge — in team mode, check account_mappings WHERE team_id = currentTeam.id instead of WHERE user_id = user.id.

  3. Account mapping creation — when creating new account_mappings in team mode, set user_id = owner_id, team_id = currentTeam.id (not user_id = current_user.id).

  4. Fetch existing mappings — in team mode, fetch account_mappings WHERE team_id = currentTeam.id instead of WHERE user_id = user.id.

  5. Selected accounts scoping — when saving to meta_selected_accounts / tiktok_selected_accounts / snapchat_selected_accounts, include team_id = currentTeam.id in team mode, team_id = NULL in personal mode. When reading selected accounts, always scope by (user_id, team_id). This is what allows the owner to have Client A's Meta account active in Team A and Client B's in Team B — from the same single OAuth connection. Depends on migration 1.9.

Acceptance Criteria

  • get-accessible-account-mappings uses team_id FK query (not account_mapping_ids array)
  • get-accessible-account-mappings teams select uses name (not dropped team_id TEXT or account_mapping_ids columns)
  • get-accessible-account-mappings _teamName uses teamData.name (not dropped teamData.team_id TEXT column)
  • No attempt to read meta_access_token, tiktok_access_token, snapchat_access_token from account_mappings
  • deploy-meta-ads (and tiktok/snapchat): token fetched from meta_tokens WHERE user_id = account_mapping.user_id
  • deploy-meta-ads (and tiktok/snapchat): active_creatives query no longer filters by user_id
  • Deploy functions work for personal workspace (confirm end-to-end, bug fix affects everyone)
  • meta-accounts, tiktok-accounts, snapchat-accounts: accept teamId parameter; scope selected accounts query by (user_id, team_id)
  • OAuth connect buttons hidden for non-owners in team mode
  • Account listing edge functions receive owner_id + teamId in team mode
  • New account_mappings created with user_id = owner_id, team_id = currentTeam.id in team mode
  • Selected accounts delete scoped to current workspace only (not all workspaces for user)
  • Selected accounts saved with team_id = currentTeam.id in team mode, team_id = NULL in personal mode
  • Switching from team to personal workspace shows correct pre-selected checkboxes for each
  • Personal mode behavior completely unchanged

Ref: docs/TEAM_ACCOUNTS_IMPLEMENTATION_PLAN.md — Steps 4.1–4.4


Epic 1 — Phase 2: Product Module Integration (Dev 1 + Dev 2 parallel)

Blocked by: All of Phase 1 merged

Goal: Every existing product module works correctly in both team workspace and personal workspace:

  • Data queries use TeamContext and filter by the team's account_mappings in team mode
  • Personal mode falls back to user_id filter with team_id IS NULL
  • Switching workspace re-fetches all data
  • No broken queries, missing data, or permission errors in either mode

Story P1: Ad Deployment — CreateCampaign & UnifiedCampaignBuilder

Owner: Dev 1 | Blocked by: Phase 1 Files: src/pages/CreateCampaign.tsx, src/pages/UnifiedCampaignBuilder.tsx, deploy-meta-ads, deploy-tiktok-ads, deploy-snapchat-ads Labels: team-accounts, phase-2

Description

Review and update the campaign creation and deployment flow to work correctly in team workspace. The deploy function bugs (token lookup + active_creatives dual filter) are already fixed in Phase 1 task 4.3. This story verifies the full end-to-end flow and fixes any remaining frontend query issues.

Key areas to review

  • CreateCampaign.tsx line ~241: .eq("user_id", user?.id) for fetching account mappings — needs team context. In team mode should fetch WHERE team_id = currentTeam.id; in personal mode WHERE user_id = me AND team_id IS NULL.
  • Account mapping selector must show team accounts when in team mode.
  • Campaign saved with correct team-scoped account_mapping_id.
  • UnifiedCampaignBuilder.tsx: same account mapping query pattern.
  • Token fetch lines (e.g. meta_tokens WHERE user_id = effectiveUserId) — will work automatically in team context since account_mapping.user_id = owner_id.
  • Campaign draft save/load scoped to correct workspace.
  • Workspace switch clears/re-fetches current campaign context.

Data scoping pattern

const { currentTeam } = useTeam();
const { user } = useEffectiveUser();

// Account mappings query
const query = supabase.from('account_mappings').select('*');
if (currentTeam) {
  query.eq('team_id', currentTeam.id);
} else {
  query.eq('user_id', user.id).is('team_id', null);
}

Acceptance Criteria

  • Account mapping selector shows team accounts in team mode, personal accounts in personal mode
  • Campaign created and deployed with the correct team-scoped account_mapping_id
  • Deploy functions work end-to-end in team workspace (owner's token used automatically)
  • Team members (non-owner) can create and deploy campaigns in team workspace
  • Personal workspace campaign creation completely unchanged
  • Workspace switch re-fetches account mappings and clears stale context
  • Campaign drafts scoped to the active workspace

Story P2: Optimize Campaigns

Owner: Dev 2 | Blocked by: Phase 1 Files: src/pages/Optimize.tsx Labels: team-accounts, phase-2

Description

Review and update the Optimize page to work correctly in team workspace. All metrics queries must filter by the team's account_mappings when in team mode. Workspace switch must trigger full data re-fetch.

Key areas to review

  • Account mapping queries: switch from user_id = me to team_id = currentTeam.id in team mode
  • Campaign metrics / insights queries scoped to team's mappings
  • Platform-specific data (Meta, TikTok, Snapchat) filtered by team account mappings
  • Workspace switch triggers re-fetch of all dashboard data

Acceptance Criteria

  • Metrics and campaign data show only the active workspace's data
  • Team members see all campaigns on shared account mappings
  • Personal workspace shows only personal data (unchanged)
  • Workspace switch refreshes all data on the page
  • No cross-workspace data visible

Story P3: Events Feed

Owner: Dev 2 | Blocked by: Phase 1 Files: src/pages/TixrEvents.tsx Labels: team-accounts, phase-2

Description

Review and update the Events feed to work correctly in team workspace. Event data must be scoped to the team's account_mapping_id in team mode.

Key areas to review

  • TixrEvents.tsx line ~448: .eq("user_id", user.id) for account mappings — needs team context
  • Event sync queries: already scope by account_mapping_id (.in("account_mapping_id", mappingIds)) — verify this works once the mapping IDs come from the team
  • Event detail views scoped correctly
  • Workspace switch refreshes event list

Acceptance Criteria

  • Events list shows only events linked to the active workspace's account mappings
  • Team members see all events on shared account mappings
  • Personal workspace shows only personal events (unchanged)
  • Workspace switch refreshes the events list
  • Event sync operations respect team context

Story P4: Media Library

Owner: Dev 2 | Blocked by: Phase 1 Files: src/pages/MediaLibrary.tsx Labels: team-accounts, phase-2

Description

Review and update the Media Library to work correctly in team workspace. Uploads and gallery must use the team-scoped account_mapping_id in team mode.

Key areas to review

  • MediaLibrary.tsx line ~70: .eq("user_id", user.id) for account mappings — needs team context
  • Media uploads: media_files record created with team-scoped account_mapping_id in team mode
  • Gallery filter: show all media on shared account mappings for team members
  • Workspace switch refreshes gallery

Acceptance Criteria

  • Gallery shows all media on shared account mappings in team mode
  • Media uploaded in team mode tagged with team-scoped account_mapping_id
  • Personal workspace shows only personal media (unchanged)
  • Workspace switch refreshes the gallery
  • Team members can upload and view shared media

Story P5: Link Ad Accounts — End-to-End Verification

Owner: Dev 1 | Blocked by: Phase 1 (specifically S4) Files: src/pages/LinkAccounts.tsx, src/pages/SelectAccounts.tsx Labels: team-accounts, phase-2

Description

The core edge function and backend changes for Link Accounts are implemented in Phase 1 task S4. This story verifies the full end-to-end flow works correctly and all personal workspace behavior is unchanged.

Verification checklist

  1. OAuth connect buttons correctly hidden for non-owners in team mode
  2. Account listing edge functions receive owner_id in team mode
  3. New account_mappings created with user_id = owner_id, team_id = currentTeam.id in team mode
  4. "Already connected" badge correctly checks team mappings in team mode
  5. Personal mode: connect buttons visible, user_id = user.id, team_id = NULL — completely unchanged

Integration test flows

Personal workspace flow:

  • User connects their Meta account → account_mappings created with user_id = user.id, team_id = NULL
  • User selects ad accounts → saved to meta_selected_accounts
  • Confirmed: personal behavior unchanged

Team workspace flow (as owner):

  • Owner connects Meta account in team workspace
  • account_mappings created with user_id = owner_id, team_id = currentTeam.id
  • OAuth connect buttons visible (owner)
  • Account selection saves correctly scoped to team

Team workspace flow (as non-owner):

  • Non-owner navigates to /link-accounts in team mode
  • OAuth connect buttons hidden
  • Existing team account mappings visible (can select ad accounts)
  • Cannot create new account_mappings

Acceptance Criteria

  • OAuth connect buttons hidden for non-owners in team mode
  • Account listing passes owner_id to platform edge functions in team mode
  • New account_mappings: user_id = owner_id, team_id = currentTeam.id in team mode
  • "Already connected" badge workspace-aware
  • Personal mode: all behavior identical to pre-team-accounts
  • Both flows tested end-to-end (personal + team)

Epic 2: Role-Based Permissions (Both devs, after Epic 1)

Labels: team-accounts, epic-2

Goal: Enforce the role-permission matrix. All infrastructure is in place — Epic 2 is purely additive. No data migration required.

Dev 1 (backend): E2.1–E2.4 Dev 2 (frontend): E2.5–E2.8


Story E2.1: Upgrade RLS on downstream tables with role conditions

Owner: Dev 1 | Blocked by: Epic 1 Phase 2 Labels: team-accounts, epic-2

Description

Replace Epic 1's wide-open member INSERT/UPDATE/DELETE policies on 12 downstream tables with role-gated versions per the permission matrix.

Table INSERT/UPDATE roles DELETE roles
campaigns Admin, Manager Admin
media_files Admin, Manager, Contributor Admin
active_creatives Admin, Manager, Contributor Admin
facebook_creatives Admin, Manager, Contributor Admin
snapchat_creatives Admin, Manager, Contributor Admin
tiktok_creatives Admin, Manager, Contributor Admin
temporary_audiences Admin, Manager Admin
temporary_exclusions Admin, Manager Admin
active_audiences Admin, Manager Admin
faniq_audiences_draft Admin, Manager Admin
campaign_drafts Admin, Manager Admin
campaign_payloads Admin, Manager Admin

SELECT policy unchanged (all members can read).

Acceptance Criteria

  • Epic 1 wide-open INSERT policies replaced with role-gated ones
  • Read-Only members cannot insert/update/delete any data
  • Contributor members can only write media and creatives
  • Personal data (team_id IS NULL) still only visible to owner
  • No cross-team data leaks

Ref: docs/TEAM_ACCOUNTS_IMPLEMENTATION_PLAN.md — Downstream Tables RLS section


Story E2.2: _shared/permissions.ts — Edge function permission utility

Owner: Dev 1 | Blocked by: Epic 1 Phase 2 Labels: team-accounts, epic-2

Description

Create supabase/functions/_shared/permissions.ts — shared role-checking utility imported by all edge functions.

export async function checkTeamPermission(
  supabase: SupabaseClient,
  userId: string,
  teamId: string | null,
  action: Action
): Promise<{ allowed: boolean; role: TeamRole | null; reason?: string }>

Personal mode (teamId = null) always returns allowed: true. Checks team_members.role against the permission matrix for team mode.

Acceptance Criteria

  • Importable by all edge functions via ../_shared/permissions.ts
  • Personal mode returns allowed: true (backward compatibility)
  • All permission action strings from the matrix covered
  • Clear 403 reason messages

Ref: docs/TEAM_ACCOUNTS_IMPLEMENTATION_PLAN.md_shared/permissions.ts section


Story E2.3: Apply role checks to team management edge functions

Owner: Dev 1 | Blocked by: E2.2 Labels: team-accounts, epic-2

Description

Add checkTeamPermission guards to all team management edge functions: cancel-team-invitation, send-team-invitation, resend-team-invitation, remove-team-member, update-team-member-role, update-team, delete-team.

Acceptance Criteria

  • All team management functions check caller's role before acting
  • Admin-only functions return 403 for Manager/Contributor/Read-Only
  • Admin+Manager functions (invite) return 403 for Contributor/Read-Only
  • Personal mode unchanged (no team_id = full access)

Story E2.4: Apply role checks to campaign, audience, and media edge functions

Owner: Dev 1 | Blocked by: E2.2 Labels: team-accounts, epic-2

Description

Add checkTeamPermission guards to all campaign, audience, media, and video edge functions per the permission matrix.

Functions Required Permission Allowed Roles
deploy-meta-ads, deploy-tiktok-ads, deploy-snapchat-ads campaigns.create Admin, Manager
Campaign CRUD functions campaigns.create Admin, Manager
upload-media, delete-media media.upload Admin, Manager, Contributor
Audience/pixel functions audiences.manage Admin, Manager
Reporting/insights reporting.view All roles

Acceptance Criteria

  • Campaign deployment returns 403 for Contributor and Read-Only in team mode
  • Media operations accessible to Contributor role
  • Audiences/pixels: Admin + Manager only
  • Reporting accessible to all roles
  • Personal mode: full access (unchanged)

Story E2.5: PermissionGate component + usePermission hook

Owner: Dev 2 | Blocked by: Epic 1 Phase 2 Labels: team-accounts, epic-2

Description

Create src/components/PermissionGate.tsx and src/hooks/usePermission.ts for frontend role gating.

// Route-level wrapper
<PermissionGate requiredPermission="campaigns.create">
  <CreateCampaign />
</PermissionGate>

// Hook for conditional UI
const canUpload = usePermission('media.upload');
{canUpload && <UploadButton />}

Also create src/pages/AccessDenied.tsx with a clear message and link to dashboard.

hasPermission() in TeamContext must be upgraded from always returning true to checking the role matrix (task E2.8).

Acceptance Criteria

  • PermissionGate renders children or AccessDeniedPage based on role
  • usePermission hook returns boolean
  • AccessDeniedPage shows clear message with dashboard link
  • Full access in personal mode (backward compatible)
  • TypeScript types for all permission actions

Ref: docs/TEAM_ACCOUNTS_IMPLEMENTATION_PLAN.md — PermissionGate section


Story E2.6: Apply PermissionGate to all feature routes

Owner: Dev 2 | Blocked by: E2.5 Labels: team-accounts, epic-2

Description

Wrap all feature routes in App.tsx with PermissionGate.

Routes Permission Allowed Roles
/create-campaign, /unified-campaign-builder campaigns.create Admin, Manager
/media-library media.upload Admin, Manager, Contributor
/video-creator, /video-templates video.create Admin, Manager, Contributor
/audiences, /create-audience, /pixels audiences.manage Admin, Manager
/link-accounts, /select-accounts accounts.manage Admin only
/teams/:id/settings team.manage Admin only
/dashboard, /reporting/*, /profile reporting.view All roles

Handle edge case: if user switches to a team where they lose permission for the current page, redirect to dashboard.

Acceptance Criteria

  • All routes wrapped with correct PermissionGate
  • Direct URL access shows Access Denied for unauthorized routes
  • Team switch on gated page redirects to dashboard if unauthorized
  • Personal mode: all routes accessible (unchanged)
  • All 4 roles verified against route table

Story E2.7: Hide nav items by role in DashboardNavbar

Owner: Dev 2 | Blocked by: E2.5 Labels: team-accounts, epic-2

Description

Use usePermission() to conditionally render nav items in DashboardNavbar.tsx. Read-Only users see only Dashboard/Reporting. Contributor sees Dashboard + Media + Video. Manager sees everything except team settings and account linking.

Acceptance Criteria

  • Nav items hidden for unauthorized roles
  • Dashboard/reporting always visible (all roles)
  • Personal mode: all nav items visible
  • Mobile sidebar nav also respects permission gates

Story E2.8: Upgrade TeamContext.hasPermission() to check role matrix

Owner: Dev 2 | Blocked by: E2.5 Labels: team-accounts, epic-2

Description

In Epic 1, hasPermission() always returns true. Upgrade it to check currentRole against the permission matrix so PermissionGate and usePermission actually enforce roles.

// Epic 1 (current):
hasPermission: (action) => true

// Epic 2 (upgrade):
hasPermission: (action) => {
  if (isPersonalMode) return true;
  if (!currentRole) return false;
  return PERMISSION_MATRIX[action]?.includes(currentRole) ?? false;
}

Acceptance Criteria

  • hasPermission() checks role matrix in team mode
  • Personal mode still returns true for all actions
  • All PermissionGate and usePermission calls now enforce roles correctly
  • Verified for all 4 roles against permission matrix

Ref: docs/TEAM_ACCOUNTS_IMPLEMENTATION_PLAN.md — Role-Permission Matrix


Jira Issue Cross-Reference

Stories created in previous Jira sessions. The structure below maps old story numbers to the new plan.

New Story Jira Key Action
Epic 1 SCRUM-87 Keep — update description with new Epic 1 definition
Epic 2 SCRUM-88 Keep
1.1 Audit SCRUM-89 DELETE — audit already done, migrations applied
S1 (Schema migrations) SCRUM-90 Update — new migration content (add team_id FK, drop account_mapping_ids, add Epic 1 RLS)
S2 (Invitations — edge functions) SCRUM-91 Done — all tasks 2.0–2.16 complete; added 2.11b validate-team-invitation
S2 (Invitations — invite form) SCRUM-92 Done
S2 (Invitations — pending list) SCRUM-93 Done
S2 (Invitations — accept/decline page) SCRUM-94 Done
E2 Permission matrix SCRUM-95 Move to Epic description / reference doc (no longer a standalone story)
E2.1 RLS upgrades SCRUM-96 Update — change to Epic 2 role-gated RLS (not Epic 1 membership-only)
E2.2 Permission utility SCRUM-97 Keep
E2.4 Campaign guards SCRUM-98 Keep
E2.4 Media guards SCRUM-99 Merge into E2.4
S3 (TeamContext) SCRUM-100 Update — merge into Story S3, no role awareness in Epic 1 (hasPermission always true)
S3 (Team switcher) SCRUM-101 Update — merge into Story S3
E2.5 PermissionGate SCRUM-102 Keep
E2.6 Apply gates SCRUM-103 Keep
S3 (Member management edge fns) NEW — create in Jira (from old Story 1.7)
S3 (Team management page) NEW — create in Jira (from old Story 1.8)
S3 (Create team modal) NEW — create in Jira (from old Story 1.9)
S3 (Assign account mappings) REMOVED — task 3.9 removed from scope; team workspaces start fresh, no assign-from-personal flow needed
S2 (Email notification service) Done_shared/sendInvitationEmail.ts implemented and used by send + resend functions
S4 (Link Accounts edge fn changes) NEW — create in Jira (4 tasks)
P1 (Ad Deployment module) NEW — create in Jira
P2 (Optimize module) NEW — create in Jira
P3 (Events Feed module) NEW — create in Jira
P4 (Media Library module) NEW — create in Jira
P5 (Link Accounts verify) NEW — create in Jira
E2.3 Team mgmt fn guards NEW — create in Jira
E2.7 Nav items by role NEW — create in Jira
E2.8 Upgrade hasPermission NEW — create in Jira
~~1.12 Update ~52 frontend queries~~ REMOVED — scope absorbed into Phase 2 module stories P1–P5

Total: Epic 1 Phase 1 — 4 stories (S1–S4), 42 tasks | Epic 1 Phase 2 — 5 stories (P1–P5) | Epic 2 — 8 stories (E2.1–E2.8)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment