# UpdateNotificationPreference

## Permission Scope

preferenceSelf

## Overview

updateNotificationPreference upserts a single NotificationPreference row keyed by `(userId, categoryId, channelId)`, recording the user's per-channel opt-in (`allowed`). The caller must equal the target user — preference editing is strictly self-service and any cross-user attempt is rejected. The transactional invariant is enforced at the command boundary: any change targeting a NotificationCategory whose `optOutAllowed = false` with `allowed = false` is rejected, so an `allowed = false` row can never be persisted for a transactional category.

## Business Rules

- `userId`, `categoryId`, `channelId`, `allowed` are all required
- The caller's userId must equal `userId`
- A NotificationCategory must exist for `categoryId`
- A NotificationChannel must exist for `channelId`
- If the target category has `optOutAllowed = false`, `allowed = false` is rejected
- Upsert semantics: row is inserted if absent, updated if present
- Idempotent: submitting the same `allowed` value twice converges on the same row

## Process Flow

```mermaid
flowchart TD
    A[Receive update preference request] --> B{Caller.userId == target userId?}
    B -->|No| C[Return FORBIDDEN]
    B -->|Yes| D{NotificationCategory exists?}
    D -->|No| E[Return CATEGORY_NOT_FOUND]
    D -->|Yes| F{NotificationChannel exists?}
    F -->|No| G[Return CHANNEL_NOT_FOUND]
    F -->|Yes| H{Category.optOutAllowed = false AND allowed = false?}
    H -->|Yes| I[Return CATEGORY_NOT_OPT_OUT_ABLE]
    H -->|No| J[Upsert NotificationPreference row]
    J --> K[Return resulting preference row]
```

## External Dependencies

- User-management context - The caller's userId is read from the authenticated context to enforce self-service editing

## Error Scenarios

- **FORBIDDEN**: Caller is not authorized to perform this operation in the target scope
- **CATEGORY_NOT_OPT_OUT_ABLE**: Target NotificationCategory has optOutAllowed = false and the requested change would persist an opt-out directive
- **CATEGORY_NOT_FOUND**: No NotificationCategory matches the supplied categoryId
- **CHANNEL_NOT_FOUND**: No NotificationChannel row matches the supplied channelId
- **MISSING_REQUIRED_FIELD**: One or more required input fields are missing or blank

## Test Cases

- inserts a NotificationPreference row with allowed=false for an optional category × channel pair
- updates an existing NotificationPreference row's allowed flag
- returns FORBIDDEN when the caller's userId does not equal the target userId
- returns CATEGORY_NOT_OPT_OUT_ABLE when toggling allowed=false on a transactional category
- returns CATEGORY_NOT_FOUND when categoryId does not match
- returns CHANNEL_NOT_FOUND when channelId does not match
- returns MISSING_REQUIRED_FIELD when allowed is not a boolean
- returns MISSING_REQUIRED_FIELD when categoryId is blank
- returns MISSING_REQUIRED_FIELD when channelId is blank
- submitting the same `allowed` value twice produces a single row in the final state (idempotent)
