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.

ConceptRoleExample
LeadA prospect who submitted a Contact Sales requestAcme Corp — Enterprise interest via signup wizard
SourceHow the lead entered the systemWebsite form (signup wizard) or Admin-created manually
StatusCurrent stage in the sales lifecycleNew → Contacted → Qualified → Won / Lost / Converted
AssignmentWhich admin is responsible for follow-upsales@acme.internal assigned to jane.doe
ConversionTurning a lead into a live tenantOne-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 NameLogic / TriggerError Code / Behavior
Workspace CollisionChecks if a tenant already exists with the same company name or normalized subdomain slug.workspace_exists (Conflict error)
DeduplicationChecks active leads (non-Converted/non-Closed) with the same company name and exact email match.duplicate_lead (Rejection error)
Colleague SubmissionIf 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 CapChecks if count of leads created today is at or above the system cap (1,000 leads).daily_cap_exceeded (Rejection error)
SubmitContactSalesLeadCommandHandler.csC#
// 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.

New — Lead created via signup wizard Contact Sales form
admin outreach
Contacted — Sales admin reached out to the prospect
demo completed
Qualified — Lead confirmed as a real opportunity
deal agreed
Won — Deal agreed — ready for conversion
Lost — Prospect did not move forward
Converted — Tenant created, edition assigned, setup email sent

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 FieldSource QuestionStored In
Business TypeQ1 — Industry selectorPlatformLead.BusinessType
Team SizeQ2 — Team size pickerPlatformLead.TeamSize
PriorityQ3 — Priority multi-select (max 3)PlatformLead.Priority
Recommended TierComputed by recommendation enginePlatformLead.RecommendedTier
Edition KeyPlan card selected before Contact SalesPlatformLead.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.

Backend Structure — LeadsText
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 implementation

PlatformLead 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.

PlatformLead.csC#
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.

MethodRoutePermissionDescription
POST/api/v1/leads/contact-salesPublic (no auth)Submit a Contact Sales request from the signup wizard
POST/api/v1/leadsleads.createAdmin-created lead
GET/api/v1/leadsleads.viewPaginated list with filters (status, source, assigned)
GET/api/v1/leads/{id}leads.viewFull lead detail including Discovery data
GET/api/v1/leads/{id}/activityleads.viewTimeline of status changes and notes
PUT/api/v1/leads/{id}/statusleads.updateTransition lead status
PUT/api/v1/leads/{id}/assignleads.updateAssign or unassign lead to admin
POST/api/v1/leads/{id}/convert-to-tenantleads.convertAtomic conversion: create tenant + assign edition + mark Converted
DELETE/api/v1/leads/{id}leads.deleteSoft-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.

Leads UI — Sales admin initiates conversion on qualified lead
ConvertToTenantDialog — 4-step wizard: Plan Selection → Configuration → Feature Overrides → Confirm
Submit payload
ConvertLeadToTenantCommand — Executes in a single atomic database transaction
TenantProvisioner Phase 1 — Creates Tenant, Default Roles (Admin, User), and Admin User
Phase 1 complete
TenantProvisioner Phase 2 — Calls SubscriptionManager.AssignEditionAsync to create subscription
Phase 2 complete
Apply Custom Overrides — Saves custom feature overrides for the new tenant
Finalize Lead Status — Lead set to Converted, ConvertedToTenantId is mapped, activity logs saved
On DB Error (Alert)
Saga Compensation — If saving lead fails, throws critical exception to trigger manual rollback alert
ConvertLeadToTenantCommand.csC#
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.

EmailRecipientTriggerTemplate Style
Prospect Confirmationlead.EmailContact Sales form submittedIndigo branded — thanks + what happens next
Sales Team AlertLeads:SalesNotificationEmail (appsettings)Contact Sales form submittedAmber alert — company, contact, discovery data, admin link
appsettings.json — Email ConfigJSON
{
  "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.

Frontend — Leads Sub-Module StructureText
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 form

PlatformLead 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.

PlatformLead.tsTypeScript
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 KeyDescriptionUsed By
leads.viewRead lead list and detailLeadsController.GetAll, GetById, GetActivity
leads.createCreate a new lead from admin panelLeadsController.Create
leads.updateChange status or assign leadLeadsController.UpdateStatus, AssignLead
leads.convertConvert lead to tenant (high-privilege)LeadsController.ConvertToTenant
leads.deleteSoft-delete a leadLeadsController.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.

Typical CRM FlowText
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