CRM Leads
Enterprise contact-sales pipeline — capture, qualify, assign, and convert prospects to tenants from the admin panel.
The CRM Leads module is SCRIPE's built-in sales pipeline. It captures prospects who submit the Contact Sales form during the signup wizard, enriches each lead with discovery intelligence (business type, team size, priorities, recommended tier), and provides a full admin CRM workflow: list, detail drawer, status transitions, assignment, and one-click tenant conversion.
What is the Leads CRM?
A Lead represents a prospective customer who has expressed interest in the platform. Each lead carries contact info, discovery context from the signup wizard, and a lifecycle status that tracks the sales engagement from first contact to conversion. All data is soft-deleted, fully audited, and accessible only to admins with the appropriate permissions.
| Concept | Role | Example |
|---|---|---|
| Lead | A prospect who submitted a Contact Sales request | Acme Corp — Enterprise interest via signup wizard |
| Source | How the lead entered the system | Website form (signup wizard) or Admin-created manually |
| Status | Current stage in the sales lifecycle | New → Contacted → Qualified → Won / Lost / Converted |
| Assignment | Which admin is responsible for follow-up | sales@acme.internal assigned to jane.doe |
| Conversion | Turning a lead into a live tenant | One-click: creates tenant + assigns edition + sends setup email |
Ingestion and Deduplication Lifecycle
When a prospect submits a lead via the website signup wizard, the system performs validation and deduplication before creating a PlatformLead record. This includes checking for workspace subdomain conflicts, identifying colleague submissions for ABM targeting, and enforcing a daily lead registration cap.
| Check Name | Logic / Trigger | Error Code / Behavior |
|---|---|---|
| Workspace Collision | Checks if a tenant already exists with the same company name or normalized subdomain slug. | workspace_exists (Conflict error) |
| Deduplication | Checks active leads (non-Converted/non-Closed) with the same company name and exact email match. | duplicate_lead (Rejection error) |
| Colleague Submission | If company name matches an active lead but email is different, registers as colleague submission for Enterprise ABM. | Sets ParentLeadId to existing lead's ID |
| Daily Cap | Checks if count of leads created today is at or above the system cap (1,000 leads). | daily_cap_exceeded (Rejection error) |
// Inside SubmitContactSalesLeadCommandHandler.cs:
// Check 1: Workspace Collision
var slug = SubdomainHelper.Normalize(request.CompanyName);
if (await _tenantRepository.ExistsBySlugAsync(slug, ct))
return Result.Fail("workspace_exists");
// Check 2: Deduplication & ABM Parent Linking
var activeLead = await _leadRepository.GetActiveByCompanyAsync(request.CompanyName, ct);
if (activeLead != null)
{
if (activeLead.Email == request.Email)
return Result.Fail("duplicate_lead");
// Enterprise ABM: link colleague submission
parentLeadId = activeLead.Id;
}
// Check 3: Daily Cap
var todayCount = await _leadRepository.CountTodayAsync(ct);
if (todayCount >= 1000) // DailyLeadCap
return Result.Fail("daily_cap_exceeded");Lead Lifecycle
Leads move through a defined set of statuses. Status transitions are tracked in the activity timeline so the entire team can see the history of each opportunity.
Discovery Intelligence
Every lead captured via the signup wizard Contact Sales form is enriched with five discovery fields that the prospect answered during the onboarding questionnaire. These fields give the sales team instant context without requiring a follow-up call.
| Discovery Field | Source Question | Stored In |
|---|---|---|
| Business Type | Q1 — Industry selector | PlatformLead.BusinessType |
| Team Size | Q2 — Team size picker | PlatformLead.TeamSize |
| Priority | Q3 — Priority multi-select (max 3) | PlatformLead.Priority |
| Recommended Tier | Computed by recommendation engine | PlatformLead.RecommendedTier |
| Edition Key | Plan card selected before Contact Sales | PlatformLead.EditionKey |
Backend Architecture
The Leads feature follows the standard SCRIPE 3-project module layout. The PlatformLead entity lives in the Entitlements domain and is managed via a dedicated repository and CQRS command/query pipeline.
Entitlements.Domain/
├── Entities/
│ └── PlatformLead.cs # Lead entity (inherits AuditableEntity)
├── Enums/
│ ├── LeadStatus.cs # New, Contacted, Qualified, Won, Converted, Lost
│ └── LeadSource.cs # Website, Admin
└── Interfaces/
└── IPlatformLeadRepository.cs # Repository contract
Entitlements.Application/
├── Commands/
│ └── Leads/
│ ├── SubmitContactSalesLead/ # Public signup wizard form submission
│ ├── CreateLead/ # Admin-created lead
│ ├── UpdateLeadStatus/ # Status transition (New → Contacted → …)
│ ├── AssignLead/ # Assign/unassign to admin
│ ├── ConvertLeadToTenant/ # Atomic lead → tenant conversion
│ └── DeleteLead/ # Soft delete
├── Queries/
│ └── Leads/
│ ├── GetLeads/ # Paginated list with filters
│ ├── GetLeadById/ # Full detail
│ └── GetLeadActivity/ # Timeline of status changes + notes
└── DTOs/
└── Leads/
└── PlatformLeadResponse.cs # Full detail DTO
└── PlatformLeadListResponse.cs
Entitlements.Infrastructure/
└── Persistence/
└── PlatformLeadRepository.cs # EF Core implementationPlatformLead Entity
PlatformLead inherits from AuditableEntity (CreatedBy, CreatedAt, UpdatedBy, UpdatedAt, IsDeleted, RowVersion). All IDs are AES-encrypted in API transit. The entity is designed to hold both CRM lifecycle data and the discovery intelligence gathered during the signup wizard questionnaire.
public class PlatformLead : AuditableEntity
{
// ── Contact info ──────────────────────────────────────────────
public string CompanyName { get; set; } = string.Empty;
public string ContactName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string? Phone { get; set; }
// ── Interest ──────────────────────────────────────────────────
public string? EditionKey { get; set; } // Plan selected in wizard
public string? Message { get; set; } // Free-text message
public LeadSource Source { get; set; } = LeadSource.Website;
// ── Discovery Intelligence (from signup wizard) ───────────────
public string? BusinessType { get; set; } // Q1: erp / healthcare / general
public string? TeamSize { get; set; } // Q2: solo / 2-10 / 11-50 / …
public string? Priority { get; set; } // Q3: comma-separated, max 3
public string? RecommendedTier { get; set; } // Computed: free / pro / business / enterprise
// ── CRM Lifecycle ─────────────────────────────────────────────
public LeadStatus Status { get; set; } = LeadStatus.New;
public Guid? AssignedToAdminId { get; set; }
public string? Notes { get; set; }
// ── Conversion ────────────────────────────────────────────────
public DateTime? ConvertedAt { get; set; }
public string? ConvertedToTenantId { get; set; } // Encrypted
}API Endpoints
The LeadsController exposes 9 endpoints covering the full lead lifecycle. All endpoints require AdminOnly JWT authentication. The contact-sales submission endpoint is the only public route.
| Method | Route | Permission | Description |
|---|---|---|---|
| POST | /api/v1/leads/contact-sales | Public (no auth) | Submit a Contact Sales request from the signup wizard |
| POST | /api/v1/leads | leads.create | Admin-created lead |
| GET | /api/v1/leads | leads.view | Paginated list with filters (status, source, assigned) |
| GET | /api/v1/leads/{id} | leads.view | Full lead detail including Discovery data |
| GET | /api/v1/leads/{id}/activity | leads.view | Timeline of status changes and notes |
| PUT | /api/v1/leads/{id}/status | leads.update | Transition lead status |
| PUT | /api/v1/leads/{id}/assign | leads.update | Assign or unassign lead to admin |
| POST | /api/v1/leads/{id}/convert-to-tenant | leads.convert | Atomic conversion: create tenant + assign edition + mark Converted |
| DELETE | /api/v1/leads/{id} | leads.delete | Soft-delete a lead |
Convert to Tenant
The ConvertLeadToTenant command is an atomic operation that creates a live tenant from a qualified lead. The handler orchestrates tenant provisioning, edition assignment, activity logging, and status update in a single database transaction. If any step fails, the entire operation rolls back.
public record ConvertLeadToTenantCommand(
string EncryptedLeadId,
string TenantName,
string TenantCode,
string AdminEmail,
string? AdminUsername,
string? EditionId, // null = use lead's EditionKey
string? SubscriptionType, // null = handler decides
string Currency, // default: USD
bool SkipPayment = true // always true for contact-sales
) : ICommand<ConvertLeadToTenantResult>;
// Inside ConvertLeadToTenantCommandHandler.cs:
// 1. Check if lead is already Converted/Closed.
// 2. Resolve EditionId from lead.EditionKey if not provided.
// 3. Call ITenantProvisioner.ProvisionAsync() inside transaction:
// - Phase 1: Create tenant, seed default roles, and create tenant admin user.
// - Phase 2: Call _subscriptionManager.AssignEditionAsync() to create subscription.
// 4. Save custom feature overrides.
// 5. Update lead entity status = Converted, set ConvertedAt and ConvertedToTenantId.
// 6. Log Converted activity.
// 7. SaveChangesAsync() to commit the transaction.
// 8. Saga Compensation: If updating the lead fails after tenant provisioning,
// throw a critical exception to trigger database transaction rollback or manual reconciliation.Email Notifications
When a Contact Sales form is submitted, two branded HTML emails are dispatched asynchronously (fire-and-forget via Task.Run) to avoid blocking the API response. Both templates use inline CSS for maximum email client compatibility.
| Recipient | Trigger | Template Style | |
|---|---|---|---|
| Prospect Confirmation | lead.Email | Contact Sales form submitted | Indigo branded — thanks + what happens next |
| Sales Team Alert | Leads:SalesNotificationEmail (appsettings) | Contact Sales form submitted | Amber alert — company, contact, discovery data, admin link |
{
"Leads": {
"SalesNotificationEmail": "sales@yourcompany.com"
},
"SmtpSettings": {
"Host": "smtp-relay.gmail.com",
"Port": 587,
"Username": "noreply@yourcompany.com",
"Password": "<app-password>",
"FromName": "SCRIPE",
"FromEmail": "noreply@yourcompany.com",
"EnableSsl": true
}
}Frontend Architecture
The frontend leads sub-module follows the strict SCRIPE sub-module pattern: domain entities, data layer (service → mapper → repository), and presentation layer (viewmodel → view → components). All HTTP calls go through IApiService via DI — never directly in hooks.
src/modules/entitlements/leads/
├── locales/
│ ├── leads.en.ts # EN translations (drawer, stats, dialogs)
│ ├── leads.ar.ts # AR translations (native UTF-8)
│ └── index.ts
└── src/
├── domain/
│ ├── entities/
│ │ └── PlatformLead.ts # Rich entity with computed getters
│ └── interfaces/
│ └── ILeadsRepository.ts # Repository contract
├── data/
│ ├── models/
│ │ └── leads.models.ts # PlatformLeadDto + request models
│ ├── mappers/
│ │ └── LeadsMapper.ts # DTO ↔ PlatformLead entity
│ ├── services/
│ │ └── LeadsService.ts # HTTP calls via IApiService
│ └── repositories/
│ └── LeadsRepository.ts # Service → entity via mapper
└── presentation/
├── viewmodels/
│ └── useLeadsViewModel.ts # TanStack Query + mutations + drawer state
├── views/
│ └── LeadsView.tsx # Stats bar + table + all dialogs mounted
└── components/
├── LeadDetailDrawer.tsx # 480px slide-in drawer (8 sections)
├── AssignLeadDialog.tsx # Assign/unassign with admin selector
├── ConvertToTenantDialog.tsx # Pre-filled conversion form
└── CreateLeadDialog.tsx # 2-column admin lead creation formPlatformLead Entity (Frontend)
The PlatformLead domain entity wraps the raw DTO data with computed getters and display logic. The relativeTime getter uses Intl.RelativeTimeFormat for locale-aware relative timestamps. The discoveryTags getter aggregates the three discovery fields into a tag array for the drawer's discovery intelligence section.
export class PlatformLead {
constructor(private readonly data: PlatformLeadData) {}
// ── Identity ──────────────────────────────────────────────────
get id() { return this.data.id; }
get companyName() { return this.data.companyName; }
get contactName() { return this.data.contactName; }
get email() { return this.data.email; }
get phone() { return this.data.phone ?? ""; }
// ── CRM state ────────────────────────────────────────────────
get status() { return this.data.status; } // "New" | "Contacted" | …
get source() { return this.data.source; } // "Website" | "Admin"
get assignedToAdminId(){ return this.data.assignedToAdminId; }
get editionKey() { return this.data.editionKey ?? ""; }
// ── Discovery Intelligence ────────────────────────────────────
get businessType() { return this.data.businessType ?? ""; }
get teamSize() { return this.data.teamSize ?? ""; }
get priority() { return this.data.priority ?? ""; }
get recommendedTier() { return this.data.recommendedTier ?? ""; }
// ── Computed ─────────────────────────────────────────────────
get discoveryTags(): string[] {
return [this.businessType, this.teamSize, this.priority]
.filter(Boolean);
}
get isConverted() { return this.status === "Converted"; }
get relativeTime() {
// Uses Intl.RelativeTimeFormat for "2h ago" / "3 days ago"
const diff = Date.now() - new Date(this.data.createdAt).getTime();
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (diff < 3_600_000) return rtf.format(-Math.floor(diff / 60_000), "minute");
if (diff < 86_400_000) return rtf.format(-Math.floor(diff / 3_600_000), "hour");
return rtf.format(-Math.floor(diff / 86_400_000), "day");
}
}Permissions
Leads are gated behind five granular permissions following the standard SCRIPE permission format (module.action). Assign the leads.convert permission only to senior sales admins — it triggers tenant provisioning which is a high-impact operation.
| Permission Key | Description | Used By |
|---|---|---|
| leads.view | Read lead list and detail | LeadsController.GetAll, GetById, GetActivity |
| leads.create | Create a new lead from admin panel | LeadsController.Create |
| leads.update | Change status or assign lead | LeadsController.UpdateStatus, AssignLead |
| leads.convert | Convert lead to tenant (high-privilege) | LeadsController.ConvertToTenant |
| leads.delete | Soft-delete a lead | LeadsController.Delete |
Quick Start
The typical CRM flow from prospect submission to live tenant takes 5 steps. Conversion is the only step requiring senior admin permissions — all other transitions can be performed by any admin with leads.update.
Step 1: Prospect completes Contact Sales form in signup wizard
→ POST /api/v1/leads/contact-sales (no auth)
→ Lead created (Status: New, Source: Website)
→ Sales team receives alert email
→ Prospect receives confirmation email
Step 2: Sales admin reviews lead
→ GET /api/v1/leads → list with Discovery tags visible
→ Click row → LeadDetailDrawer opens with full context
Step 3: Admin assigns lead to team member
→ PUT /api/v1/leads/{id}/assign { adminId: "..." }
→ Lead status transitions to Contacted
Step 4: Qualify + advance status
→ PUT /api/v1/leads/{id}/status { status: "Qualified" }
→ PUT /api/v1/leads/{id}/status { status: "Won" }
Step 5: Convert to tenant (one-click)
→ POST /api/v1/leads/{id}/convert-to-tenant
→ Tenant created, edition assigned, setup email sent
→ Lead status: Converted, ConvertedToTenantId set