# File / document storage (attachments) — extension contract

The platform ALREADY ships the storage primitive: `IFileStorageService`
(Local disk + Azure Blob, Normal/Legal tiers), registered in DI and injectable
from any extension handler or controller, with its configuration blocks
(`FileStorage`, `AzureStorage`) present in every generated `appsettings.json`.
What it does NOT ship is the product feature around it — that is the
extension's job, following THIS pattern. A BA/dev facing "pièces jointes /
documents" must NEVER conclude "no file mechanism exists" nor store file
content in the database (`varbinary`): the bytes go through the service, the
DATABASE only carries a client METADATA entity.

## The platform contract (verified against SmartStack.app)

| Piece | Type / call | Notes |
|---|---|---|
| Service | `IFileStorageService` | `SmartStack.Application.Common.Interfaces` — public, shipped in the NuGet package |
| Methods | `UploadAsync(stream, fileName, contentType, storageType, containerPath?, ct)` → opaque stored key; `DownloadAsync(storedFileName, …)` → `(Stream, ContentType)`; `GetSecureUrlAsync`, `DeleteAsync`, `ExistsAsync`, `GetMetadataAsync` | the stored key shape is `{containerPath}/yyyy/MM/{guid}_{name}` — treat it as OPAQUE, never expose it in DTOs |
| Tiers | `StorageType.Normal` \| `StorageType.Legal` | `Legal` = immutable, legal-hold retention (Art. 958f CO, 10 years); `DeleteAsync` THROWS on `Legal` |
| DI | `AddScoped<IFileStorageService, Local\|AzureBlob…>` via `AddSmartStack` | provider chosen by `AzureStorage:UseAzure`; Local requires `FileStorage:BasePath` (fail-fast when empty — `ss dev` seeds a dev path) |
| Tenant | NOT tenant-aware | isolation is the CALLER's job: pass a tenant-scoped `containerPath` (e.g. `crm/interactions/{tenantId}`) AND resolve every download through the tenant-filtered metadata query |
| Validation | Local validates NOTHING (no size, no extension); only the Azure implementation checks `MaxFileSizeMB`/`AllowedExtensions` | the extension CONTROLLER owns validation — always |

## ⚠️ What the socle does NOT provide (the traps)

1. **No generic upload endpoint.** `FilesController` (`api/files`) is
   download-only, and its `normal/download/{*fileName}` route is
   **`[AllowAnonymous]`** (URL-capability for branding assets). NEVER route
   business documents through `api/files` — every extension exposes its OWN
   authenticated endpoints.
2. **No generic attachment entity.** `support_TicketAttachments` /
   `hr_AbsenceRequestAttachments` are Core-internal; the extension models its
   own metadata entity (below).
3. **No exported React upload component.** The package's `FileDropzone` is
   internal — the extension writes its own dropzone (theme tokens, editable).
4. **No orphan GC.** If you stage uploads before the parent exists, plan the
   sweep; simplest is upload-on-submit against an existing parent.

## The canonical pattern — end to end

### 1. Metadata entity (`extensions.*`) — a NORMAL BA entity

The entity is legitimate and lives in the module's `entité.md` (C-6/DM-019
steer its shape, they do not ban it). Scaffold via `scaffold-entity`:

```markdown
### ENT-00x — InteractionDocument
| Attribut | Type | Contraintes |
|---|---|---|
| FileName | string/256 | required — original display name |
| StoredFileName | string/500 | required, unique — opaque IFileStorageService key, never in DTOs |
| ContentType | string/100 | required |
| FileSizeBytes | long | required |
Relations : InteractionDocument *→1 Interaction — FK InteractionId, onDelete cascade
```

No `binary`/`blob` attribute — EVER (`scaffold-entity` fail-closes on those
types; audit `DM-019`/`PRD-053` flag them upstream).

### 2. Extension controller — multipart up, authenticated streamed down

Hand-written during `/ba-develop` Phase 3 (no scaffolder yet — the standard
scaffold-controller emits JSON `[FromBody]` endpoints only):

