# Fix Data-Loss Bugs — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Fix all bugs that cause silent data loss in the composant/piece/product/skeleton/custom-fields data model.

**Architecture:** 6 independent fixes across backend (PHP) and frontend (TS). Each task is self-contained and can be committed independently. Backend fixes come first because they protect data integrity at the source.

**Tech Stack:** Symfony 8 / PHP 8.4 / PostgreSQL 16 / Nuxt 4 / Vue 3 / TypeScript

---

## File Map

| Task | Action | File |
|------|--------|------|
| T1 | Modify | `src/Controller/MachineStructureController.php:174-195` |
| T2 | Modify | `src/Controller/ComposantPieceSlotController.php:41-47` |
| T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:195-236` |
| T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:340-405` |
| T4 | Modify | `src/Controller/CustomFieldValueController.php:199-211` |
| T5 | Modify | `frontend/app/composables/useComponentEdit.ts:398-405` |
| T5 | Modify | `frontend/app/composables/usePieceEdit.ts:407-414` |
| T6 | Modify | `frontend/app/composables/useComponentCreate.ts` (same pattern if present) |

---

### Task 1: Clone machine — CustomFieldValue pointe vers les CustomField de la source

**Probleme:** `cloneCustomFields` clone les `CustomField` (definitions) pour la target, mais les `CustomFieldValue` (valeurs) restent liees aux `CustomField` de la source. Supprimer la source cascade-delete les valeurs du clone.

**Files:**
- Modify: `src/Controller/MachineStructureController.php:174-195`
- Test: `tests/Api/Controller/MachineStructureControllerTest.php` (clone test existant)

- [ ] **Step 1: Write the failing test**

Dans le test de clone existant, ajouter une assertion : apres clone, verifier que chaque `CustomFieldValue` de la machine clonee pointe vers un `CustomField` dont `machineId` est l'ID de la machine clonee (pas la source).

