feat: implement core database schema and RLS policies for campaigns and leads

This commit is contained in:
Marc Klose 2026-04-07 11:43:08 +03:00
parent b8d19c5797
commit 3024cfd377
2 changed files with 372 additions and 0 deletions

View file

@ -0,0 +1,231 @@
# 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_<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 `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.

View file

@ -0,0 +1,141 @@
-- Migration: Core Schema for Campaigns and Leads
-- Description: Adds core tables and RLS policies for Phase 1 MVP.
-- Created: 2026-04-07 11:31:27
-- 1. Update organizations table
ALTER TABLE public.organizations
ADD COLUMN IF NOT EXISTS icp jsonb DEFAULT '{}',
ADD COLUMN IF NOT EXISTS company_profile jsonb DEFAULT '{}',
ADD COLUMN IF NOT EXISTS monthly_quota int DEFAULT 0,
ADD COLUMN IF NOT EXISTS current_leads_month int DEFAULT 0,
ADD COLUMN IF NOT EXISTS quota_reset_date timestamptz DEFAULT now();
-- 2. Create campaigns table
CREATE TABLE IF NOT EXISTS public.campaigns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
search_account_id uuid NOT NULL REFERENCES auth.users(id),
name text NOT NULL,
industries jsonb DEFAULT '[]'::jsonb,
locations jsonb DEFAULT '[]'::jsonb,
headcount jsonb DEFAULT '[]'::jsonb,
target_leads int NOT NULL,
current_leads int DEFAULT 0,
search_cursor text,
status text DEFAULT 'pending'::text,
created_at timestamptz DEFAULT now(),
CONSTRAINT campaigns_status_check CHECK (status IN ('pending', 'running', 'paused', 'pipeline_completed', 'quota_reached'))
);
CREATE UNIQUE INDEX IF NOT EXISTS campaigns_name_org_unique ON public.campaigns (organization_id, name);
-- 3. Create leads table (Global deduplication & Enrichment Cache)
CREATE TABLE IF NOT EXISTS public.leads (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
linkedin_url text UNIQUE NOT NULL,
name text NOT NULL,
domain text,
enriched_data jsonb DEFAULT '{}'::jsonb,
last_enriched_at timestamptz,
created_at timestamptz DEFAULT now()
);
-- 4. Create campaign_leads junction table
CREATE TABLE IF NOT EXISTS public.campaign_leads (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id uuid NOT NULL REFERENCES public.campaigns(id) ON DELETE CASCADE,
lead_id uuid NOT NULL REFERENCES public.leads(id) ON DELETE CASCADE,
organization_id uuid NOT NULL REFERENCES public.organizations(id),
status text DEFAULT 'pending'::text,
qualification_data jsonb DEFAULT '{}'::jsonb,
outreach_template jsonb DEFAULT '{}'::jsonb,
created_at timestamptz DEFAULT now(),
CONSTRAINT campaign_leads_status_check CHECK (status IN ('pending', 'qualified', 'disqualified'))
);
CREATE UNIQUE INDEX IF NOT EXISTS campaign_leads_unique ON public.campaign_leads (campaign_id, lead_id);
-- 5. Create lead_contacts table
CREATE TABLE IF NOT EXISTS public.lead_contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id uuid NOT NULL REFERENCES public.leads(id) ON DELETE CASCADE,
organization_id uuid NOT NULL REFERENCES public.organizations(id),
provider_id text,
member_urn text,
public_id text,
name text NOT NULL,
first_name text,
last_name text,
headline text,
linkedin_url text NOT NULL,
connection_status text DEFAULT 'none'::text,
claimed_by_id uuid REFERENCES auth.users(id),
created_at timestamptz DEFAULT now(),
CONSTRAINT lead_contacts_connection_status_check CHECK (connection_status IN ('none', 'pending', 'retry_pending', 'accepted', 'no_contact'))
);
-- 6. Enable RLS
ALTER TABLE public.campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.leads ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.campaign_leads ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.lead_contacts ENABLE ROW LEVEL SECURITY;
-- 7. Implement RLS Policies
-- 7.1 Organizations Policies
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'tenant_can_view_organization') THEN
CREATE POLICY "tenant_can_view_organization" ON public.organizations
FOR SELECT USING ((SELECT organization_id FROM public.profiles WHERE id = auth.uid()) = id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'tenant_owner_can_update_organization') THEN
CREATE POLICY "tenant_owner_can_update_organization" ON public.organizations
FOR UPDATE USING ((SELECT organization_id FROM public.profiles WHERE id = auth.uid() AND role = 'owner') = id);
END IF;
END $$;
-- 7.2 Campaigns Policies
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'tenant_can_all_campaigns') THEN
CREATE POLICY "tenant_can_all_campaigns" ON public.campaigns
FOR ALL USING ((SELECT organization_id FROM public.profiles WHERE id = auth.uid()) = organization_id)
WITH CHECK ((SELECT organization_id FROM public.profiles WHERE id = auth.uid()) = organization_id);
END IF;
END $$;
-- 7.3 Leads Policies (Global cache)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'authenticated_can_view_leads') THEN
CREATE POLICY "authenticated_can_view_leads" ON public.leads
FOR SELECT TO authenticated USING (true);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'authenticated_can_insert_leads') THEN
CREATE POLICY "authenticated_can_insert_leads" ON public.leads
FOR INSERT TO authenticated WITH CHECK (true);
END IF;
END $$;
-- 7.4 Campaign Leads Policies
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'tenant_can_all_campaign_leads') THEN
CREATE POLICY "tenant_can_all_campaign_leads" ON public.campaign_leads
FOR ALL USING ((SELECT organization_id FROM public.profiles WHERE id = auth.uid()) = organization_id)
WITH CHECK ((SELECT organization_id FROM public.profiles WHERE id = auth.uid()) = organization_id);
END IF;
END $$;
-- 7.5 Lead Contacts Policies
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'tenant_can_all_lead_contacts') THEN
CREATE POLICY "tenant_can_all_lead_contacts" ON public.lead_contacts
FOR ALL USING ((SELECT organization_id FROM public.profiles WHERE id = auth.uid()) = organization_id)
WITH CHECK ((SELECT organization_id FROM public.profiles WHERE id = auth.uid()) = organization_id);
END IF;
END $$;