ScopeLogix.CLI 1.2.0

dotnet tool install --global ScopeLogix.CLI --version 1.2.0
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local ScopeLogix.CLI --version 1.2.0
                    
This package contains a .NET tool you can call from the shell/command line.
#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

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
Email 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 (ChangeRequestEpicChangeRequestFeatureChangeRequestUserStoryChangeRequestTask).

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 MatrixSkill + UserSkill (proficiency) + EstimateSkillRequirement → drives TeamComplexityFactor on the estimate.


Authentication & Authorization

Schemes

  • JWT Bearer — primary scheme for the web app
  • API Key (X-Api-Key header) — for external tool integrations; handled by ApiKeyAuthenticationHandler; 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/ via ApiPrefixConvention
  • 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) and GET /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}.jsonappsettings.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

  1. 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.
  2. Backend — add the PostHog and FeatureFlags sections to appsettings.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.API
    

    If PostHog:ProjectToken is empty, the API registers a DisabledFeatureFlagService and every flag reads OFF — so the app boots fine with no PostHog config.

  3. 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.com
    

    In production, point VITE_POSTHOG_HOST at a same-origin reverse proxy (e.g. /ingest → PostHog) so the strict CSP stays same-origin and ad-blockers don't drop events. Leaving VITE_POSTHOG_KEY blank 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

  1. In PostHog, create a flag keyed canary-<feature>. Add release conditions, e.g. start with isInternal = true → 100%, then add a sticky percentage and/or plan in [Professional, Business]. Put an owner + intended removal date in the description.

  2. Expose it to the client by adding the key to FeatureFlags:KnownFlags in appsettings.json (only listed keys are delivered via /api/feature-flags/me).

  3. Gate the API wherever the feature does work:

    if (!await featureFlags.IsEnabledAsync("canary-<feature>", userId))
        return NotFound();   // or run the old path
    
  4. Gate 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 CanaryDemoBanner in src/routes/_authenticated/dashboard.tsx, gated by the canary-demo flag.

  5. 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
1.2.0 0 6/9/2026
1.1.0 96 5/14/2026
1.0.0 110 5/9/2026