```php
// After clone, fetch the cloned machine's custom field values
$clonedValues = $em->getRepository(CustomFieldValue::class)->findBy(['machine' => $clonedMachine]);
foreach ($clonedValues as $cfv) {
    $this->assertSame(
        $clonedMachine->getId(),
        $cfv->getCustomField()->getMachine()->getId(),
        'Cloned CustomFieldValue must reference the cloned CustomField, not the source'
    );
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `make test FILES=tests/Api/Controller/MachineStructureControllerTest.php`
Expected: FAIL — cloned values reference source machine's custom fields

- [ ] **Step 3: Implement the fix**

In `cloneCustomFields`, build a map `$oldCfId => $newCf` in the first loop, then use it in the second loop:

```php
private function cloneCustomFields(Machine $source, Machine $target): void
{
    $cfMap = [];

    foreach ($source->getCustomFields() as $cf) {
        $newCf = new CustomField();
        $newCf->setName($cf->getName());
        $newCf->setType($cf->getType());
        $newCf->setRequired($cf->isRequired());
        $newCf->setDefaultValue($cf->getDefaultValue());
        $newCf->setOptions($cf->getOptions());
        $newCf->setOrderIndex($cf->getOrderIndex());
        $newCf->setMachine($target);
        $this->entityManager->persist($newCf);

        $cfMap[$cf->getId()] = $newCf;
    }

    foreach ($source->getCustomFieldValues() as $cfv) {
        $originalCf = $cfv->getCustomField();
        $newCf = $cfMap[$originalCf->getId()] ?? null;
        if (!$newCf) {
            continue;
        }

        $newValue = new CustomFieldValue();
        $newValue->setMachine($target);
        $newValue->setCustomField($newCf);
        $newValue->setValue($cfv->getValue());
        $this->entityManager->persist($newValue);
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `make test FILES=tests/Api/Controller/MachineStructureControllerTest.php`
Expected: PASS

- [ ] **Step 5: Lint**

Run: `make php-cs-fixer-allow-risky`

- [ ] **Step 6: Commit**

```bash
git add src/Controller/MachineStructureController.php tests/Api/Controller/MachineStructureControllerTest.php
git commit -m "fix(clone) : custom field values reference cloned definitions, not source"
```

---

### Task 2: ComposantPieceSlot PATCH — pas de validation du type de piece ni 404

**Probleme:** On peut assigner n'importe quelle piece dans un slot, meme si son type ne correspond pas au type requis par le squelette. Si la piece n'existe pas, `null` est silencieusement mis.

**Files:**
- Modify: `src/Controller/ComposantPieceSlotController.php:41-47`
- Test: `tests/Api/Controller/ComposantPieceSlotControllerTest.php` (creer si absent)

- [ ] **Step 1: Write the failing test — piece not found returns 404**

```php
public function testPatchSlotWithNonExistentPieceReturns404(): void
{
    $client = $this->createGestionnaireClient();
    // Create a slot via fixtures
    $slot = $this->createComposantPieceSlot();

    $client->request('PATCH', '/api/composant-piece-slots/' . $slot->getId(), [
        'json' => ['selectedPieceId' => 'cl_nonexistent_id'],
        'headers' => ['Content-Type' => 'application/json'],
    ]);

    $this->assertResponseStatusCodeSame(404);
}
```

- [ ] **Step 2: Write the failing test — wrong piece type returns 422**

```php
public function testPatchSlotWithWrongPieceTypeReturns422(): void
{
    $client = $this->createGestionnaireClient();
    $typeA = $this->createModelType(['category' => 'piece', 'name' => 'Type A']);
    $typeB = $this->createModelType(['category' => 'piece', 'name' => 'Type B']);
    $slot = $this->createComposantPieceSlot(['typePiece' => $typeA]);
    $wrongPiece = $this->createPiece(['typePiece' => $typeB]);

    $client->request('PATCH', '/api/composant-piece-slots/' . $slot->getId(), [
        'json' => ['selectedPieceId' => $wrongPiece->getId()],
        'headers' => ['Content-Type' => 'application/json'],
    ]);

    $this->assertResponseStatusCodeSame(422);
}
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `make test FILES=tests/Api/Controller/ComposantPieceSlotControllerTest.php`
Expected: FAIL

- [ ] **Step 4: Implement the fix**

```php
if (array_key_exists('selectedPieceId', $payload)) {
    if (null === $payload['selectedPieceId']) {
        $slot->setSelectedPiece(null);
    } else {
        $piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
        if (!$piece) {
            return $this->json(['success' => false, 'error' => 'Pièce introuvable.'], 404);
        }

        $slotTypePiece = $slot->getTypePiece();
        if ($slotTypePiece && $piece->getTypePiece()?->getId() !== $slotTypePiece->getId()) {
            return $this->json([
                'success' => false,
                'error' => sprintf(
                    'La pièce doit être de type « %s ».',
                    $slotTypePiece->getName(),
                ),
            ], 422);
        }

        $slot->setSelectedPiece($piece);
    }
}
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `make test FILES=tests/Api/Controller/ComposantPieceSlotControllerTest.php`
Expected: PASS

- [ ] **Step 6: Lint + commit**

```bash
make php-cs-fixer-allow-risky
git add src/Controller/ComposantPieceSlotController.php tests/Api/Controller/ComposantPieceSlotControllerTest.php
git commit -m "fix(slots) : validate piece type matches slot requirement + 404 on missing piece"
```

---

### Task 3: Conversion de categorie — slots supprimes sans verification + skeleton requirements orphelins

**Probleme A:** `checkComponentToPiece` verifie `structure IS NOT NULL` (ancien JSON) mais les donnees sont dans les tables de slots. Le check passe toujours et les slots sont cascade-deleted.

**Probleme B:** Apres conversion, les `skeleton_piece_requirements`, `skeleton_product_requirements`, `skeleton_subcomponent_requirements` de l'ancien type ne sont pas supprimes.

**Files:**
- Modify: `src/Service/ModelTypeCategoryConversionService.php:195-236` (check)
- Modify: `src/Service/ModelTypeCategoryConversionService.php:340-405` (convert)

- [ ] **Step 1: Fix `checkComponentToPiece` — ajouter le check sur les tables de slots**

Apres le check `structure IS NOT NULL` existant (qui reste pour compatibilite), ajouter :

```php
// Check slot tables for actual data (post-normalization architecture)
$slotsWithData = (int) $this->connection->fetchOne(
    'SELECT COUNT(*) FROM composant_piece_slots cps
     JOIN composants c ON cps.composantid = c.id
     WHERE c.typecomposantid = :id AND cps.selectedpieceid IS NOT NULL',
    ['id' => $modelTypeId],
);

$subSlots = (int) $this->connection->fetchOne(
    'SELECT COUNT(*) FROM composant_subcomponent_slots css
     JOIN composants c ON css.composantid = c.id
     WHERE c.typecomposantid = :id AND css.selectedcomposantid IS NOT NULL',
    ['id' => $modelTypeId],
);

if ($slotsWithData > 0 || $subSlots > 0) {
    $parts = [];
    if ($slotsWithData > 0) {
        $parts[] = sprintf('%d slot(s) pièce rempli(s)', $slotsWithData);
    }
    if ($subSlots > 0) {
        $parts[] = sprintf('%d slot(s) sous-composant rempli(s)', $subSlots);
    }
    $blockers[] = sprintf(
        'Des composants ont des données dans leurs slots : %s.',
        implode(', ', $parts),
    );
}
```

- [ ] **Step 2: Fix `convertComponentToPiece` — nettoyer les skeleton requirements avant le changement de categorie**

Ajouter entre l'etape 6 (DELETE composants) et l'etape 7 (UPDATE model_types) :

```php
// 6b. Clean up skeleton requirements that belong to COMPONENT category
$this->connection->executeStatement(
    'DELETE FROM skeleton_piece_requirements WHERE modeltypeid = :id',
    ['id' => $modelTypeId],
);
$this->connection->executeStatement(
    'DELETE FROM skeleton_subcomponent_requirements WHERE modeltypeid = :id',
    ['id' => $modelTypeId],
);
// Note: skeleton_product_requirements are kept — valid for both COMPONENT and PIECE categories
```

- [ ] **Step 3: Fix `convertPieceToComponent` — meme nettoyage dans l'autre sens**

Les `skeleton_product_requirements` qui appartenaient au type PIECE restent. Aucun nettoyage specifique necessaire car les product requirements sont valides pour les deux types. Mais verifier que la methode existe et n'a pas le meme probleme.

- [ ] **Step 4: Run all conversion tests**

Run: `make test FILES=tests/Api/Controller/ModelTypeConversionControllerTest.php`
Si absent: `make test` (tous les tests)
Expected: PASS

- [ ] **Step 5: Lint + commit**

```bash
make php-cs-fixer-allow-risky
git add src/Service/ModelTypeCategoryConversionService.php
git commit -m "fix(conversion) : block conversion when slots have data + clean skeleton requirements"
```

---

### Task 4: CustomFieldValueController — cree des CustomField orphelins sans FK

**Probleme:** Quand `customFieldId` est absent et `customFieldName` est fourni, un nouveau `CustomField` est cree sans etre rattache a aucune entite (ni machine, ni modelType). La ligne est invisible et inutile.

**Files:**
- Modify: `src/Controller/CustomFieldValueController.php:199-211`

- [ ] **Step 1: Implement the fix**

La methode `resolveCustomField` cree un `CustomField` orphelin. Il faut utiliser le `target` (deja resolu) pour rattacher le champ au bon parent. Le plus simple : deplacer la creation du CustomField apres la resolution du target, ou passer le target en parametre.

Option retenue : retourner un array `['customField' => $cf, 'isNew' => true]` et laisser `applyTarget` gerer le rattachement, OU plus simplement, interdire la creation ad-hoc et retourner une erreur 400 quand le champ n'existe pas.

L'approche la plus sure (pas de CustomField orphelin) :

```php
// In resolveCustomField, replace the auto-creation block with:
$customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : '';
if ('' === $customFieldName) {
    return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
}

// Try to find existing custom field by name for the target entity
$target = $this->resolveTarget($payload);
if ($target instanceof JsonResponse) {
    return $this->json(['success' => false, 'error' => 'Cannot create custom field without a valid target entity.'], 400);
}

$existingField = $this->customFieldRepository->findOneBy(['name' => $customFieldName]);
if ($existingField) {
    return $existingField;
}

return $this->json(['success' => false, 'error' => sprintf('Custom field "%s" not found. Create it explicitly first.', $customFieldName)], 404);
```

**Alternative plus conservative** si le frontend depend de cette auto-creation : garder la creation mais rattacher au target. Cela necessite de refactorer le flow pour passer le target a `resolveCustomField`. Choisir selon le frontend.

- [ ] **Step 2: Run tests**

Run: `make test`
Expected: PASS (verifier qu'aucun test ne depend de l'auto-creation)

- [ ] **Step 3: Lint + commit**

```bash
make php-cs-fixer-allow-risky
git add src/Controller/CustomFieldValueController.php
git commit -m "fix(custom-fields) : prevent creation of orphan CustomField without target entity"
```

---

### Task 5: Frontend — custom fields definition lookup au mauvais chemin

**Probleme:** `useComponentEdit` passe `typeComposant.customFields` (pas serialise par l'API) au lieu de `typeComposant.structure.customFields`. Idem `usePieceEdit` avec `typePiece.pieceCustomFields` au lieu de `typePiece.structure.customFields`.

Consequence : le `definitionMap` est toujours vide, les champs perso sans `customFieldId` existant ne trouvent pas leur definition et sont envoyes sans `definitionId` (fallback sur metadata = CustomField orphelin cote backend = Task 4).

**Files:**
- Modify: `frontend/app/composables/useComponentEdit.ts:401-403`
- Modify: `frontend/app/composables/usePieceEdit.ts:410-412`

- [ ] **Step 1: Fix useComponentEdit.ts**

Ligne 401-403, remplacer :
```ts
[
  updatedComponent?.typeComposant?.customFields,
]
```
par :
```ts
[
  updatedComponent?.typeComposant?.structure?.customFields,
]
```

- [ ] **Step 2: Fix usePieceEdit.ts**

Ligne 410-412, remplacer :
```ts
[
  updatedPiece?.typePiece?.pieceCustomFields,
]
```
par :
```ts
[
  updatedPiece?.typePiece?.structure?.customFields,
]
```

- [ ] **Step 3: Verifier le meme pattern dans les autres fichiers**

Verifier `useComponentCreate.ts`, `pieces/create.vue`, `product/[id]/edit.vue` pour le meme probleme.

- [ ] **Step 4: Lint + typecheck**

```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```

- [ ] **Step 5: Commit**

```bash
cd frontend
git add app/composables/useComponentEdit.ts app/composables/usePieceEdit.ts
git commit -m "fix(custom-fields) : use structure.customFields path for definition lookup"
```

---

### Task 6 (bonus): Verifier et corriger les memes patterns dans create flows

- [ ] **Step 1:** Grep `_saveCustomFieldValues` dans tous les fichiers et verifier que chaque appel passe `structure.customFields` et non `customFields` ou `pieceCustomFields` directement.

- [ ] **Step 2:** Corriger si necessaire, lint, commit.

---

## Ordre d'execution recommande

1. **T1** (clone) — fix isole, pas de dependance
2. **T2** (slots validation) — fix isole
3. **T5** (frontend custom fields path) — fix isole
4. **T4** (orphan CustomField) — depend de T5 pour comprendre si le frontend utilise l'auto-creation
5. **T3** (conversion) — le plus complexe, faire en dernier
6. **T6** (bonus verification)
