FleetOS Emergent Contacts Migration Plan

Date: 2026-01-09 Status: APPROVED - Ready to Execute Architecture: Company-Owned Work + Emergent Contacts Effort: 8 hours Risk: LOW


🎯 Objective

Implement BIBLE-compliant architecture:

  • βœ… Companies own all work (permanent)

  • βœ… Contacts are emergent actors (temporary, created on first access)

  • βœ… Contacts can assign and be assigned (but don't own)

  • βœ… No signup required

  • βœ… Emergency access works


πŸ“‹ Phase 1: Database Migration

Step 1.1: Create Migration File

File: database/fleetos/emergent_contacts_migration.sql (NEW)

-- ============================================
-- FLEETOS EMERGENT CONTACTS MIGRATION
-- Implements BIBLE-compliant architecture:
-- - Companies own the work
-- - Contacts are emergent actors (not owners)
-- Safe to run: Uses IF NOT EXISTS
-- ============================================

-- ============================================
-- PART 1: CONTACTS TABLE (Emergent)
-- ============================================

CREATE TABLE IF NOT EXISTS contacts (
  contact_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  company_id UUID NOT NULL REFERENCES companies(company_id) ON DELETE CASCADE,
  contact_value TEXT UNIQUE NOT NULL,  -- Canonical email or phone
  contact_type TEXT CHECK (contact_type IN ('email', 'phone')),
  display_name TEXT,
  first_seen_at TIMESTAMP DEFAULT NOW(),
  last_seen_at TIMESTAMP DEFAULT NOW()
);

-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_contacts_company ON contacts(company_id);
CREATE INDEX IF NOT EXISTS idx_contacts_value ON contacts(contact_value);
CREATE INDEX IF NOT EXISTS idx_contacts_last_seen ON contacts(last_seen_at DESC);

COMMENT ON TABLE contacts IS 'Emergent contacts - created on first access, no signup required';
COMMENT ON COLUMN contacts.contact_value IS 'Canonical email (lowercase) or phone (E.164 format)';
COMMENT ON COLUMN contacts.first_seen_at IS 'When this contact first accessed FleetOS';
COMMENT ON COLUMN contacts.last_seen_at IS 'Most recent access (for "last active" routing)';

-- ============================================
-- PART 2: ADD COMPANY COLUMNS TO SERVICE_REQUESTS
-- ============================================

-- Add fleet_company_id (companies own the work)
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM information_schema.columns
    WHERE table_name='service_requests' AND column_name='fleet_company_id'
  ) THEN
    ALTER TABLE service_requests
    ADD COLUMN fleet_company_id UUID REFERENCES companies(company_id);

    RAISE NOTICE 'βœ… Added fleet_company_id column';
  ELSE
    RAISE NOTICE 'ℹ️  fleet_company_id already exists';
  END IF;
END $$;

-- Add supplier_company_id (companies own the work)
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM information_schema.columns
    WHERE table_name='service_requests' AND column_name='supplier_company_id'
  ) THEN
    ALTER TABLE service_requests
    ADD COLUMN supplier_company_id UUID REFERENCES companies(company_id);

    RAISE NOTICE 'βœ… Added supplier_company_id column';
  ELSE
    RAISE NOTICE 'ℹ️  supplier_company_id already exists';
  END IF;
END $$;

-- Add submitted_by_contact_id (emergent actor, not owner)
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM information_schema.columns
    WHERE table_name='service_requests' AND column_name='submitted_by_contact_id'
  ) THEN
    ALTER TABLE service_requests
    ADD COLUMN submitted_by_contact_id UUID REFERENCES contacts(contact_id);

    RAISE NOTICE 'βœ… Added submitted_by_contact_id column';
  ELSE
    RAISE NOTICE 'ℹ️  submitted_by_contact_id already exists';
  END IF;
END $$;

-- Add assigned_to_contact_id (emergent actor, not owner)
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM information_schema.columns
    WHERE table_name='service_requests' AND column_name='assigned_to_contact_id'
  ) THEN
    ALTER TABLE service_requests
    ADD COLUMN assigned_to_contact_id UUID REFERENCES contacts(contact_id);

    RAISE NOTICE 'βœ… Added assigned_to_contact_id column';
  ELSE
    RAISE NOTICE 'ℹ️  assigned_to_contact_id already exists';
  END IF;
END $$;

-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_service_requests_fleet_company
  ON service_requests(fleet_company_id);

CREATE INDEX IF NOT EXISTS idx_service_requests_supplier_company
  ON service_requests(supplier_company_id);

CREATE INDEX IF NOT EXISTS idx_service_requests_submitted_by
  ON service_requests(submitted_by_contact_id);

