Source:
ocean/docs/tenant-provisioning-workplan.md| ✏️ Edit on GitHub
Tenant Provisioning Implementation Workplan
Current State Analysis
What's Working
- ✅ User signup creates organization via
handle_new_user()trigger - ✅ Stripe customer creation via
sync_user_to_stripewebhook (separate) - ✅ Comprehensive
provision-user-resourcesEdge Function exists
What's Missing
- ❌ No trigger calling the comprehensive provisioning function
- ❌ Two separate webhooks causing potential race conditions
- ❌ No Neon database provisioning happening
- ❌ No status tracking for provisioning steps
Implementation Plan
Phase 1: Database Schema Updates
-- 1. Add provisioning status tracking to organizations
ALTER TABLE organizations
ADD COLUMN IF NOT EXISTS provisioning_status jsonb DEFAULT jsonb_build_object(
'stripe', 'pending',
'neon', 'pending',
'overall', 'pending',
'last_updated', now()
),
ADD COLUMN IF NOT EXISTS provisioning_started_at timestamptz,
ADD COLUMN IF NOT EXISTS provisioning_completed_at timestamptz,
ADD COLUMN IF NOT EXISTS provisioning_error jsonb;
-- 2. Create provisioning_logs table for debugging
CREATE TABLE IF NOT EXISTS provisioning_logs (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
organization_id uuid REFERENCES organizations(id),
user_id uuid REFERENCES auth.users(id),
step text NOT NULL,
status text NOT NULL,
details jsonb,
error jsonb,
created_at timestamptz DEFAULT now()
);
-- 3. Add indexes for performance
CREATE INDEX idx_provisioning_logs_org_id ON provisioning_logs(organization_id);
CREATE INDEX idx_provisioning_logs_created_at ON provisioning_logs(created_at);
CREATE INDEX idx_organizations_provisioning_status ON organizations((provisioning_status->>'overall'));
Phase 2: Consolidate Webhooks
Option A: Single Comprehensive Webhook (Recommended)
-- 1. Create new consolidated webhook function
CREATE OR REPLACE FUNCTION public.provision_new_tenant()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
request_id bigint;
service_role_key text;
edge_function_url text;
org_record record;
BEGIN
-- Get the organization that was just created
SELECT * INTO org_record
FROM organizations
WHERE id = (
SELECT organization_id
FROM organization_members
WHERE user_id = NEW.id
ORDER BY created_at DESC
LIMIT 1
);
IF NOT FOUND THEN
RAISE LOG 'No organization found for user %, skipping provisioning', NEW.id;
RETURN NEW;
END IF;
-- Get service role key from vault
SELECT decrypted_secret INTO service_role_key
FROM vault.decrypted_secrets
WHERE name = 'SERVICE_ROLE_KEY';
-- Build Edge Function URL
edge_function_url := current_setting('app.settings.supabase_url', true) || '/functions/v1/provision-user-resources';
-- Update provisioning status
UPDATE organizations
SET
provisioning_status = jsonb_build_object(
'stripe', 'in_progress',
'neon', 'in_progress',
'overall', 'in_progress',
'last_updated', now()
),
provisioning_started_at = now()
WHERE id = org_record.id;
-- Log provisioning start
INSERT INTO provisioning_logs (organization_id, user_id, step, status, details)
VALUES (org_record.id, NEW.id, 'provisioning_started', 'in_progress',
jsonb_build_object('email', NEW.email, 'org_name', org_record.name));
-- Call comprehensive provisioning Edge Function
SELECT net.http_post(
url := edge_function_url,
body := jsonb_build_object(
'type', 'INSERT',
'table', 'users',
'record', row_to_json(NEW),
'organization', row_to_json(org_record)
),
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || service_role_key
),
timeout_milliseconds := 30000
) INTO request_id;
RAISE LOG 'Provisioning request % for user % and org %', request_id, NEW.email, org_record.name;
RETURN NEW;
END $$;
-- 2. Replace existing triggers
DROP TRIGGER IF EXISTS sync_user_to_stripe_on_insert ON auth.users;
DROP TRIGGER IF EXISTS provision_tenant_on_user_insert ON auth.users;
CREATE TRIGGER provision_tenant_on_user_insert
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.provision_new_tenant();
Phase 3: Update Edge Function
Update provision-user-resources to:
- Add status updates throughout the process
// After Stripe customer creation
await supabase
.from('organizations')
.update({
provisioning_status: {
stripe: 'completed',
neon: 'in_progress',
overall: 'in_progress',
last_updated: new Date().toISOString(),
},
})
.eq('id', organization.id)
// After Neon provisioning
await supabase
.from('organizations')
.update({
provisioning_status: {
stripe: 'completed',
neon: 'completed',
overall: 'completed',
last_updated: new Date().toISOString(),
},
provisioning_completed_at: new Date().toISOString(),
})
.eq('id', organization.id)
- Add comprehensive error handling
// On any error
await supabase
.from('organizations')
.update({
provisioning_status: {
...currentStatus,
overall: 'failed',
last_updated: new Date().toISOString(),
},
provisioning_error: {
message: error.message,
timestamp: new Date().toISOString(),
step: failedStep,
},
})
.eq('id', organization.id)
Phase 4: Add Monitoring & Recovery
- Create monitoring function
CREATE OR REPLACE FUNCTION public.check_provisioning_status()
RETURNS TABLE (
organization_id uuid,
organization_name text,
provisioning_status jsonb,
duration interval,
needs_attention boolean
)
LANGUAGE sql
AS $$
SELECT
id,
name,
provisioning_status,
CASE
WHEN provisioning_completed_at IS NOT NULL
THEN provisioning_completed_at - provisioning_started_at
ELSE now() - provisioning_started_at
END as duration,
CASE
WHEN provisioning_status->>'overall' = 'failed' THEN true
WHEN provisioning_status->>'overall' = 'in_progress'
AND provisioning_started_at < now() - interval '10 minutes' THEN true
ELSE false
END as needs_attention
FROM organizations
WHERE provisioning_started_at IS NOT NULL
ORDER BY provisioning_started_at DESC;
$$;
- Create retry function
CREATE OR REPLACE FUNCTION public.retry_tenant_provisioning(org_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
org_record record;
user_record record;
request_id bigint;
BEGIN
-- Get organization and user details
SELECT o.*, u.*
INTO org_record
FROM organizations o
JOIN organization_members om ON om.organization_id = o.id
JOIN auth.users u ON u.id = om.user_id
WHERE o.id = org_id
AND om.role = 'owner'
LIMIT 1;
-- Reset status
UPDATE organizations
SET
provisioning_status = jsonb_build_object(
'stripe', CASE
WHEN stripe_customer_id IS NOT NULL THEN 'completed'
ELSE 'pending'
END,
'neon', 'pending',
'overall', 'pending',
'last_updated', now()
),
provisioning_error = null
WHERE id = org_id;
-- Trigger provisioning again
-- (Implementation details...)
RETURN jsonb_build_object(
'success', true,
'organization_id', org_id,
'message', 'Provisioning retry initiated'
);
END $$;
Phase 5: Add GraphQL Support
extend type Organization {
provisioningStatus: ProvisioningStatus
provisioningError: ProvisioningError
}
type ProvisioningStatus {
stripe: String!
neon: String!
overall: String!
lastUpdated: DateTime!
duration: Int
}
type ProvisioningError {
message: String!
timestamp: DateTime!
step: String
}
extend type Query {
organizationsNeedingAttention: [Organization!]!
}
extend type Mutation {
retryProvisioning(organizationId: ID!): ProvisioningResult!
}
Migration Steps
- Backup current data
- Deploy schema changes
- Deploy updated Edge Function
- Test with single user
- Create new trigger
- Disable old triggers
- Monitor provisioning
- Run recovery for existing orgs without Neon
Testing Plan
-
Unit Tests
- Edge Function logic
- Database trigger behavior
- Error scenarios
-
Integration Tests
- Full signup flow
- Retry mechanisms
- Status tracking
-
Load Tests
- Multiple simultaneous signups
- Network failures
- API rate limits
Rollback Plan
If issues arise:
- Disable new trigger
- Re-enable old triggers
- Manually provision failed accounts
- Fix issues and redeploy
Success Metrics
- All new signups get Stripe + Neon within 2 minutes
- 0% orphaned organizations (org without Stripe or Neon)
- Provisioning status accurately reflects state
- Failed provisioning can be retried successfully