---
name: backend-screen-controller
description: >
  Generates the **screen-driven** API stratum: one controller per section
  exposing one endpoint per screen, payload shaped from the pagespec. Routes
  under /api/screens/{plural}/{action} with Swagger group "screens".
  Companion of `scaffold-controller` (integration stratum). Both call the SAME
  Business layer — rules never duplicate.
phase: development/backend
cli: cli/scaffold-screen-controller
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# Screen Controller — API Layer (screen-driven stratum)

Generates one **screen-driven controller per section**. Each pagespec
(`pagespecs/<Entity>.<view>.md`) in the section produces one endpoint with a
payload **shaped from the pagespec columns/fields** — not the full entity. This
is the contract the generated React app consumes.

> **Two strata, one Business layer.** See `backend-controller/SKILL.md` for the
> integration stratum. Both call the same `I{Entity}Service` — never duplicate
> business rules between them.

## When to Use

- Phase 2b (API screen-driven) of `/ba-develop` — fans out one agent per section
- After Phase 2a (integration controller) — the screen controller calls the
  same Business methods + emits its own DTOs

## What it emits

For section `{Section}` with N pagespecs:

```
src/{Ns}.Api/Controllers/{Module}/Screens/{Section}ScreenController.cs
src/{Ns}.Application/{Module}/DTOs/Screens/{Entity}{View}ScreenDto.cs   × N (one per read screen)
```

## Controller pattern (output)

```csharp
[ApiController]
[ApiExplorerSettings(GroupName = "screens")]
[Route("api/screens/opportunities")]
[Authorize]
[Produces("application/json")]
public class OpportunitiesScreenController : ControllerBase
{
    private readonly IOpportunityService _service;
    public OpportunitiesScreenController(IOpportunityService service) => _service = service;

    /// <summary>List screen — SCR-CRM-PIPELINE-OPPORTUNITES-001 (Opportunity list).</summary>
    [HttpGet("list")]
    [RequirePermission(PipelinePermissions.Opportunites.Read)]
    public async Task<ActionResult<PaginatedResult<OpportunityListScreenDto>>> GetList(
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 20,
        [FromQuery] string? search = null,
        [FromQuery] string? sortBy = null,
        [FromQuery] string? sortDir = null,
        [FromQuery] Guid? clientId = null,   // one per spec.fkFilters — relation (FK) filter
        [FromQuery] string? stage = null,    // one per pagespec filters[] — select/text → string?,
        [FromQuery] bool? overdue = null,    //   boolean → bool?, date-range → DateTime? From/To
        [FromQuery] DateTime? closeDateFrom = null,
        [FromQuery] DateTime? closeDateTo = null,
        CancellationToken ct = default)
    {
        // Pagespec filter params are passed as NAMED args (never positional) so two
        // adjacent string? params can never silently transpose against the query record.
        var result = await _service.GetForListScreenAsync(new GetOpportunityListScreenQuery(page, pageSize, search, sortBy, sortDir, clientId, Stage: stage, Overdue: overdue, CloseDateFrom: closeDateFrom, CloseDateTo: closeDateTo), ct);
        return Ok(result);
    }

    /// <summary>Detail screen — SCR-CRM-PIPELINE-OPPORTUNITES-002.</summary>
    [HttpGet("detail/{id:guid}")]
    [RequirePermission(PipelinePermissions.Opportunites.Read)]
    public async Task<ActionResult<OpportunityDetailScreenDto>> GetDetail(Guid id, CancellationToken ct = default)
    {
        var result = await _service.GetForDetailScreenAsync(id, ct);
        return result is null ? NotFound() : Ok(result);
    }

    /// <summary>Custom action — archive (row scope, from pagespec).</summary>
    [HttpPost("{id:guid}/archive")]
    [RequirePermission(PipelinePermissions.Opportunites.Update)]
    public async Task<ActionResult> Archive(Guid id, CancellationToken ct = default)
    {
        await _service.ArchiveAsync(id, ct);
        return NoContent();
    }
}
```

## Route convention

