9.8 KiB
Feature: Database Schema and RLS
The following plan defines the core data foundation for the Teklifsat platform, including campaigns, leads, and contact management with strict multi-tenant isolation via Supabase Row-Level Security (RLS).
Feature Description
This feature implements the first milestone of the Phase 1 MVP: setting up the relational database schema and security policies. It extends the existing organizations table and adds new tables for campaigns, leads, and their interactions, ensuring that all data is strictly scoped to the user's organization.
User Story
As an Owner or SDR, I want a secure and structured database to store my campaigns and leads, So that I can manage my prospecting pipeline without risking data leakage between organizations.
Problem Statement
The platform currently only has basic organization and profile management. It lacks the core entities needed to run campaigns, track leads, and manage LinkedIn contacts as defined in the PRD.
Solution Statement
We will update the organizations table to include ICP and quota fields, and create four new tables (campaigns, leads, campaign_leads, lead_contacts) with appropriate foreign keys and unique constraints. We will then apply RLS policies to each table using a consistent pattern based on the user's organization_id.
Feature Metadata
Feature Type: New Capability / Infrastructure Estimated Complexity: Medium Primary Systems Affected: Supabase Database (Schema, RLS) Dependencies: Supabase
CONTEXT REFERENCES
Relevant Codebase Files IMPORTANT: YOU MUST READ THESE FILES BEFORE IMPLEMENTING!
public.organizations(Existing table) - Why: Main tenant entity to be updated.public.profiles- Why: Containsorganization_idused for RLS.- lib/supabase/server.ts - Why: Pattern for interacting with Supabase from the server.
- app/dashboard/settings/team/actions.ts (lines 12-42) - Why: Shows how
organization_idis retrieved from the profile.
Relevant Documentation YOU SHOULD READ THESE BEFORE IMPLEMENTING!
- Supabase RLS Policies
- Why: Required for implementing secure multi-tenant isolation.
- PostgreSQL Unique Indices with Filters
- Why: Needed for the restricted uniqueness constraint on campaigns (industry + country per tenant).
Patterns to Follow
Multi-Tenant Isolation:
All tenant-scoped tables must include a reference to organization_id (or link to it via a parent table) and have RLS policies that validate auth.uid() against the profiles table.
Naming Conventions:
- Table names:
snake_case(plural). - Column names:
snake_case. - Policy names:
tenant_can_<action>_<table_name>.
IMPLEMENTATION PLAN
Phase 1: Schema Updates & Foundation
Updating existing structures and creating the core campaign/lead hierarchy.
Tasks:
- Add ICP, profile, and quota fields to the
organizationstable. - Create the
campaignstable with multi-tenant scoping and the specified unique constraint. - Create the global
leadstable for company deduplication. - Create the joining
campaign_leadstable for campaign-specific lead data. - Create the
lead_contactstable.
Phase 2: RLS Policy Implementation
Enforcing isolation at the database level.
Tasks:
- Enable RLS on all new tables.
- Implement reusable helper functions for organization-based filtering (optional but recommended).
- Write
SELECT,INSERT,UPDATE, andDELETEpolicies for each table.
STEP-BY-STEP TASKS
UPDATE public.organizations
- IMPLEMENT: Add the following columns to the
organizationstable:icp:jsonb, default{}company_profile:jsonb, default{}monthly_quota:int, default0current_leads_month:int, default0quota_reset_date:timestamptz, defaultnow()
- VALIDATE:
mcp_supabase_execute_sqlto check column existence.
CREATE public.campaigns
- IMPLEMENT: Create the
campaignstable:id:uuid(PK), defaultgen_random_uuid()organization_id:uuid(FK ->organizations.id), NOT NULLsearch_account_id:uuid(FK ->auth.users.id), NOT NULLname:text, NOT NULLindustries:jsonb, default[](Array of Unipile Industry IDs)locations:jsonb, default[](Array of Unipile Location IDs)headcount:jsonb, default[](Array of {min, max} objects matching Unipile API)target_leads:int, NOT NULLcurrent_leads:int, default0search_cursor:textstatus:text, default'pending'(Check constraint:pending,running,paused,pipeline_completed,quota_reached)created_at:timestamptz, defaultnow()
- INDEX:
CREATE UNIQUE INDEX campaigns_name_org_unique ON campaigns (organization_id, name); - VALIDATE (Server-Side): Implementation must include a check to find existing campaigns with identical
industries+locations+headcountwithin the sameorganization_idbefore creating a new one.
CREATE public.leads (Companies)
- IMPLEMENT: Create the
leadstable (Global deduplication & Enrichment Cache):id:uuid(PK), defaultgen_random_uuid()linkedin_url:text, UNIQUE, NOT NULLname:text, NOT NULLdomain:textenriched_data:jsonb, default{}(Global shared company info)last_enriched_at:timestamptzcreated_at:timestamptz, defaultnow()
- VALIDATE:
mcp_supabase_list_tablesfor table structure.
CREATE public.campaign_leads
- IMPLEMENT: Create the
campaign_leadsjunction table:id:uuid(PK), defaultgen_random_uuid()campaign_id:uuid(FK ->campaigns.id), CASCADE DELETElead_id:uuid(FK ->leads.id), CASCADE DELETEorganization_id:uuid(FK ->organizations.id), NOT NULLstatus:text, default'pending'(Check:pending,qualified,disqualified)qualification_data:jsonb, default{}(Tenant-specific matching reason)outreach_template:jsonb, default{}(Tenant-specific pitch)created_at:timestamptz, defaultnow()
- INDEX:
CREATE UNIQUE INDEX campaign_leads_unique ON campaign_leads (campaign_id, lead_id); - VALIDATE:
mcp_supabase_list_tables.
CREATE public.lead_contacts
- IMPLEMENT: Create the
lead_contactstable (Tenant-scoped interaction):id:uuid(PK), defaultgen_random_uuid()lead_id:uuid(FK ->leads.id), CASCADE DELETEorganization_id:uuid(FK ->organizations.id), NOT NULLprovider_id:text(Unipile ID for messaging/invites)member_urn:text(Global LinkedIn ID for deduplication)public_id:text(Vanity ID from URL)name:text, NOT NULLfirst_name:textlast_name:textheadline:textlinkedin_url:text, NOT NULLconnection_status:text, default'none'(Check:none,pending,retry_pending,accepted,no_contact)claimed_by_id:uuid(FK ->auth.users.id), NULLABLE (The SDR currently engaging this contact)created_at:timestamptz, defaultnow()
- VALIDATE:
mcp_supabase_list_tables.
IMPLEMENT RLS POLICIES
- IMPLEMENT: Apply RLS to all tables.
- PATTERN: Each policy check should follow this logic:
(SELECT organization_id FROM profiles WHERE id = auth.uid()) = organization_id - POLICIES:
organizations: Owners and members can view. Owners can update.campaigns: Members of the same organization can view, insert, and update.leads: Anyone authenticated can view/insert (global company profiles).campaign_leads: Scoped viacampaign_id -> campaigns.organization_idOR by addingorganization_idto the table for performance. Let's addorganization_idtocampaign_leadsas well for easier RLS.lead_contacts: Scoped viaorganization_id.
- VALIDATE:
mcp_supabase_execute_sqlquery againstpg_policies.
TESTING STRATEGY
Unit Tests
- Not applicable for raw SQL schema, but can be validated via manual SQL inserts.
Integration Tests (Manual)
- Multi-Tenant Check:
- High-level test: Create two organizations (Org A, Org B).
- Insert a campaign for Org A.
- Verify that User B (in Org B) cannot see or update Campaign A.
- Quota Barrier:
- Verify that
monthly_quotaandcurrent_leads_monthare updated and respected (to be tested more thoroughly in the pipeline phase).
- Verify that
VALIDATION COMMANDS
Level 1: Schema Check
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name IN ('organizations', 'campaigns', 'leads', 'campaign_leads', 'lead_contacts');
Level 2: RLS Check
SELECT tablename, policyname, cmd, qual
FROM pg_policies
WHERE schemaname = 'public';
ACCEPTANCE CRITERIA
- All tables defined in Step-by-Step are created with correct types and keys.
organizationstable successfully extended with new fields.- RLS is enabled on
campaigns,campaign_leads, andlead_contacts. - Users can only access data belonging to their organization.
- Unique constraint for industry + country per tenant is enforced.
- Foreign key relationships are correctly established with cascade deletes where appropriate.
NOTES
- We are using the existing
organizationstable as the tenant anchor. - The
leadstable is intentionally global to allow for company deduplication across the whole platform, but company-specific results are stored in the tenant-scopedcampaign_leadstable.