# Story 3.11: Custom Prompts - Endpoint CRUD

Status: done

## Story

En tant qu'**utilisateur Pro**,
Je veux **créer et gérer des prompts système personnalisés via API**,
de sorte que **je puisse guider le contexte de traduction LLM**.

## Acceptance Criteria

1. **Création de prompt**: Quand un utilisateur Pro POST sur `/api/v1/prompts` avec `{name, content}`, un prompt template est créé et associé à son compte. (FR59)
2. **Liste des prompts**: GET `/api/v1/prompts` retourne la liste des prompts de l'utilisateur.
3. **Détail d'un prompt**: GET `/api/v1/prompts/{id}` retourne les détails d'un prompt spécifique.
4. **Mise à jour**: PATCH `/api/v1/prompts/{id}` permet de mettre à jour le nom et/ou le contenu.
5. **Suppression**: DELETE `/api/v1/prompts/{id}` supprime un prompt.
6. **Restriction Pro**: Les utilisateurs Free reçoivent 403 avec erreur `PRO_FEATURE_REQUIRED`.

## Tasks / Subtasks

- [x] **Task 1: Créer le modèle CustomPrompt et la migration Alembic** (AC: #1)
  - [x] 1.1 Ajouter le modèle `CustomPrompt` dans `database/models.py`
  - [x] 1.2 Générer la migration Alembic `alembic revision --autogenerate -m "add_custom_prompts_table"`
  - [x] 1.3 Appliquer la migration `alembic upgrade head`
  - [x] 1.4 Vérifier la table créée dans la DB

- [x] **Task 2: Créer les schémas Pydantic** (AC: Tous)
  - [x] 2.1 Créer `schemas/prompt_schemas.py` avec PromptCreate, PromptUpdate, PromptResponse, PromptListItem
  - [x] 2.2 Définir les validators (content max 10000 chars, name max 255 chars)
  - [x] 2.3 Documenter les schémas pour OpenAPI

- [x] **Task 3: Créer les routes CRUD** (AC: Tous)
  - [x] 3.1 Créer `routes/prompt_routes.py` avec les endpoints CRUD
  - [x] 3.2 Implémenter POST `/api/v1/prompts` - Créer un prompt
  - [x] 3.3 Implémenter GET `/api/v1/prompts` - Lister les prompts (avec pagination)
  - [x] 3.4 Implémenter GET `/api/v1/prompts/{id}` - Détail d'un prompt
  - [x] 3.5 Implémenter PATCH `/api/v1/prompts/{id}` - Mettre à jour un prompt
  - [x] 3.6 Implémenter DELETE `/api/v1/prompts/{id}` - Supprimer un prompt
  - [x] 3.7 Enregistrer le router dans `routes/api_v1_router.py`

- [x] **Task 4: Implémenter la restriction Pro** (AC: #6)
  - [x] 4.1 Utiliser `require_pro_user` depuis `routes/deps.py`
  - [x] 4.2 Retourner 403 avec erreur `PRO_FEATURE_REQUIRED` pour les utilisateurs Free

- [x] **Task 5: Tests** (AC: Tous)
  - [x] 5.1 Tests unitaires pour les schémas
  - [x] 5.2 Tests d'intégration pour les endpoints CRUD
  - [x] 5.3 Test de la restriction Pro (403 pour Free)
  - [x] 5.4 Test de la propriété utilisateur (un user ne peut pas voir les prompts d'un autre)

- [x] **Task 6: Documentation OpenAPI** (AC: Tous)
  - [x] 6.1 Documenter tous les endpoints avec exemples
  - [x] 6.2 Documenter les codes d'erreur
  - [x] 6.3 Vérifier sur `/docs` que la documentation est complète

## Dev Notes

### 🏗️ Architecture - Structure du Module

**Fichiers à créer/modifier:**

```
database/
├── models.py              # MODIFIÉ - Ajouter CustomPrompt
schemas/
├── prompt_schemas.py      # CRÉÉ - Schémas Pydantic
routes/
├── prompt_routes.py       # CRÉÉ - Endpoints CRUD
├── api_v1_router.py       # MODIFIÉ - Enregistrement du router
alembic/versions/
└── xxx_add_custom_prompts_table.py  # CRÉÉ - Migration DB
tests/
└── test_prompts.py        # CRÉÉ - Tests
```

### 📊 Modèle de Données

**Table `custom_prompts`:**

```python
class CustomPrompt(Base):
    """User's custom prompts for LLM translation context.
    Story 3.11: Custom Prompts - Endpoint CRUD
    """

    __tablename__ = "custom_prompts"

    id = Column(String(36), primary_key=True, default=generate_uuid)
    user_id = Column(
        String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
    )
    name = Column(String(255), nullable=False)  # User-friendly name
    content = Column(Text, nullable=False)  # The actual prompt content (up to 10000 chars)
    created_at = Column(DateTime, default=_utcnow)
    updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)

    # Indexes
    __table_args__ = (Index("ix_custom_prompts_user_id", "user_id"),)
```

**Contraintes:**
- `name`: 1-255 caractères
- `content`: 1-10000 caractères
- Un utilisateur peut avoir plusieurs prompts
- Suppression en cascade si l'utilisateur est supprimé

### 📝 Schémas Pydantic

```python
# schemas/prompt_schemas.py

class PromptCreate(BaseModel):
    """Schema for creating a prompt."""
    name: str = Field(..., min_length=1, max_length=255)
    content: str = Field(..., min_length=1, max_length=10000)

    @field_validator("name", "content")
    @classmethod
    def strip_whitespace(cls, v: str) -> str:
        return v.strip()


class PromptUpdate(BaseModel):
    """Schema for updating a prompt (all fields optional)."""
    name: Optional[str] = Field(None, min_length=1, max_length=255)
    content: Optional[str] = Field(None, min_length=1, max_length=10000)


class PromptResponse(BaseModel):
    """Schema for prompt in response."""
    id: str
    name: str
    content: str
    created_at: Optional[datetime] = None
    updated_at: Optional[datetime] = None

    model_config = {"from_attributes": True}


class PromptListItem(BaseModel):
    """Schema for prompt in list (lighter version)."""
    id: str
    name: str
    content_preview: str = Field(..., description="First 100 chars of content")
    created_at: Optional[datetime] = None
    updated_at: Optional[datetime] = None

    model_config = {"from_attributes": True}
```

### 🔧 Pattern CRUD à Suivre (basé sur glossary_routes.py)

**Structure des endpoints:**

1. **POST** - Créer un prompt:
   - Utiliser `require_pro_user` dependency
   - Valider les données avec Pydantic schema
   - Créer l'entité en DB
   - Logger l'action
   - Retourner 201 avec `{data: {...}, meta: {}}`

2. **GET list** - Lister les prompts:
   - Pagination avec `page` et `per_page` query params
   - `DEFAULT_PAGE_SIZE = 50`, `MAX_PAGE_SIZE = 100`
   - Retourner `{data: [...], meta: {total, page, per_page, total_pages}}`
   - Utiliser `content_preview` (100 premiers caractères) pour alléger la réponse

3. **GET detail** - Détail d'un prompt:
   - Valider le format UUID
   - Vérifier que le prompt appartient à l'utilisateur
   - Retourner 404 avec `PROMPT_NOT_FOUND` si introuvable

4. **PATCH** - Mettre à jour:
   - Valider le format UUID
   - Vérifier la propriété
   - Permettre mise à jour partielle (uniquement les champs fournis)
   - Mettre à jour `updated_at`

5. **DELETE** - Supprimer:
   - Valider le format UUID
   - Vérifier la propriété
   - Retourner 204 (No Content)

### 🚨 Points d'Attention - Anti-Patterns à Éviter

1. **Ne PAS réinventer les dependencies** - Utiliser `require_pro_user` depuis `routes/deps.py`

2. **Ne PAS utiliser des modèles Pydantic inline** - Créer les schémas dans `schemas/prompt_schemas.py`

3. **Pagination obligatoire** - La liste DOIT avoir `page` et `per_page` pour éviter les N+1 queries

4. **Erreur 500 proscrite** - Toujours catcher les exceptions DB et retourner des erreurs JSON structurées

5. **Logger les actions** - Utiliser `logger.info()` pour create/update/delete

6. **UUID validation** - Toujours valider le format UUID avant de query la DB

### 📚 Références de Code Existant

**Pattern exact à suivre:** `routes/glossary_routes.py` (Story 3.9)

```python
# Structure du router
router = APIRouter(prefix="/api/v1/prompts", tags=["Prompts v1"])

# POST /api/v1/prompts
@router.post("", response_model=PromptDetailResponse, status_code=201)
async def create_prompt(body: PromptCreate, user: ProUser = Depends(require_pro_user)):
    ...

# GET /api/v1/prompts
@router.get("", response_model=PromptListResponse)
async def list_prompts(page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=100), user: ProUser = Depends(require_pro_user)):
    ...

# GET /api/v1/prompts/{prompt_id}
@router.get("/{prompt_id}", response_model=PromptDetailResponse)
async def get_prompt(prompt_id: str, user: ProUser = Depends(require_pro_user)):
    ...

# PATCH /api/v1/prompts/{prompt_id}
@router.patch("/{prompt_id}", response_model=PromptDetailResponse)
async def update_prompt(prompt_id: str, body: PromptUpdate, user: ProUser = Depends(require_pro_user)):
    ...

# DELETE /api/v1/prompts/{prompt_id}
@router.delete("/{prompt_id}", status_code=204)
async def delete_prompt(prompt_id: str, user: ProUser = Depends(require_pro_user)):
    ...
```

### 🧪 Tests à Créer

**Fichier:** `tests/test_prompts.py`

```python
def test_create_prompt_success():
    """Pro user can create a prompt"""

def test_create_prompt_free_user_forbidden():
    """Free user receives 403 PRO_FEATURE_REQUIRED"""

def test_list_prompts_with_pagination():
    """List returns paginated results"""

def test_get_prompt_detail():
    """Get single prompt by ID"""

def test_get_prompt_not_found():
    """404 for non-existent or other user's prompt"""

def test_update_prompt():
    """PATCH updates name and/or content"""

def test_delete_prompt():
    """DELETE removes prompt"""

def test_prompt_ownership():
    """User cannot access another user's prompts"""

def test_content_max_length():
    """Content > 10000 chars returns 400"""

def test_name_max_length():
    """Name > 255 chars returns 400"""
```

### Project Structure Notes

- **Nouveau fichier:** `schemas/prompt_schemas.py` - Schémas Pydantic pour prompts
- **Nouveau fichier:** `routes/prompt_routes.py` - Endpoints CRUD
- **Nouveau fichier:** `tests/test_prompts.py` - Tests
- **Modification:** `database/models.py` - Ajout du modèle CustomPrompt
- **Modification:** `routes/api_v1_router.py` - Enregistrement du router

### References

- [Source: _bmad-output/planning-artifacts/epics.md#Story-3.11] - Story requirements (FR59)
- [Source: _bmad-output/planning-artifacts/architecture.md#API-Response-Formats] - Format API
- [Source: routes/glossary_routes.py] - Pattern CRUD à suivre (Story 3.9)
- [Source: routes/deps.py] - Dependencies `require_pro_user`
- [Source: schemas/glossary_schemas.py] - Pattern schémas Pydantic
- [Source: database/models.py] - Structure des modèles existants

## Dev Agent Record

### Agent Model Used

Claude 3.5 Sonnet (claude-3-5-sonnet)

### Debug Log References

Aucune erreur bloquante rencontrée. Migration Alembic a nécessité une adaptation pour SQLite (limitation ALTER COLUMN).

### Completion Notes List

- ✅ Modèle CustomPrompt ajouté à `database/models.py` avec index sur user_id
- ✅ Migration Alembic générée et appliquée (revision 5206607942a2)
- ✅ Schémas Pydantic créés: PromptCreate, PromptUpdate, PromptResponse, PromptListItem
- ✅ Endpoints CRUD complets: POST, GET (list), GET (detail), PATCH, DELETE
- ✅ Restriction Pro implémentée via `require_pro_user` dependency
- ✅ 19 tests d'intégration créés et passent tous
- ✅ Documentation OpenAPI complète avec descriptions et exemples
- ✅ Validation des contraintes (name: 1-255 chars, content: 1-10000 chars)
- ✅ Pagination implémentée sur l'endpoint de liste
- ✅ Propriété utilisateur vérifiée (404 si accès à un prompt d'un autre utilisateur)
- ✅ [Review Fix] Validation PATCH body vide ajoutée (erreur 400 NO_UPDATE_FIELDS)
- ✅ [Review Fix] Helper `_validate_uuid()` extraite pour éviter duplication
- ✅ [Review Fix] Méthode `has_updates()` ajoutée à PromptUpdate

### File List

**Nouveaux fichiers:**
- `schemas/prompt_schemas.py`
- `routes/prompt_routes.py`
- `tests/test_prompts.py`
- `alembic/versions/5206607942a2_add_custom_prompts_table.py`

**Fichiers modifiés:**
- `database/models.py` - Ajout du modèle CustomPrompt
- `routes/api_v1_router.py` - Enregistrement du router prompt_router

## Change Log

- 2026-02-22: Implémentation complète de la Story 3.11 - Custom Prompts CRUD endpoints
- 2026-02-22: Code review - Fix validation PATCH body vide, refactor UUID validation, ajout test