| pagespec.view | HTTP | Route | Suffix |
|---------------|------|-------|--------|
| `list` | GET | `/api/screens/{plural}/list` | — |
| `detail` | GET | `/api/screens/{plural}/detail/{id:guid}` | — |
| `form` (create) | POST | `/api/screens/{plural}/form` | — |
| `form` (update) | PUT | `/api/screens/{plural}/form/{id:guid}` | — |
| `dashboard` | GET | `/api/screens/{plural}/dashboard` | one endpoint → `{ widgets }` |
| header action | `{httpMethod}` | `/api/screens/{plural}/{endpoint}` | from pagespec.endpoint |
| row action | `{httpMethod}` | `/api/screens/{plural}/{id:guid}/{endpoint}` | from pagespec.endpoint (no `detail/` prefix — matches the frontend service URL `${API_PATH}/${id}/{endpoint}` + the integration controller, so a screen row action never 404s) |
| bulk action | `{httpMethod}` | `/api/screens/{plural}/bulk/{endpoint}` | from pagespec.endpoint |

> **Dashboard view**. When the section has a pagespec with `view: "dashboard"`,
> the controller emits ONE endpoint `GET /api/screens/{plural}/dashboard` →
> `${E}DashboardDto { widgets: { <key>: WidgetResult } }`. Each declared widget is
> populated with a shape-correct default (kpi → `{ value }`, line/bar → `{ points }`,
> pie → `{ slices }`, list → `{ rows, columns }`) so the frontend renders the widget
> skeleton instead of its "no data" placeholder, plus a per-widget `TODO[DASH:<key>]`
> carrying the inferred aggregation (type · entity · field · aggregation) to fill in.
> The frontend `useDashboard${E}` hook (scaffold-api-client) ALWAYS targets this screen
> route — the dashboard is the only view whose data (the pagespec widgets) is exclusive
> to the screen stratum, so it is served here regardless of the entity's routeMode.
> (Neither `scaffold-controller` nor `scaffold-business` emit a `/dashboard` endpoint.)

