---
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,
        CancellationToken ct = default)
    {
        var result = await _service.GetForListScreenAsync(new GetOpportunitiesListScreenQuery(page, pageSize, search), 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("detail/{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` (consolidated) | GET | `/api/screens/{plural}/dashboard/consolidated` | Wave F3 |
| `dashboard` (alerts) | GET | `/api/screens/{plural}/dashboard/alerts` | Wave F3 |
| header action | `{httpMethod}` | `/api/screens/{plural}/{endpoint}` | from pagespec.endpoint |
| row action | `{httpMethod}` | `/api/screens/{plural}/detail/{id:guid}/{endpoint}` | from pagespec.endpoint |
| bulk action | `{httpMethod}` | `/api/screens/{plural}/bulk/{endpoint}` | from pagespec.endpoint |

> **Dashboard view (Wave F3)**. When the section has a pagespec with
> `view: "dashboard"`, the controller emits two endpoints that reuse the
> integration-stratum service methods `GetDashboardConsolidatedAsync` +
> `GetDashboardAlertsAsync` (scaffolded by `scaffold-business` when the entity
> declares `hasDashboard: true`). The DTOs `${E}DashboardConsolidatedDto` +
> `${E}DashboardAlertDto` live in the integration namespace —
> `scaffold-screen-controller` does NOT re-emit them. Frontend consumes via
> the existing `useDashboard${E}` hook from `scaffold-api-client`.

> **Still unsupported (TODO)**. Views `kanban`, `card`, `app-home`,
> `module-home`, `section-home` push a `[SCREEN-…]` marker to `todos[]`
> instead of an endpoint — they require different aggregation primitives
> than the four supported views and are queued for a future wave. 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.

The Business layer method signature stays canonical:

| Endpoint | Service method |
|----------|----------------|
| `GET /list` | `_service.GetForListScreenAsync(GetXxxListScreenQuery q, ct)` |
| `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"
  }'
```

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