# 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: Contains `organization_id` used for RLS. - [lib/supabase/server.ts](file:///c:/Users/Marc/dev/fullstack/my-fullstack-ai-platform/lib/supabase/server.ts) - Why: Pattern for interacting with Supabase from the server. - [app/dashboard/settings/team/actions.ts](file:///c:/Users/Marc/dev/fullstack/my-fullstack-ai-platform/app/dashboard/settings/team/actions.ts) (lines 12-42) - Why: Shows how `organization_id` is retrieved from the profile. ### Relevant Documentation YOU SHOULD READ THESE BEFORE IMPLEMENTING! - [Supabase RLS Policies](https://supabase.com/docs/guides/auth/row-level-security) - Why: Required for implementing secure multi-tenant isolation. - [PostgreSQL Unique Indices with Filters](https://www.postgresql.org/docs/current/indexes-partial.html) - 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__`. --- ## 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 `organizations` table. - Create the `campaigns` table with multi-tenant scoping and the specified unique constraint. - Create the global `leads` table for company deduplication. - Create the joining `campaign_leads` table for campaign-specific lead data. - Create the `lead_contacts` table. ### 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`, and `DELETE` policies for each table. --- ## STEP-BY-STEP TASKS ### UPDATE `public.organizations` - **IMPLEMENT**: Add the following columns to the `organizations` table: - `icp`: `jsonb`, default `{}` - `company_profile`: `jsonb`, default `{}` - `monthly_quota`: `int`, default `0` - `current_leads_month`: `int`, default `0` - `quota_reset_date`: `timestamptz`, default `now()` - **VALIDATE**: `mcp_supabase_execute_sql` to check column existence. ### CREATE `public.campaigns` - **IMPLEMENT**: Create the `campaigns` table: - `id`: `uuid` (PK), default `gen_random_uuid()` - `organization_id`: `uuid` (FK -> `organizations.id`), NOT NULL - `search_account_id`: `uuid` (FK -> `auth.users.id`), NOT NULL - `name`: `text`, NOT NULL - `industries`: `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 NULL - `current_leads`: `int`, default `0` - `search_cursor`: `text` - `status`: `text`, default `'pending'` (Check constraint: `pending`, `running`, `paused`, `pipeline_completed`, `quota_reached`) - `created_at`: `timestamptz`, default `now()` - **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` + `headcount` within the same `organization_id` before creating a new one. ### CREATE `public.leads` (Companies) - **IMPLEMENT**: Create the `leads` table (Global deduplication & Enrichment Cache): - `id`: `uuid` (PK), default `gen_random_uuid()` - `linkedin_url`: `text`, UNIQUE, NOT NULL - `name`: `text`, NOT NULL - `domain`: `text` - `enriched_data`: `jsonb`, default `{}` (Global shared company info) - `last_enriched_at`: `timestamptz` - `created_at`: `timestamptz`, default `now()` - **VALIDATE**: `mcp_supabase_list_tables` for table structure. ### CREATE `public.campaign_leads` - **IMPLEMENT**: Create the `campaign_leads` junction table: - `id`: `uuid` (PK), default `gen_random_uuid()` - `campaign_id`: `uuid` (FK -> `campaigns.id`), CASCADE DELETE - `lead_id`: `uuid` (FK -> `leads.id`), CASCADE DELETE - `organization_id`: `uuid` (FK -> `organizations.id`), NOT NULL - `status`: `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`, default `now()` - **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_contacts` table (Tenant-scoped interaction): - `id`: `uuid` (PK), default `gen_random_uuid()` - `lead_id`: `uuid` (FK -> `leads.id`), CASCADE DELETE - `organization_id`: `uuid` (FK -> `organizations.id`), NOT NULL - `provider_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 NULL - `first_name`: `text` - `last_name`: `text` - `headline`: `text` - `linkedin_url`: `text`, NOT NULL - `connection_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`, default `now()` - **VALIDATE**: `mcp_supabase_list_tables`. ### IMPLEMENT RLS POLICIES - **IMPLEMENT**: Apply RLS to all tables. - **PATTERN**: Each policy check should follow this logic: ```sql (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 via `campaign_id -> campaigns.organization_id` OR by adding `organization_id` to the table for performance. Let's add `organization_id` to `campaign_leads` as well for easier RLS. - `lead_contacts`: Scoped via `organization_id`. - **VALIDATE**: `mcp_supabase_execute_sql` query against `pg_policies`. --- ## TESTING STRATEGY ### Unit Tests - Not applicable for raw SQL schema, but can be validated via manual SQL inserts. ### Integration Tests (Manual) 1. **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. 2. **Quota Barrier**: - Verify that `monthly_quota` and `current_leads_month` are updated and respected (to be tested more thoroughly in the pipeline phase). --- ## VALIDATION COMMANDS ### Level 1: Schema Check ```sql 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 ```sql 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. - [ ] `organizations` table successfully extended with new fields. - [ ] RLS is enabled on `campaigns`, `campaign_leads`, and `lead_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 `organizations` table as the tenant anchor. - The `leads` table is intentionally global to allow for company deduplication across the whole platform, but company-specific results are stored in the tenant-scoped `campaign_leads` table.