CREATE INDEX IF NOT EXISTS idx_service_requests_assigned_to
  ON service_requests(assigned_to_contact_id);

-- ============================================
-- PART 3: EVENT LOG (Audit Trail)
-- ============================================

CREATE TABLE IF NOT EXISTS event_log (
  event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  event_type TEXT NOT NULL,
  company_id UUID REFERENCES companies(company_id) ON DELETE CASCADE,
  contact_id UUID REFERENCES contacts(contact_id) ON DELETE SET NULL,
  target_request_id UUID REFERENCES service_requests(request_id) ON DELETE CASCADE,
  metadata JSONB,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_event_log_company ON event_log(company_id);
CREATE INDEX IF NOT EXISTS idx_event_log_contact ON event_log(contact_id);
CREATE INDEX IF NOT EXISTS idx_event_log_created ON event_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_event_log_type ON event_log(event_type);

COMMENT ON TABLE event_log IS 'Audit trail - tracks every access and action';
COMMENT ON COLUMN event_log.contact_id IS 'Which emergent contact acted';

-- ============================================
-- PART 4: HELPER FUNCTIONS
-- ============================================

-- Function: Get or create emergent contact
CREATE OR REPLACE FUNCTION get_or_create_contact(
  p_contact_value TEXT,
  p_company_id UUID,
  p_contact_type TEXT,
  p_display_name TEXT DEFAULT NULL
) RETURNS UUID AS $$
DECLARE
  v_contact_id UUID;
BEGIN
  -- Try to find existing contact
  SELECT contact_id INTO v_contact_id
  FROM contacts
  WHERE contact_value = LOWER(TRIM(p_contact_value));

  -- If not found, create emergent contact
  IF v_contact_id IS NULL THEN
    INSERT INTO contacts (
      company_id,
      contact_value,
      contact_type,
      display_name,
      first_seen_at,
      last_seen_at
    ) VALUES (
      p_company_id,
      LOWER(TRIM(p_contact_value)),
      p_contact_type,
      p_display_name,
      NOW(),
      NOW()
    )
    RETURNING contact_id INTO v_contact_id;

    RAISE NOTICE '✨ Emergent contact created: %', p_contact_value;
  ELSE
    -- Update last_seen for existing contact
    UPDATE contacts
    SET last_seen_at = NOW()
    WHERE contact_id = v_contact_id;
  END IF;

  RETURN v_contact_id;
END;
$$ LANGUAGE plpgsql;

COMMENT ON FUNCTION get_or_create_contact IS 'Get existing or create emergent contact on first access';

-- Function: Get last active contact for company
CREATE OR REPLACE FUNCTION get_last_active_contact(p_company_id UUID)
RETURNS TABLE(contact_id UUID, contact_value TEXT, last_seen_at TIMESTAMP) AS $$
BEGIN
  RETURN QUERY
  SELECT c.contact_id, c.contact_value, c.last_seen_at
  FROM contacts c
  WHERE c.company_id = p_company_id
  ORDER BY c.last_seen_at DESC
  LIMIT 1;
END;
$$ LANGUAGE plpgsql;

COMMENT ON FUNCTION get_last_active_contact IS 'Find last active contact for org-level fallback notifications';

-- ============================================
-- PART 5: UPDATE STATUS ENUM
-- ============================================

-- Drop existing constraint if exists
DO $$
BEGIN
  IF EXISTS (
    SELECT 1 FROM information_schema.constraint_column_usage
    WHERE constraint_name = 'valid_status'
  ) THEN
    ALTER TABLE service_requests DROP CONSTRAINT valid_status;
    RAISE NOTICE 'βœ… Dropped old status constraint';
  END IF;
END $$;

-- Add new BIBLE-compliant status constraint
ALTER TABLE service_requests
ADD CONSTRAINT valid_status
CHECK (status IN ('new', 'acknowledged', 'in_progress', 'completed', 'declined'));

RAISE NOTICE 'βœ… Added BIBLE-compliant status constraint (new β†’ acknowledged β†’ in_progress β†’ completed/declined)';

-- ============================================
-- MIGRATION COMPLETE
-- ============================================

DO $$
BEGIN
  RAISE NOTICE '========================================';
  RAISE NOTICE 'βœ… FLEETOS EMERGENT CONTACTS MIGRATION COMPLETE';
  RAISE NOTICE '========================================';
  RAISE NOTICE '';
  RAISE NOTICE 'Created:';
  RAISE NOTICE '  βœ… contacts table (emergent actors)';
  RAISE NOTICE '  βœ… event_log table (audit trail)';
  RAISE NOTICE '';
  RAISE NOTICE 'Added to service_requests:';
  RAISE NOTICE '  βœ… fleet_company_id (companies own work)';
  RAISE NOTICE '  βœ… supplier_company_id (companies own work)';
  RAISE NOTICE '  βœ… submitted_by_contact_id (emergent actor)';
  RAISE NOTICE '  βœ… assigned_to_contact_id (emergent actor)';
  RAISE NOTICE '';
  RAISE NOTICE 'Helper functions:';
  RAISE NOTICE '  βœ… get_or_create_contact() - emergent contact creation';
  RAISE NOTICE '  βœ… get_last_active_contact() - for fallback routing';
  RAISE NOTICE '';
  RAISE NOTICE 'Status lifecycle: new β†’ acknowledged β†’ in_progress β†’ completed/declined';
  RAISE NOTICE '';
  RAISE NOTICE '⚠️  NEXT: Update application code to use new architecture';
  RAISE NOTICE '========================================';
END $$;

How to run:

  1. Copy entire SQL above

  2. Open Supabase SQL Editor: https://supabase.com/dashboard/project/YOUR_PROJECT/sql

  3. Paste SQL

  4. Click "Run"

  5. Verify success messages in output


Step 1.2: Verify Migration

Run these queries to confirm:


πŸ“ Phase 2: Code Changes

Step 2.1: Create ContactService

File: services/shared/ContactService.ts (NEW)


Step 2.2: Create EventLogService

File: services/shared/EventLogService.ts (NEW)


Step 2.3: Update MagicLinkService

File: services/shared/MagicLinkService.ts

Add to MagicLinkPayload interface:

No other changes needed to MagicLinkService - it already handles encoding/decoding!


File: services/AuthService.ts

Find the sendDailyAccessLink() method and update:


Step 2.5: Update QueryService.createServiceRequest()

File: services/shared/QueryService.ts

Replace the createServiceRequest method:


Step 2.6: Update Seed Functions

File: App.tsx

Update seedShepherdRelationships:

Update call site (around line 1050):


Step 2.7: Update Signup Handlers

File: App.tsx

Update FLEETOS_SUPPLIER_ONBOARDING handler:

Similar update for FLEETOS_FLEET_ONBOARDING (same pattern)


Step 2.8: Update Dashboard Queries

File: components/fleetos/components/SupplierTodoList.tsx

Update the query to use company_id:


πŸ§ͺ Phase 3: Testing Checklist

Pre-Migration Tests

Migration Tests

Code Tests

Test 1: Emergent Contact Creation

Test 2: Repeat Access (No Duplicate)

Test 3: Company-Owned Work

Test 4: Team Visibility (CRITICAL)

Test 5: Assignment

Test 6: Emergency Access

Test 7: Event Log Audit

Verification Queries


πŸ”„ Phase 4: Rollback Strategy

If Migration Fails

SQL Rollback:

Code Rollback:


βœ… Success Criteria

Migration succeeds when:

  1. βœ… contacts table exists with helper functions

  2. βœ… event_log table exists

  3. βœ… service_requests has 4 new columns

  4. βœ… Emergent contact creation works (no signup required)

  5. βœ… Team visibility works (same domain = see all work)

  6. βœ… Assignment works (contacts can assign)

  7. βœ… Emergency access works (any domain member can access)

  8. βœ… Event log captures all actions

  9. βœ… One Sentence Test passes

  10. βœ… Build passes with no errors


πŸ“Š Timeline

Phase
Task
Time

1

Database Migration

1 hour

2

Code Changes

4 hours

3

Testing

2 hours

4

Documentation

1 hour

Total

8 hours


πŸš€ Execution Order

  1. βœ… Phase 1: Run database migration

  2. βœ… Phase 2: Update all code files

  3. βœ… Phase 3: Run all tests

  4. βœ… Phase 4: Document results

  5. βœ… Commit: Save to git


πŸ“ Files Changed Summary

New Files (3):

  1. database/fleetos/emergent_contacts_migration.sql

  2. services/shared/ContactService.ts

  3. services/shared/EventLogService.ts

Modified Files (5):

  1. services/shared/MagicLinkService.ts - Add contact_id to payload

  2. services/AuthService.ts - Use emergent contacts

  3. services/shared/QueryService.ts - Company-owned requests

  4. App.tsx - Update signup handlers and seed functions

  5. components/fleetos/components/SupplierTodoList.tsx - Company-scoped queries

Total: 8 files


βœ… Ready to Execute

Architecture: BIBLE-compliant emergent contacts Risk: LOW (additive changes) Effort: 8 hours Status: APPROVED

Next: Begin Phase 1 (Database Migration)


Created: 2026-01-09 Author: Claude Sonnet 4.5 + Founder Approval Status: Ready to Execute

Last updated

Was this helpful?