feat: implement core database schema and RLS policies for campaigns and leads
This commit is contained in:
parent
b8d19c5797
commit
3024cfd377
2 changed files with 372 additions and 0 deletions
231
.agents/plans/database-schema-and-rls.md
Normal file
231
.agents/plans/database-schema-and-rls.md
Normal 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.
|
||||
141
migrations/20260407113127_core_schema.sql
Normal file
141
migrations/20260407113127_core_schema.sql
Normal 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 $$;
|
||||
Loading…
Reference in a new issue