> **Legacy standalone `kanban` / `card` pagespecs** (the pre-fold shape) serve
> the LIST endpoint (`[HttpGet("list")]`) and push a `[SCREEN-…]` migration
> TODO to `todos[]` — the board/gallery are viewModes of the LIST page
> (folded by `create-prd/cli/derive-kanban-spec` / `viewModes`), served by its
> endpoint; fold the pagespec and delete the file. Hub views (`app-home`,
> `module-home`, `section-home`) emit NO endpoint and NO TODO — pure Slot
> containers on the frontend, absence is by design. The controller is still
> emitted (with the other views' methods) so the build doesn't break.

> The `{plural}` segment is the **lowercase plural of the entity**, mirrored from
> the integration controller's `[controller]` token. Sections sharing entity
> plurals must be disambiguated upstream in the BA (section codes).

## DTO contract

For each `list` / `detail` pagespec, the scaffolder emits one DTO whose fields
are taken **only** from the pagespec's `columns[]` (list) or fields exposed in
the screen (detail). Computed columns become read-only `init` properties.

### Core-projected columns (`source`)

A column carrying a `source` block (`{"nav":"Customer","target":"TenantOrganisation",
"property":"Name","fkField":"CustomerCompanyId","fallbackLocal":…?}`) reads its
value from a V1-whitelist Core entity through THIS entity's navigation — the
DTO member stays a flat scalar (the emitters don't change), but the Phase 2b
implementation of `GetForListScreenAsync` / `GetForDetailScreenAsync` MUST
project through the navigation, never invent a local column:

```csharp
FirstName = x.User.FirstName,                                              // source, FK required
Email     = (x.UserId != null ? x.User!.Email : x.Email) ?? string.Empty,  // source + fallbackLocal (Screen DTO props are non-nullable with "" defaults)
CustomerCompanyName = x.Customer.Name,                                      // renamed nav → TenantOrganisation
```

Rules: coalesce nullable string projections with `?? string.Empty` (Screen DTO
properties are non-nullable with `""` defaults); **never** add an EF
`Include()` for a projection (it translates to a JOIN on its own); **never**
chain two Core navigations (`x.User.Department.Name` is silently ignored by
`SmartStackExtensionDbContext` — V1 surface restriction). If a BA spec
genuinely demands a nested Core read, use `ICoreDataService` with a post-query
merge and flag the pagespec `needsRefinement`.

The Business layer method signature stays canonical:

| Endpoint | Service method |
|----------|----------------|
| `GET /list` | `_service.GetForListScreenAsync(GetXxxListScreenQuery q, ct)` — the hand-written body MUST apply one explicit predicate per non-null query member, BEFORE `CountAsync`: (a) `Guid?` FK params → `if (q.ClientId is not null) qry = qry.Where(x => x.ClientId == q.ClientId);` (a 360 related tab's `?clientId={id}` fetch relies on it); (b) pagespec filter params (`lib/page-spec-filters.ts` derivation) — select (`string?`) → equality on the stored column, text (`string?`) → `EF.Functions.Like(x.Col, $"%{q.Number}%")`, boolean (`bool?`) → equality, date-range (`DateTime?` From/To) → inclusive bounds (`>= From` / `<= To`); (c) a param serving a **computed/derived column** (settlementStatus, origin…) has NO stored column — implement the same derivation the projection uses (subquery/join), never a fake local column and never a silent drop (DEV-API-017 flags the missing member; a dropped predicate is a wrong result set) |
| `GET /detail/{id}` | `_service.GetForDetailScreenAsync(Guid id, ct)` |
| `POST /form` | `_service.CreateAsync(...)` (reused from Phase 2a — never duplicate) |
| `PUT /form/{id}` | `_service.UpdateAsync(...)` (reused from Phase 2a) |
| custom action `{code}` | `_service.{Pascal(code)}Async(...)` (reused) |

## ⚠ BLOCKING — Convention rules

- **SCRN001**: `[ApiExplorerSettings(GroupName = "screens")]` on the class.
- **SCRN002**: `[Route("api/screens/{plural}")]` — never `/api/...` or
  `/api/v1/integration/...`.
- **SCRN003**: One method per pagespec in the section. The doc comment carries
  the `SCR-…` code verbatim.
- **SCRN004**: `[RequirePermission(...)]` on every method — taken from
  `pagespec.permission` (or `pagespec.action.permission` for custom actions).
- **SCRN005**: The Business method called MUST exist. If the scaffolder finds
  no matching method in the service interface, it emits a
  `// TODO[SCREEN-{code}]:` marker — Phase 2b's audit blocks merge until filled.
- **SCRN006**: Never emit business rules in the controller — call the service.
- **SCRN007**: Read endpoints return `{Entity}{View}ScreenDto` — never the
  domain entity, never `EntityListDto` from integration. The screen-driven
  shape is allowed to drift from integration intentionally.

## Invocation

```bash
npx --prefer-offline tsx skills/development/backend/screen-controller/cli/scaffold-screen-controller/index.ts \
  --spec '{
    "section": "opportunites",
    "sectionPlural": "opportunities",
    "module": "pipeline",
    "appCode": "crm",
    "namespace": "Crm",
    "moduleDir": ".smartstack/ba/CRM/PIPELINE",
    "projectPath": "/path/to/project",
    "fkFilters": ["clientId"]
  }'
```

The CLI reads `moduleDir/pagespecs/*.md` for the section (matching
`pagespec.section === spec.section`) and emits all the files in one shot.

`fkFilters` (optional, default `[]`) lists the camelCase Guid FKs of the entity —
each becomes an optional `[FromQuery] Guid?` relation filter on `GET /list`,
mirroring the `Guid?` params scaffold-business appends to
`Get{Entity}ListScreenQuery` (SAME field order — the query args are positional).
Phase 2b derives it from the entity's fields via
`lib/page-spec-related-tabs.fkFilterFields()`; this is the read path a 360
related tab uses (`?clientId={currentId}`).
