System Architecture
End-to-end view of the TPL LeadGen pipeline — auth, ingestion, CRM push, and RBAC
Browser
Pages — served from
wwwroot/
login.html — Auto-redirect to Microsoft SSO · error displayindex.html — Dashboard & stat cardsleads.html — Table · sort · search · CRM push with confirm dialog · “Sent” badgesworker-runs.html — Ingestion history · Run Nowsettings.html — Schedule · limits · CRM credentials (per-permission)users.html — User management · role assignment adminroles.html — Role & permission editor admin
Vanilla JS ·
fetch() · no framework · no build stepjs/auth.js — shared JWT module · requireAuth() · authFetch() · permission gates
Auth Module
js/auth.jsCaptures
?sso_token= on every page load → stores in localStorageDecodes JWT payload client-side to read user, role, permissions
requireAuth() — redirects unauthenticated users to /login.htmlauthFetch() — injects Authorization: Bearer <jwt> on all API callsHides / shows nav links based on
admin.manage_users / admin.manage_rolesJWT stored in localStorage · expiry checked client-side · 401 response clears token & re-redirects
HTTPS
Bearer JWT
JSON
Bearer JWT
JSON
→
←
ASP.NET Core API · net8.0
Static File Middleware
UseDefaultFiles() + UseStaticFiles()Serves all 9 HTML pages & JS/CSS assets from
wwwroot/Auth enforced client-side via
auth.js + server-side via [RequireAuthorization]Auth & SSO Endpoints public
GET /oauth2/login — redirect to Microsoft AAD /authorizeGET /oauth2/callback — exchange code → extract email → find/provision user → issue JWT → redirectGET /api/auth/me — current user profile & permissionsTokenService issues HS256 JWT · claims: sub, email, name, role, roleId, permissions[] · 48 h expiry
Admin API [auth + permission]
GET /api/users · POST · PUT /{id} · DELETE /{id} — admin.manage_usersPATCH /api/users/{id}/password — emergency password resetGET /api/roles · POST · PUT /{id} · DELETE /{id} — admin.manage_rolesPUT /api/roles/{id}/permissions — replace permission setGET /api/permissions — catalog grouped by area · GET /api/marketsDashboard & Leads API [auth + permission]
GET /api/leads — paginated · search · sort · includes CRM push flags · leads.viewPATCH /api/leads/{id}/status — leads.edit_statusPATCH /api/leads/{id}/notes — leads.edit_notesGET /api/leads/stats — counts by status · dashboard.viewGET /api/worker-runs — incl. requestJson & responseJson · dashboard.view_worker_runsGET /api/settings · POST — settings.view / settings.editPOST /api/worker/run — queue manual trigger · settings.trigger_workerPOST /dev/ingest · POST /dev/skip-trace · /swagger
dev only
CRM Sync API user-triggered only
GET /api/leads/{id}/crm-sync — per-lead CRM status · leads.push_to_crmPOST /api/leads/{id}/crm-sync/ServiceMinder — push to ServiceMinder after confirmPOST /api/leads/{id}/crm-sync/ServiceTitan — push to ServiceTitan after confirmGET /api/crm/settings · PUT — settings.crm.view / settings.crm.editWrites CrmPushLog per attempt (userId · userName · requestJson · externalId · timestamp) · Nothing runs in background · Confirmation dialog required before every push
Application Services (Core layer)
TokenService — issues HS256 JWT with user claims + permissions JSON array
LeadIngestionService — dedup · batch create · ZIP rotation · stamps WorkerRunId on each lead · emits RentCastApiLog per ZIP
CrmSyncService — per-CRM push · writes CrmPushLog · updates CrmSyncStatus with externalId
SkipTraceService — enriches buyer contact data
WorkerSettingsService — live-editable schedule persisted to
runtime-settings.jsonEF Core 8 · Npgsql · IUnitOfWork pattern · ILeadRepository · ITargetZipCodeRepository
Background Worker (IHostedService)
RentCastIngestionWorker — fires on startup, then every N hours
30-second poll loop — responds to schedule or “Run Now” trigger
Pre-generates
WorkerRunId → passes to IngestAsync()Persists
WorkerRun record (requestJson + responseJson) + all RentCastApiLog rows per cycleMax 264 API calls / run · 1,000 calls / month budget
EF Core
read / write
read / write
→
PostgreSQL · tpl_leadgen
Auth & RBAC Tables
app_users email (unique) · firstName · lastName · roleId · marketId · isActiveroles name · description · isSystem — seeded: Admin, Viewerpermissions key · name · groupId — 12 permission keys across 4 groupsrole_permissions roleId + permissionId (composite PK)permission_groups Dashboard · Leads · Settings · Adminmarkets DFW · Austin · Houston · San AntonioSeeded at startup by AuthSeeder · Admin gets all 12 permissions · Viewer gets dashboard.view + leads.view
Lead Domain Tables
leads status · source · sourceId (unique) · workerRunId (FK)buyers name · email · phone · skipTracedAtproperties type · saleDate · price · sqft · beds/bathsaddresses street · city · state · zip · lat/lontarget_zip_codes 264 ZIPs · lastQueriedAtoutreach_events channel · status · occurredAtcrm_sync_statuses ServiceMinder · ServiceTitan · state · externalIdAudit & Debug Log Tables
crm_push_logs leadId · userId · userName · crmType · requestId · requestJson · externalId · success · attemptedAtrentcast_api_logs workerRunId · zipCode · requestUrl · responseJson · statusCode · recordsReturned · success · attemptedAtworker_runs ranAt · succeeded · metrics · requestJson · responseJsonOne CrmPushLog per push attempt (including failures) · One RentCastApiLog per ZIP per run · WorkerRun summarises the full cycle
EF Core Migrations
InitialSchema
AddPerformanceIndexes
AddWorkerRuns
AddAuthAndRbac
AddCrmPushLog
AddRentCastLogsAndWorkerRunDebug latest
Key indexes: leads.status · leads.sourceId (unique) · leads.workerRunId · app_users.email (unique) · worker_runs.ranAt · rentcast_api_logs.workerRunId
Npgsql.EntityFrameworkCore.PostgreSQL 8.0.10
Npgsql.EntityFrameworkCore.PostgreSQL 8.0.10
HTTPS
(API only)
(API only)
→
←
External Services
Microsoft Azure AD SSO
login.microsoftonline.com/{tenantId}oauth2/v2.0/authorize — OpenID Connect flowoauth2/v2.0/token — exchange auth code for ID tokenID token claims used:
email · given_name · family_nameNew users auto-provisioned with Viewer role on first login
Configured via AzureAdOptions: TenantId · ClientId · ClientSecret · No passwords managed by app
RentCast API
api.rentcast.io GET /v1/listings/saleRecent deed sales by ZIP code · returns buyer name · sale price · address · bed/bath
Response JSON logged to
rentcast_api_logs per callRetry on 5xx / network only — 3 attempts, exponential back-off · 4xx returns failed result
1,000 calls / month budget · ~264 calls per full scan · Stub mode when no API key
ServiceMinder CRM
REST API · runtime credentials from encrypted
crm-settings.jsonReturns confirmation / job ID stored as
externalId in CrmSyncStatusPurple “✓ Sent to Minder” badge shown on lead after successful push
Credentials encrypted at rest with AES-256-GCM · Push is user-triggered with confirmation · Singleton client (connection pool)
ServiceTitan CRM
OAuth 2.0 client credentials flow · access token cached in memory
Returns confirmation ID stored as
externalId in CrmSyncStatusBlue “✓ Sent to Titan” badge shown on lead after successful push
Credentials encrypted at rest with AES-256-GCM · Push is user-triggered with confirmation · Singleton client (token cache)
BatchSkipTracing API
Contact enrichment per buyer — email · phone · mailing address
Stamps
SkipTracedAt on buyer after each attemptOn-demand via
POST /dev/skip-trace · future: scheduled cronDev vs Production — Key Differences
| Aspect | Local Development | Azure Production |
|---|---|---|
| Environment | Development |
Production |
| Config source | appsettings.Development.Local.json (git-ignored) |
Azure App Settings — set via CLI, never in files |
| Authentication | Microsoft AAD SSO — requires TenantId / ClientId / ClientSecret | Same Microsoft AAD SSO — redirect URI must match App Registration |
| RentCast API key | Usually not set → Stub mode — synthetic data | Must be set → Live mode |
| Database | Local PostgreSQL localhost:5432, no SSL |
Azure PostgreSQL Flexible Server, SslMode=Require |
| Dev endpoints | POST /dev/ingest · POST /dev/skip-trace · Swagger at /swagger |
Not exposed — hidden when ASPNETCORE_ENVIRONMENT=Production |
| CRM credentials | Edited via Settings UI → encrypted in crm-settings.json locally |
Same encrypted file — persists across deploys if using persistent storage |
| Logs | Terminal console (colour-coded) | az webapp log tail streaming |
Auth Flow (SSO)
- Browser loads any protected page → no JWT → redirected to
/login.html login.htmlauto-redirects to/oauth2/login(Microsoft account picker)- User authenticates with Microsoft AAD
/oauth2/callbackexchanges auth code for ID token- Email extracted from ID token → user looked up in DB
- Existing user: loaded with role + permissions → JWT issued
- New user: auto-created with Viewer role → admin assigns role later
- Redirect to
/?sso_token=<jwt>→auth.jsstores token, cleans URL
Ingestion Flow
- Worker fires on startup, then every N hours (configurable)
- Pre-generates a
WorkerRunIdGUID before callingIngestAsync - Fetches the stalest target ZIPs up to API budget
- Calls RentCast per ZIP — logs raw response to
RentCastApiLog - Bulk dedup: one DB query returns already-ingested sourceIds
- Creates Lead → Property → Buyer → Address; stamps
WorkerRunIdon each lead - Commits in one DB transaction; persists
WorkerRun+ allRentCastApiLogrows
CRM Push Flow
- User clicks “→ SM” or “→ ST” button in the Leads table
- Browser shows confirm dialog: “Send this lead to [CRM]? This cannot be undone.”
- On confirm →
POST /api/leads/{id}/crm-sync/{crm} - CrmSyncService pushes lead payload to the CRM API
- Confirmation ID stored in
CrmSyncStatus.ExternalId CrmPushLogrow written: userId, userName, requestJson, externalId, success/error, timestamp- On success: button permanently replaced with colour-coded “✓ Sent” badge
- Subsequent page loads show badge instead of button (flag from
crm_sync_statuses)
RBAC Flow
- JWT payload contains
permissions[]JSON array baked in at login time - Every API endpoint calls
HasPermission(ctx, "key")before executing - Frontend
auth.jsreads permissions from JWT to show/hide UI elements - CRM settings split:
settings.crm.viewto see,settings.crm.editto change - Admin assigns roles via Users page → user re-logs in for new permissions to take effect
- Permission set per role managed via Roles page
Key Design Decisions
- Clean architecture — Core layer has zero infrastructure dependencies
- EF Core Fluent API only; no data annotations in Core entities
- IUnitOfWork → same DbContext scope across all repositories
- No background CRM sync — every CRM push is user-triggered with a confirm dialog
- CRM credentials encrypted at rest (AES-256-GCM) — never stored in plain text
- Every CRM push attempt written to
crm_push_logs(success or failure) - Every RentCast API call logged to
rentcast_api_logswith raw response JSON - Lead.WorkerRunId links each lead back to the exact ingestion run that created it
- Microsoft SSO only — no passwords managed by the app
- Stub HTTP clients in dev — no real API key needed to run locally
- No frontend framework — vanilla JS, zero build step, zero npm