```csharp
[HttpPost("{id:guid}/documents")]
[RequirePermission(CrmPermissions.Interactions.Update)]
[RequestSizeLimit(MaxFileSizeBytes)]                       // 10 MB default
public async Task<ActionResult<InteractionDocumentDto>> Upload(
    Guid id, IFormFile file, CancellationToken ct)
{
    // Controller owns validation — Local storage validates NOTHING.
    var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
    if (!AllowedExtensions.Contains(ext)) return BadRequest(…);
    if (file.Length is 0 or > MaxFileSizeBytes) return BadRequest(…);

    var interaction = await _db.Interactions                // tenant-filtered
        .FirstOrDefaultAsync(x => x.Id == id, ct);
    if (interaction is null) return NotFound();

    await using var stream = file.OpenReadStream();
    var storedKey = await _storage.UploadAsync(
        stream, file.FileName, file.ContentType,
        StorageType.Normal, $"crm/interactions/{_tenant.TenantId}", ct);
    // create + save the metadata row, return the DTO (no storedKey inside)
}

[HttpGet("{id:guid}/documents/{docId:guid}/download")]
[RequirePermission(CrmPermissions.Interactions.Read)]
public async Task<IActionResult> Download(Guid id, Guid docId, CancellationToken ct)
{
    var doc = await _db.InteractionDocuments                 // tenant + scope filters
        .FirstOrDefaultAsync(d => d.Id == docId && d.InteractionId == id, ct);
    if (doc is null) return NotFound();                      // out-of-scope ⇒ 404
    var (stream, contentType) = await _storage.DownloadAsync(doc.StoredFileName, StorageType.Normal, ct);
    return File(stream, contentType, doc.FileName);          // streamed, never buffered
}
```

Delete = remove the metadata ROW first, then `DeleteAsync(storedKey)` (swallow
a storage miss; a DB-orphaned blob is recoverable, the reverse is a 500 loop).
`Legal` tier: `DeleteAsync` throws — hide the delete affordance in the UI.

Rules recap: `[RequirePermission]` on every verb; data-scope via the metadata
QUERY (never `[RequireDataScope]` — Core-only, see `data-scopes.md`); tenant
isolation via the tenant-filtered DbContext + tenant-scoped `containerPath`.

### 3. Client — FormData through the package's `api`

```ts
import { api } from '@atlashub/smartstack'

const form = new FormData()
form.append('file', file)
await api.post(`/api/crm/interactions/${id}/documents`, form)
// the package's stripJsonContentTypeForFormData interceptor sets the
// multipart boundary — do NOT set Content-Type manually
const blob = (await api.get(`…/documents/${docId}/download`, { responseType: 'blob' })).data
```

Never author a custom action with `payloadParameters[].type: 'file'` — that
pipeline posts JSON (a `File` serializes to `{}`, audit `PRD-107` flags it).
Uploads always go through dedicated endpoints like the above.

### 4. UI — local dropzone (editable, theme tokens)

The package exports no upload component: write a small local dropzone +
attachment list in the module (drag&drop optional; `<input type="file">` +
extension/size pre-check mirroring the server whitelist is enough), styled
with the design-system tokens, mounted as the entity's « Documents » related
tab. Mark `// @customised` if you edit a scaffolded page to mount it.

### 5. Tests

Backend: mock `IFileStorageService` (Moq) in handler/controller tests — assert
the validation rejections and that `UploadAsync` receives the tenant-scoped
`containerPath`. Frontend: MSW handlers for the upload/download routes. Follow
`test-conventions/references/` per test type.

## Forbidden (what the audits enforce)

- File CONTENT in the database — `binary`/`blob`/`varbinary`/`byte[]`
  attribute (DM-019 err, PRD-053 err, scaffold-entity throws).
- Raw disk I/O (`System.IO` writes) or a hand-rolled storage path — bytes go
  through `IFileStorageService` only.
- Routing business documents through `api/files/*` (anonymous) or exposing an
  unauthenticated download URL.
- `payloadParameters[].type: 'file'` custom actions (PRD-107) — not wired for
  multipart.
- Exposing `StoredFileName` in any DTO.
