232 lines
9.8 KiB
Markdown
232 lines
9.8 KiB
Markdown
|
|
# 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.
|