Complete Jira roadmap for the Team Accounts feature. Contains everything needed to create and manage the issues manually.
- 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)
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)
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
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()
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
| 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 |
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 indocs/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.
Owner: Dev 1 | No blockers (start immediately)
Labels: team-accounts, phase-1
Implementation plan ref: Steps 1.1–1.10
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.
-- 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 columnFinal teams structure: id, name, description, owner_id, invite_code, created_at, updated_at
-- 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);-- 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';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.
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);CREATE INDEX IF NOT EXISTS idx_team_members_user_team
ON team_members(user_id, team_id);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());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;- All 8 migrations run cleanly on fresh and existing databases
- Existing
account_mappingsdata unaffected (team_iddefaults to NULL) - Role enums:
admin,manager,contributor,read_only - Existing
team_membersrows withrole='owner'/'member'migrated to'admin'/'contributor'before constraint added - Existing
team_invitationsrows with old role/status values migrated before constraints added - UNIQUE constraint on
team_members(team_id, user_id) -
active_audiences.account_mapping_idbackfilled from linked campaigns -
teams.account_mapping_ids,user_id,member_count,team_id(TEXT) columns dropped;nameanddescriptioncolumns added; existing team names copied fromteam_id→namebefore drop (Step 2 and Step 4 depend on this) - No existing
meta_access_token,tiktok_access_token,snapchat_access_tokencolumns to add — tokens stay in their own tables -
meta_selected_accounts,tiktok_selected_accounts,snapchat_selected_accountshaveteam_idcolumn added; existing rows stayNULL(personal) -
unified_selected_accountsVIEW updated to includeteam_idcolumn (usingCREATE OR REPLACE VIEW, notALTER VIEW) - Legacy squad + sub-account tables, policies, and functions fully removed
- All duplicate
account_mappingspolicies dropped - Wide-open
temporary_audiencesINSERT policy dropped - Downstream table RLS allows any team member to read/write (no role gate, Epic 1)
-
generate_team_invite_codeRPC: already tracked in20251127173352_remote_schema.sql— no action needed
Ref: docs/TEAM_ACCOUNTS_IMPLEMENTATION_PLAN.md — Steps 1.1–1.10
Owner: Dev 1 | Blocked by: Story S1 | Status: ✅ Done
Labels: team-accounts, phase-1
Implementation plan ref: Steps 2.0–2.16
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).
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_id → team.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)
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.
-
create-teamcreates team withnamecolumn (not oldteam_idTEXT column) + adds creator asadmininteam_members -
get-user-teamsreturnsaccount_mapping_countandmember_count(notaccount_mapping_ids) after migration 1.1 -
delete-teamonly 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 insertingteam_members - Invitation email sent on create + resend; delivery failure doesn't block invitation creation
-
/team-inviteregistered flow: Accept/Decline buttons, redirect to dashboard in team context -
/team-inviteunregistered 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 hitsFunctionsHttpError -
join-team-with-codeimplemented;Teams.tsxdeleted (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-contextfunctions deleted and removed fromconfig.toml
Ref: docs/TEAM_ACCOUNTS_IMPLEMENTATION_PLAN.md — Steps 2.0–2.16
Owner: Dev 1 | Blocked by: Story S1
Labels: team-accounts, phase-1
Implementation plan ref: Steps 3.1–3.11
Create TeamContext, team switcher dropdown, create team modal, all team management edge functions, and the team management page.
// 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).
Dropdown in DashboardNavbar.tsx: Personal Account option + list of teams with role badge + Create Team entry at bottom. Calls switchTeam() from TeamContext.
Opens from team switcher. Team name input (required, max 100 chars). Calls create-team. On success: refreshes team list, auto-switches to new team.
3.4 get-team-members — get_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 — Removed from scope.
Team workspaces start fresh. Owner connects OAuth in team context (creates new assign-account-mapping-to-teamaccount_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)
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).
Route: /teams — Teams.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.
-
TeamContextprovidescurrentTeam,userTeams,switchTeam,isPersonalMode,hasPermission(alwaystrue) - 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/settingswith 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
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
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.
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.
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
}))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 neededApply the same fixes to deploy-tiktok-ads and deploy-snapchat-ads.
Five changes:
-
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.idbefore showing connect buttons. -
"Already connected" badge — in team mode, check
account_mappings WHERE team_id = currentTeam.idinstead ofWHERE user_id = user.id. -
Account mapping creation — when creating new
account_mappingsin team mode, setuser_id = owner_id, team_id = currentTeam.id(notuser_id = current_user.id). -
Fetch existing mappings — in team mode, fetch
account_mappings WHERE team_id = currentTeam.idinstead ofWHERE user_id = user.id. -
Selected accounts scoping — when saving to
meta_selected_accounts/tiktok_selected_accounts/snapchat_selected_accounts, includeteam_id = currentTeam.idin team mode,team_id = NULLin 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.
-
get-accessible-account-mappingsusesteam_idFK query (notaccount_mapping_idsarray) -
get-accessible-account-mappingsteams select usesname(not droppedteam_idTEXT oraccount_mapping_idscolumns) -
get-accessible-account-mappings_teamNameusesteamData.name(not droppedteamData.team_idTEXT column) - No attempt to read
meta_access_token,tiktok_access_token,snapchat_access_tokenfromaccount_mappings -
deploy-meta-ads(and tiktok/snapchat): token fetched frommeta_tokens WHERE user_id = account_mapping.user_id -
deploy-meta-ads(and tiktok/snapchat):active_creativesquery no longer filters byuser_id - Deploy functions work for personal workspace (confirm end-to-end, bug fix affects everyone)
-
meta-accounts,tiktok-accounts,snapchat-accounts: acceptteamIdparameter; 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+teamIdin team mode - New
account_mappingscreated withuser_id = owner_id, team_id = currentTeam.idin team mode - Selected accounts delete scoped to current workspace only (not all workspaces for user)
- Selected accounts saved with
team_id = currentTeam.idin team mode,team_id = NULLin 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
Blocked by: All of Phase 1 merged
Goal: Every existing product module works correctly in both team workspace and personal workspace:
- Data queries use
TeamContextand filter by the team'saccount_mappingsin team mode - Personal mode falls back to
user_idfilter withteam_id IS NULL - Switching workspace re-fetches all data
- No broken queries, missing data, or permission errors in either mode
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
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.
CreateCampaign.tsxline ~241:.eq("user_id", user?.id)for fetching account mappings — needs team context. In team mode should fetchWHERE team_id = currentTeam.id; in personal modeWHERE 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 sinceaccount_mapping.user_id = owner_id. - Campaign draft save/load scoped to correct workspace.
- Workspace switch clears/re-fetches current campaign context.
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);
}- 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
Owner: Dev 2 | Blocked by: Phase 1
Files: src/pages/Optimize.tsx
Labels: team-accounts, phase-2
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.
- Account mapping queries: switch from
user_id = metoteam_id = currentTeam.idin 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
- 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
Owner: Dev 2 | Blocked by: Phase 1
Files: src/pages/TixrEvents.tsx
Labels: team-accounts, phase-2
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.
TixrEvents.tsxline ~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
- 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
Owner: Dev 2 | Blocked by: Phase 1
Files: src/pages/MediaLibrary.tsx
Labels: team-accounts, phase-2
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.
MediaLibrary.tsxline ~70:.eq("user_id", user.id)for account mappings — needs team context- Media uploads:
media_filesrecord created with team-scopedaccount_mapping_idin team mode - Gallery filter: show all media on shared account mappings for team members
- Workspace switch refreshes gallery
- 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
Owner: Dev 1 | Blocked by: Phase 1 (specifically S4)
Files: src/pages/LinkAccounts.tsx, src/pages/SelectAccounts.tsx
Labels: team-accounts, phase-2
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.
- OAuth connect buttons correctly hidden for non-owners in team mode
- Account listing edge functions receive
owner_idin team mode - New
account_mappingscreated withuser_id = owner_id, team_id = currentTeam.idin team mode - "Already connected" badge correctly checks team mappings in team mode
- Personal mode: connect buttons visible,
user_id = user.id,team_id = NULL— completely unchanged
Personal workspace flow:
- User connects their Meta account →
account_mappingscreated withuser_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_mappingscreated withuser_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-accountsin team mode - OAuth connect buttons hidden
- Existing team account mappings visible (can select ad accounts)
- Cannot create new account_mappings
- OAuth connect buttons hidden for non-owners in team mode
- Account listing passes
owner_idto platform edge functions in team mode - New account_mappings:
user_id = owner_id, team_id = currentTeam.idin team mode - "Already connected" badge workspace-aware
- Personal mode: all behavior identical to pre-team-accounts
- Both flows tested end-to-end (personal + team)
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
Owner: Dev 1 | Blocked by: Epic 1 Phase 2
Labels: team-accounts, epic-2
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).
- 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
Owner: Dev 1 | Blocked by: Epic 1 Phase 2
Labels: team-accounts, epic-2
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.
- 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
Owner: Dev 1 | Blocked by: E2.2
Labels: team-accounts, epic-2
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.
- 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)
Owner: Dev 1 | Blocked by: E2.2
Labels: team-accounts, epic-2
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 |
- 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)
Owner: Dev 2 | Blocked by: Epic 1 Phase 2
Labels: team-accounts, epic-2
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).
-
PermissionGaterenders children orAccessDeniedPagebased on role -
usePermissionhook returns boolean -
AccessDeniedPageshows 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
Owner: Dev 2 | Blocked by: E2.5
Labels: team-accounts, epic-2
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.
- 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
Owner: Dev 2 | Blocked by: E2.5
Labels: team-accounts, epic-2
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.
- 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
Owner: Dev 2 | Blocked by: E2.5
Labels: team-accounts, epic-2
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;
}-
hasPermission()checks role matrix in team mode - Personal mode still returns
truefor all actions - All
PermissionGateandusePermissioncalls now enforce roles correctly - Verified for all 4 roles against permission matrix
Ref: docs/TEAM_ACCOUNTS_IMPLEMENTATION_PLAN.md — Role-Permission Matrix
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 |
| 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) |
| — | 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)