ScopeLogix.CLI
1.2.0
dotnet tool install --global ScopeLogix.CLI --version 1.2.0
dotnet new tool-manifest
dotnet tool install --local ScopeLogix.CLI --version 1.2.0
#tool dotnet:?package=ScopeLogix.CLI&version=1.2.0
nuke :add-package ScopeLogix.CLI --version 1.2.0
ScopeLogix
ScopeLogix is a scope and cost estimation SaaS platform. It lets teams build hierarchical project estimates (Epics → Features → User Stories → Tasks), manage approval workflows, track change requests, generate proposals, and sync work items to Azure DevOps, Jira, or NetSuite.
Table of Contents
- Solution Structure
- Tech Stack
- Architecture
- Domain Model
- Authentication & Authorization
- API Design
- Frontend
- Configuration
- Feature Flags & Canary Releases
- Running Locally
- Testing
- Key Design Decisions
Solution Structure
ScopeLogix/
├── ScopeLogix.API/ # ASP.NET Core REST API
│ ├── Controllers/ # 21 controllers (estimates, billing, integrations, etc.)
│ ├── Services/ # 50+ business logic services
│ └── Contracts/ # Request/response DTOs
├── ScopeLogix.Data/ # Data access layer
│ ├── DbContext/ # ScopeLogixDbContext
│ ├── EntityConfigurations/# EF Core Fluent API configs (20+ files)
│ ├── Migrations/ # EF Core code-first migrations
│ └── Repositories/ # Repository implementations
├── ScopeLogix.Objects/ # Domain models and enums (shared)
├── ScopeLogix.CLI/ # `scopelogix` global NuGet tool (login, import)
├── ScopeLogix.Tests/ # xUnit integration + unit tests
├── ScopeLogix.Website/ # React 19 + TanStack frontend
├── database/ # Raw SQL scripts
├── docs/ # Additional documentation
└── scripts/ # Deployment and utility scripts
Tech Stack
Backend
| Concern | Technology |
|---|---|
| Runtime | .NET 10 / ASP.NET Core 10 |
| ORM | Entity Framework Core 10 |
| Database | SQL Server (default) or PostgreSQL |
| Auth | JWT Bearer, API Key, Azure Entra ID OAuth |
| Passwords | BCrypt.Net-Next |
| Mapping | Mapster |
| AI | Anthropic (Claude), Azure OpenAI, GitHub Models |
| Billing | Stripe |
| Storage | Azure Blob Storage |
| Azure Service Bus → Azure Communication Services (SMTP fallback) | |
| Documents | QuestPDF (PDF), OpenXml (Word) |
| Observability | OpenTelemetry (traces → Jaeger) |
| Integrations | Azure DevOps REST API, Jira Cloud, NetSuite |
| API Docs | Swagger / Swashbuckle |
| Rate Limiting | ASP.NET Core built-in rate limiter |
Frontend
| Concern | Technology |
|---|---|
| Framework | React 19 |
| Build | Vite 8 |
| Language | TypeScript 5.7 |
| Routing | TanStack Router (file-based) |
| Server state | TanStack React-Query |
| Tables | TanStack React-Table |
| Forms | React Hook Form + Zod |
| UI components | shadcn/ui + Radix UI |
| Styling | Tailwind CSS 4 |
| Rich text | Tiptap |
| Charts | Recharts |
| Testing | Vitest + React Testing Library |
Architecture
Layer Overview
HTTP Request
│
▼
Controller (validates auth, delegates to service, maps response)
│
▼
Service (business logic, orchestrates repositories)
│
▼
Repository (EF Core queries, no business logic)
│
▼
DbContext (ScopeLogixDbContext, 51 DbSets)
Repository Pattern
Every aggregate has a dedicated repository interface in ScopeLogix.Data.Abstractions and a concrete EF Core implementation. Read-heavy aggregates split into IXxxReadRepository / IXxxWriteRepository interfaces. All are registered as Scoped in DI.
Service Layer
Services own all business logic. They depend on repositories, never directly on DbContext. A service like EstimateService coordinates Epics, Features, UserStories, Tasks, and Approvals repositories in a single operation.
Batch Loading (N+1 Prevention)
Services call BatchLoadHierarchyAsync() to load an entire estimate tree in ~5 queries using grouping and dictionary projections. EF Core lazy-loading is not used anywhere.
Global Exception Handling
Middleware in Program.cs maps exception types to HTTP status codes:
| Exception | Status |
|---|---|
KeyNotFoundException |
404 |
UnauthorizedAccessException |
403 |
ArgumentException / InvalidOperationException |
400 |
| Unhandled | 500 |
All errors are written to the ErrorLog table with the user ID, IP, TraceId, and a sanitized stack trace (sensitive keys like password, token, apikey are redacted automatically).
Rate Limiting
Three named policies protect different surface areas:
| Policy | Limit | Applied to |
|---|---|---|
auth |
5 req/min (sliding) | Login, signup, password reset |
config |
15 req/min (sliding) | Integration config mutations |
general |
120 req/min (fixed) | Authenticated endpoints |
| Global fallback | 300 req/min per IP | Everything else |
Domain Model
Estimate Hierarchy
Estimate
├── Epic(s)
│ ├── Feature(s)
│ │ ├── UserStory(s)
│ │ │ └── Task(s)
│ │ └── Task(s) ← tasks can attach directly to a Feature
│ └── ...
├── ChangeRequest(s) ← parallel snapshot hierarchy (see below)
├── EstimateApproval(s) ← PM, Technical, SubscriptionAdmin roles
└── EstimateTeamMember(s) ← used for complexity factor calculation
Key Entities
Estimate — The top-level document. Stores pricing parameters (BaseCostPerSP, RateMultiplier, PMPercentage, QAPercentage, ConversionRate, ValueCaptureRate). TotalPoints, TotalStoryPoints, and TotalCost are computed in services and not persisted.
Project — Groups estimates. Owns a default BaseCostPerSP inherited by new estimates. Maps to a NetSuite customer.
ChangeRequest — A proposed scope addition. Pricing parameters are snapshotted at creation time so the CR cost does not drift if the parent estimate is later repriced. It carries its own full hierarchy (ChangeRequestEpic → ChangeRequestFeature → ChangeRequestUserStory → ChangeRequestTask).
Subscription — Organization billing unit. Types: Free, Individual, Professional, Business. Backed by Stripe (customerId, subscriptionId, extra seats).
WorkItem — Sprint/agile tracking, independent of the estimate hierarchy. Has status (New, ToDo, InProgress, InReview, Done, Blocked, Removed) and time entries.
Skill Matrix — Skill + UserSkill (proficiency) + EstimateSkillRequirement → drives TeamComplexityFactor on the estimate.
Authentication & Authorization
Schemes
- JWT Bearer — primary scheme for the web app
- API Key (
X-Api-Keyheader) — for external tool integrations; handled byApiKeyAuthenticationHandler; only the hash is stored in the database - Azure Entra ID OAuth — used for the Azure DevOps integration flow
Policies
| Policy | Allowed roles |
|---|---|
SystemAdmin |
IsAdmin claim OR SystemAdmin role |
SubscriptionAdmin |
SystemAdmin OR SubscriptionAdmin role |
Approver |
SystemAdmin, SubscriptionAdmin, PmApprover, TechnicalApprover |
API Design
- All routes are prefixed with
/api/viaApiPrefixConvention - URL-based versioning:
/api/v1/...(default version 1.0) - JSON uses camelCase naming; enums serialized as strings
- Controllers document responses with
[ProducesResponseType]attributes - Health endpoints:
GET /api/health(liveness) andGET /api/health/ready(readiness, restricted to loopback) - Security headers applied globally: CSP,
X-Frame-Options: DENY,X-Content-Type-Options: nosniff - Response compression: Brotli (fastest) + Gzip (optimal)
Multi-Provider AI
An IAiScopeProviderFactory resolves to Anthropic, Azure OpenAI, or GitHub Models at runtime based on configuration. Each provider implements IAiScopeProvider. Adding a new provider requires implementing one interface and registering it — no changes to calling code.
Frontend
Routes live under ScopeLogix.Website/src/routes/ and are picked up automatically by the TanStack Router Vite plugin (output written to routeTree.gen.ts — do not edit by hand).
src/routes/
├── __root.tsx # Root layout
├── _authenticated.tsx # Auth boundary (redirects to /login)
├── _authenticated/ # All protected pages
│ ├── admin/ # System admin panel
│ └── ...
├── login.tsx
├── register.tsx
├── pricing.tsx
└── ...
Server state (API data) is managed entirely by TanStack React-Query. The custom apx.rest client is the HTTP abstraction used by query/mutation hooks.
Forms use React Hook Form with Zod schemas for validation. Rich text (proposals, comments) is handled by Tiptap with DOMPurify sanitization on output.
Configuration
Configuration is resolved in this order (highest wins): user secrets → environment variables → appsettings.{Env}.json → appsettings.json.
Required secrets (must be set before startup):
Jwt__Key # ≥32 character signing key
ConnectionStrings__DefaultConnection
Common optional settings:
{
"DatabaseProvider": "sqlserver", // or "postgres"
"Frontend": {
"BaseUrl": "http://localhost:3000"
},
"Storage": {
"ConnectionString": "UseDevelopmentStorage=true",
"ContainerName": "estimatedocuments"
},
"Stripe": { "SecretKey": "sk_...", "PublishableKey": "pk_...", "WebhookSecret": "whsec_..." },
"DevOps": { "PersonalAccessToken": "...", "Organization": "..." },
"Jira": { ... },
"NetSuite": { ... },
"Jaeger": { "OtlpEndpoint": "http://localhost:4317" }
}
Startup validates required configuration via ValidateDataAnnotations() + ValidateOnStart() and will fail fast with a clear error message if something is missing.
Feature Flags & Canary Releases
Feature flags let us decouple deploy from release: ship code to production "dark," then turn a feature on for a growing cohort (internal → 1% → 10% → 100%) with an instant kill-switch. Flags are backed by PostHog Cloud.
The guiding rule is the server is the single source of truth. The API evaluates a user's flags (with targeting context), and the React client is bootstrapped with those values so the API and UI never disagree. Every evaluation fails safe (OFF) — a PostHog outage can't break the app.
How it works
API IFeatureFlagService.IsEnabledAsync("canary-x", userId)
└─ PostHog .NET SDK, local evaluation (flag defs cached in-process)
context: distinctId = User.Id; personProps = { plan, email, isInternal }
▲ gates endpoints/behavior │
│ ▼
Web GET /api/feature-flags/me → { distinctId, flags: { key: bool } }
└─ posthog-js init({ bootstrap: { distinctID, featureFlags } }) (no flicker)
└─ useFlag("canary-x") gates UI · isFlagEnabled() gates routes
Key files: Services/FeatureFlagService.cs, Controllers/FeatureFlagsController.cs, Settings/FeatureFlagSettings.cs (backend); src/lib/posthog.ts, src/lib/flags.ts, src/main.tsx (frontend).
One-time setup
Create a PostHog Cloud project and grab two keys:
- the project token (
phc_…) — a public client key, safe to commit - a personal API key (
phx_…) — enables server-side local evaluation (no per-request round-trip); treat as a secret.
- the project token (
Backend — add the
PostHogandFeatureFlagssections toappsettings.json, and set the personal key via user-secrets:{ "PostHog": { "ProjectToken": "phc_...", "HostUrl": "https://us.i.posthog.com" // or https://eu.i.posthog.com }, "FeatureFlags": { "KnownFlags": [ "canary-demo" ], // flags exposed to the client bootstrap "InternalEmailDomains": [ "scopelogix.com" ] } }dotnet user-secrets set "PostHog:PersonalApiKey" "phx_..." --project ScopeLogix.APIIf
PostHog:ProjectTokenis empty, the API registers aDisabledFeatureFlagServiceand every flag reads OFF — so the app boots fine with no PostHog config.Frontend — set the client key in
ScopeLogix.Website/.env(or a mode-specific.env.*):VITE_POSTHOG_KEY=phc_... VITE_POSTHOG_HOST=https://us.i.posthog.comIn production, point
VITE_POSTHOG_HOSTat a same-origin reverse proxy (e.g./ingest→ PostHog) so the strict CSP stays same-origin and ad-blockers don't drop events. LeavingVITE_POSTHOG_KEYblank disables PostHog on the client entirely.
Targeting cohorts
The server attaches these PostHog properties for each user; build flag release conditions against them in the PostHog dashboard:
| Property | Source | Use for |
|---|---|---|
| distinct id | User.Id |
Sticky percentage rollouts (same user stays in/out as % ramps) |
isInternal |
User.IsAdmin or an InternalEmailDomains email |
Internal/dogfood allowlist |
plan |
Subscription.Type (Free/Individual/Professional/Business) |
Plan-gated previews |
email |
User.Email |
Targeting specific users |
Adding a canary flag
In PostHog, create a flag keyed
canary-<feature>. Add release conditions, e.g. start withisInternal = true → 100%, then add a sticky percentage and/orplan in [Professional, Business]. Put an owner + intended removal date in the description.Expose it to the client by adding the key to
FeatureFlags:KnownFlagsinappsettings.json(only listed keys are delivered via/api/feature-flags/me).Gate the API wherever the feature does work:
if (!await featureFlags.IsEnabledAsync("canary-<feature>", userId)) return NotFound(); // or run the old pathGate the UI — hide a component, or guard a route:
// Component import { useFlag } from '@/lib/flags' const enabled = useFlag('canary-<feature>') if (!enabled) return null // Route guard (TanStack Router) import { isFlagEnabled } from '@/lib/flags' export const Route = createFileRoute('/...')({ beforeLoad: () => { if (!isFlagEnabled('canary-<feature>')) throw redirect({ to: '/' }) }, })A live reference example is
CanaryDemoBannerinsrc/routes/_authenticated/dashboard.tsx, gated by thecanary-demoflag.Roll out in PostHog: internal → 1% → 10% → 50% → 100%, watching metrics. To abort, flip the flag off — both API and UI revert on the next evaluation. Once a feature is at 100% and stable, delete the flag and remove the dead code (flags are short-lived; avoid flag debt). Flags control exposure, not authorization — keep all existing role/access checks.
Testing gated code
Unit-test flag logic by mocking IPostHogClient (no PostHog account needed) — see ScopeLogix.Tests/Services/FeatureFlagServiceTests.cs:
var posthog = new Mock<IPostHogClient>();
posthog.Setup(p => p.IsFeatureEnabledAsync("canary-x", It.IsAny<string>(),
It.IsAny<FeatureFlagOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// assert the gated branch runs when ON, and the fail-safe (OFF) path when it throws
For end-to-end verification, log in as an internal user (@scopelogix.com) with canary-demo set to internal-only, confirm the gated surface appears, then flip the flag off and confirm both API and UI revert.
Running Locally
Prerequisites
- .NET 10 SDK
- Node.js 22+
- SQL Server (or PostgreSQL)
- Azurite (optional, for local blob storage)
Backend
# Set secrets (one-time setup)
dotnet user-secrets set "Jwt__Key" "your-secret-key-at-least-32-chars" --project ScopeLogix.API
dotnet user-secrets set "ConnectionStrings__DefaultConnection" "Server=localhost;Database=ScopeLogix;Trusted_Connection=True;" --project ScopeLogix.API
# Run — EF Core migrations are applied automatically on startup
dotnet run --project ScopeLogix.API
# API: https://localhost:7xxx
# Swagger: https://localhost:7xxx/swagger
Frontend
cd ScopeLogix.Website
npm install
npm run dev
# App: http://localhost:3000
Testing
# Run all backend tests
dotnet test
# With coverage report
dotnet test --collect:"XPlat Code Coverage"
# Frontend tests
cd ScopeLogix.Website && npm test
Test stack: xUnit, Moq, FluentAssertions, Microsoft.AspNetCore.Mvc.Testing (WebApplicationFactory), EF Core InMemory provider.
ScopeLogix.Tests/
├── Controllers/ # Integration tests via WebApplicationFactory
├── Services/ # Unit tests with mocked repositories
├── Startup/ # DI configuration tests
└── TestHelpers/ # Shared fixtures and mock builders
All tests follow Arrange-Act-Assert. External dependencies (repositories, HTTP clients, Stripe) are mocked. Controller tests construct a ClaimsPrincipal to simulate authenticated users.
Key Design Decisions
Snapshot Pricing on Change Requests
When a ChangeRequest is created, the estimate's pricing parameters (BaseCostPerSP, RateMultiplier, etc.) are copied into the CR. Later repricing of the parent estimate does not retroactively change approved CR costs. This preserves a reliable audit trail at the cost of some data duplication.
Parallel Hierarchies for Change Requests
Change requests maintain their own independent item hierarchy (ChangeRequestEpic → ...Task) rather than referencing the estimate's items via foreign keys. This lets a CR be fully evaluated in isolation without coupling its cost to the current state of the parent estimate's items.
Computed Totals vs. Persisted Rollups
Fields like TeamComplexityFactor, GrandTotalBudget, and ApprovedChangeRequestsCost are computed in services or Mapster mappers and are not stored in the database. This avoids stale computed values and makes calculation logic easy to change. Exception: denormalized rollups on ChangeRequest itself (e.g., TotalCost) are persisted for quick list views.
Batch Loading Over Lazy Navigation
The service layer explicitly batch-loads entire hierarchy trees rather than relying on EF Core lazy-loading navigation properties. This gives predictable query counts and avoids accidental N+1 regressions as the hierarchy deepens.
Pluggable AI Providers
The IAiScopeProviderFactory + IAiScopeProvider interface pair decouples AI-specific code from the estimate generation service. Adding a new provider (e.g., Google Gemini) requires implementing one interface and registering it — no changes to calling code.
Multi-Database Support
DatabaseProvider in config selects between SQL Server and PostgreSQL at startup. SQL Server connections use EF Core's built-in retry policy (5 retries, 10 s max delay). This allows the same codebase to run on either engine without branching.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.