Files
Inventory/docs/superpowers/plans/2026-03-23-comment-documents.md
Matthieu be859e57db refactor : rename Inventory_frontend to frontend in docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:20:19 +02:00

26 KiB

Comment Document Attachments — 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: Allow users to attach one or more documents when creating a comment, via a single multipart/form-data request.

Architecture: Add a comment ManyToOne on Document entity (same pattern as machine/site/etc.), modify CommentController::create() to accept multipart/form-data with files + text fields, store files via existing DocumentStorageService, and update the frontend CommentSection.vue to include a file picker.

Tech Stack: Symfony 8, Doctrine, API Platform, Vue 3 Composition API, TypeScript, TailwindCSS/DaisyUI


Task 1: Migration — add comment_id FK on documents

Files:

  • Create: migrations/Version20260323160000.php

  • Step 1: Create the migration

<?php
declare(strict_types=1);
namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260323160000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Add comment_id FK on documents table';
    }

    public function up(Schema $schema): void
    {
        $this->addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'comment_id') THEN ALTER TABLE documents ADD COLUMN comment_id VARCHAR(36) DEFAULT NULL; END IF; END $$");
        $this->addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_documents_comment') THEN ALTER TABLE documents ADD CONSTRAINT fk_documents_comment FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE; END IF; END $$");
        $this->addSql("CREATE INDEX IF NOT EXISTS idx_documents_comment_id ON documents(comment_id)");
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_comment');
        $this->addSql('DROP INDEX IF EXISTS idx_documents_comment_id');
        $this->addSql('ALTER TABLE documents DROP COLUMN IF EXISTS comment_id');
    }
}
  • Step 2: Run the migration

Run: docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction Expected: Migration executes successfully.

  • Step 3: Update test schema

Run: make test-setup

  • Step 4: Commit
git add migrations/Version20260323160000.php
git commit -m "feat(documents) : add comment_id FK on documents table"

Task 2: Entity updates — Document.comment + Comment.documents

Files:

  • Modify: src/Entity/Document.php

  • Modify: src/Entity/Comment.php

  • Step 1: Add comment ManyToOne on Document entity

In src/Entity/Document.php, add after the $site property (around line 109):

#[ORM\ManyToOne(targetEntity: Comment::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'comment_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Comment $comment = null;

And add getter/setter:

public function getComment(): ?Comment
{
    return $this->comment;
}

public function setComment(?Comment $comment): static
{
    $this->comment = $comment;

    return $this;
}
  • Step 2: Add documents OneToMany on Comment entity

In src/Entity/Comment.php, add the import:

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

Add property after $updatedAt:

/** @var Collection<int, Document> */
#[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'comment', cascade: ['remove'])]
private Collection $documents;

Initialize in constructor:

public function __construct()
{
    $this->createdAt = new DateTimeImmutable();
    $this->updatedAt = new DateTimeImmutable();
    $this->documents = new ArrayCollection();
}

Add getter:

/** @return Collection<int, Document> */
public function getDocuments(): Collection
{
    return $this->documents;
}
  • Step 3: Run php-cs-fixer

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

  • Step 4: Run tests to check nothing broke

Run: make test Expected: All existing tests pass.

  • Step 5: Commit
git add src/Entity/Document.php src/Entity/Comment.php
git commit -m "feat(documents) : add Comment-Document relationship (ManyToOne/OneToMany)"

Task 3: Update CommentController to accept multipart/form-data with files

Files:

  • Modify: src/Controller/CommentController.php

  • Step 1: Add DocumentStorageService dependency and update create() method

Update constructor to inject DocumentStorageService:

use App\Entity\Document;
use App\Enum\DocumentType;
use App\Service\DocumentStorageService;
use Symfony\Component\HttpFoundation\File\UploadedFile;
public function __construct(
    private readonly EntityManagerInterface $entityManager,
    private readonly ProfileRepository $profiles,
    private readonly DocumentStorageService $storageService,
) {}

Replace the create() method body to handle both JSON and multipart:

#[Route('', name: 'api_comments_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
    $this->denyAccessUnlessGranted('ROLE_VIEWER');

    $session   = $request->getSession();
    $profileId = $session->get('profileId');
    if (!$profileId) {
        return $this->json(['message' => 'Aucun profil actif.'], 401);
    }

    $profile = $this->profiles->find($profileId);
    if (!$profile) {
        return $this->json(['message' => 'Profil introuvable.'], 401);
    }

    // Parse fields from JSON or form-data
    $contentType = $request->headers->get('Content-Type', '');
    if (str_contains($contentType, 'multipart/form-data')) {
        $content    = trim((string) $request->request->get('content', ''));
        $entityType = trim((string) $request->request->get('entityType', ''));
        $entityId   = trim((string) $request->request->get('entityId', ''));
        $entityName = $request->request->get('entityName') ? trim((string) $request->request->get('entityName')) : null;
    } else {
        $payload = json_decode($request->getContent(), true);
        if (!is_array($payload)) {
            return $this->json(['message' => 'Payload JSON invalide.'], 400);
        }
        $content    = trim((string) ($payload['content'] ?? ''));
        $entityType = trim((string) ($payload['entityType'] ?? ''));
        $entityId   = trim((string) ($payload['entityId'] ?? ''));
        $entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null;
    }

    if ('' === $content) {
        return $this->json(['message' => 'Le contenu est requis.'], 400);
    }

    $allowedTypes = ['machine', 'piece', 'composant', 'product', 'piece_category', 'component_category', 'product_category', 'machine_skeleton'];
    if (!in_array($entityType, $allowedTypes, true)) {
        return $this->json(['message' => 'Type d\'entité invalide.'], 400);
    }

    if ('' === $entityId) {
        return $this->json(['message' => 'L\'identifiant de l\'entité est requis.'], 400);
    }

    $authorName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
    if ('' === $authorName) {
        $authorName = $profile->getEmail() ?? 'Inconnu';
    }

    $comment = new Comment();
    $comment->setContent($content);
    $comment->setEntityType($entityType);
    $comment->setEntityId($entityId);
    $comment->setEntityName($entityName);
    $comment->setAuthorId($profileId);
    $comment->setAuthorName($authorName);

    $this->entityManager->persist($comment);

    // Handle file uploads
    /** @var UploadedFile[] $files */
    $files = $request->files->all('files');
    foreach ($files as $file) {
        if (!$file instanceof UploadedFile || !$file->isValid()) {
            continue;
        }

        $document   = new Document();
        $documentId = 'cl'.bin2hex(random_bytes(12));
        $document->setId($documentId);
        $document->setName($file->getClientOriginalName());
        $document->setFilename($file->getClientOriginalName());
        $document->setMimeType($file->getMimeType() ?: 'application/octet-stream');
        $document->setSize((int) $file->getSize());
        $document->setType(DocumentType::DOCUMENTATION);
        $document->setComment($comment);

        $extension    = $this->storageService->extensionFromFilename($file->getClientOriginalName());
        $relativePath = $this->storageService->storeFromPath(
            $file->getPathname(),
            $documentId,
            $extension,
        );
        $document->setPath($relativePath);

        $this->entityManager->persist($document);
    }

    $this->entityManager->flush();

    return $this->json($this->normalize($comment), 201);
}
  • Step 2: Update normalize() to include documents
private function normalize(Comment $comment): array
{
    $documents = [];
    foreach ($comment->getDocuments() as $document) {
        $documents[] = [
            'id'          => $document->getId(),
            'name'        => $document->getName(),
            'filename'    => $document->getFilename(),
            'mimeType'    => $document->getMimeType(),
            'size'        => $document->getSize(),
            'type'        => $document->getType()->value,
            'fileUrl'     => '/api/documents/'.$document->getId().'/file',
            'downloadUrl' => '/api/documents/'.$document->getId().'/download',
            'createdAt'   => $document->getCreatedAt()->format(DateTimeInterface::ATOM),
        ];
    }

    return [
        'id'             => $comment->getId(),
        'content'        => $comment->getContent(),
        'entityType'     => $comment->getEntityType(),
        'entityId'       => $comment->getEntityId(),
        'entityName'     => $comment->getEntityName(),
        'authorId'       => $comment->getAuthorId(),
        'authorName'     => $comment->getAuthorName(),
        'status'         => $comment->getStatus(),
        'resolvedById'   => $comment->getResolvedById(),
        'resolvedByName' => $comment->getResolvedByName(),
        'resolvedAt'     => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM),
        'createdAt'      => $comment->getCreatedAt()->format(DateTimeInterface::ATOM),
        'updatedAt'      => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM),
        'documents'      => $documents,
    ];
}
  • Step 3: Run php-cs-fixer

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

  • Step 4: Run tests

Run: make test Expected: All existing tests still pass (they use JSON, not multipart).

  • Step 5: Commit
git add src/Controller/CommentController.php
git commit -m "feat(comments) : accept multipart/form-data with file uploads on create"

Task 4: Update DocumentUploadProcessor and DocumentQueryController

Files:

  • Modify: src/State/DocumentUploadProcessor.php

  • Modify: src/Controller/DocumentQueryController.php

  • Step 1: Add commentId to DocumentUploadProcessor relation map

In src/State/DocumentUploadProcessor.php, update $relationMap in setRelationsFromRequest():

$relationMap = [
    'machineId'   => 'Machine',
    'composantId' => 'Composant',
    'pieceId'     => 'Piece',
    'productId'   => 'Product',
    'siteId'      => 'Site',
    'commentId'   => 'Comment',
];
  • Step 2: Add comment route to DocumentQueryController

Add CommentRepository import and inject it, then add the route:

use App\Repository\CommentRepository;

Add to constructor:

private readonly CommentRepository $commentRepository,

Wait — Comment has no repository. Use the EntityManager instead. Add the route method:

#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
    $this->denyAccessUnlessGranted('ROLE_VIEWER');

    $comment = $this->getEntityManager()->getRepository(\App\Entity\Comment::class)->find($id);
    if (!$comment) {
        return $this->json(['success' => false, 'error' => 'Comment not found.'], 404);
    }

    $documents = $this->documentRepository->findBy(['comment' => $comment]);

    return $this->json($this->normalizeDocuments($documents));
}

Actually, the controller doesn't have getEntityManager(). Use DocumentRepository directly:

#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
    $this->denyAccessUnlessGranted('ROLE_VIEWER');

    $documents = $this->documentRepository->findBy(['comment' => $id]);

    return $this->json($this->normalizeDocuments($documents));
}

Wait — findBy(['comment' => $id]) won't work with a string ID directly on a relation. Let me use the pattern from the existing code and add the Comment entity lookup. The simplest approach: inject EntityManagerInterface.

Actually, looking at the existing pattern more carefully, the other methods fetch the entity first and pass the object. We can use the documentRepository's entity manager. Let's just follow the exact same pattern and add a dependency. But actually, let's keep it simple — the documents table has comment_id column, so we can use a custom query. The simplest: just inject EntityManagerInterface.

use Doctrine\ORM\EntityManagerInterface;

Add to constructor: private readonly EntityManagerInterface $em,

#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
    $this->denyAccessUnlessGranted('ROLE_VIEWER');

    $comment = $this->em->find(\App\Entity\Comment::class, $id);
    if (!$comment) {
        return $this->json(['success' => false, 'error' => 'Comment not found.'], 404);
    }

    $documents = $this->documentRepository->findBy(['comment' => $comment]);

    return $this->json($this->normalizeDocuments($documents));
}
  • Step 3: Update normalizeDocuments to include commentId

Add to the normalizeDocuments return array:

'commentId' => $document->getComment()?->getId(),
  • Step 4: Run php-cs-fixer + tests

Run: make php-cs-fixer-allow-risky && make test

  • Step 5: Commit
git add src/State/DocumentUploadProcessor.php src/Controller/DocumentQueryController.php
git commit -m "feat(documents) : add comment support in upload processor and query controller"

Task 5: Backend tests — comment with documents

Files:

  • Modify: tests/Api/Controller/CommentControllerTest.php

  • Step 1: Add test for creating comment with files

public function testCreateCommentWithFiles(): void
{
    $machine = $this->createMachine('Machine A');

    $client = $this->createViewerClient();

    // Create a temporary file for upload
    $tmpFile = tempnam(sys_get_temp_dir(), 'test_');
    file_put_contents($tmpFile, 'test file content');

    $uploadedFile = new \Symfony\Component\HttpFoundation\File\UploadedFile(
        $tmpFile,
        'test-doc.pdf',
        'application/pdf',
        null,
        true,
    );

    $client->request('POST', '/api/comments', [
        'headers' => ['Content-Type' => 'multipart/form-data'],
        'extra'   => [
            'parameters' => [
                'content'    => 'Comment with file',
                'entityType' => 'machine',
                'entityId'   => $machine->getId(),
                'entityName' => 'Machine A',
            ],
            'files' => [
                'files' => [$uploadedFile],
            ],
        ],
    ]);

    $this->assertResponseStatusCodeSame(201);
    $data = json_decode($client->getResponse()->getContent(), true);
    $this->assertSame('Comment with file', $data['content']);
    $this->assertCount(1, $data['documents']);
    $this->assertSame('test-doc.pdf', $data['documents'][0]['filename']);

    @unlink($tmpFile);
}
  • Step 2: Add test for creating comment with multiple files
public function testCreateCommentWithMultipleFiles(): void
{
    $machine = $this->createMachine('Machine A');

    $client = $this->createViewerClient();

    $tmpFile1 = tempnam(sys_get_temp_dir(), 'test_');
    file_put_contents($tmpFile1, 'content 1');
    $tmpFile2 = tempnam(sys_get_temp_dir(), 'test_');
    file_put_contents($tmpFile2, 'content 2');

    $file1 = new \Symfony\Component\HttpFoundation\File\UploadedFile($tmpFile1, 'doc1.pdf', 'application/pdf', null, true);
    $file2 = new \Symfony\Component\HttpFoundation\File\UploadedFile($tmpFile2, 'doc2.png', 'image/png', null, true);

    $client->request('POST', '/api/comments', [
        'extra' => [
            'parameters' => [
                'content'    => 'Multiple files',
                'entityType' => 'machine',
                'entityId'   => $machine->getId(),
            ],
            'files' => [
                'files' => [$file1, $file2],
            ],
        ],
    ]);

    $this->assertResponseStatusCodeSame(201);
    $data = json_decode($client->getResponse()->getContent(), true);
    $this->assertCount(2, $data['documents']);

    @unlink($tmpFile1);
    @unlink($tmpFile2);
}
  • Step 3: Add test that existing JSON create still works and returns empty documents array
public function testCreateCommentJsonStillReturnsDocuments(): void
{
    $machine = $this->createMachine('Machine A');

    $client = $this->createViewerClient();
    $client->request('POST', '/api/comments', [
        'json' => [
            'content'    => 'No files',
            'entityType' => 'machine',
            'entityId'   => $machine->getId(),
        ],
    ]);

    $this->assertResponseStatusCodeSame(201);
    $data = json_decode($client->getResponse()->getContent(), true);
    $this->assertSame([], $data['documents']);
}
  • Step 4: Run tests

Run: make test Expected: All tests pass.

  • Step 5: Commit
git add tests/Api/Controller/CommentControllerTest.php
git commit -m "test(comments) : add tests for comment creation with file attachments"

Task 6: Frontend — update useComments composable

Files:

  • Modify: frontend/app/composables/useComments.ts

  • Step 1: Add document type to Comment interface

export interface CommentDocument {
  id: string
  name: string
  filename: string
  mimeType: string
  size: number
  type: string
  fileUrl: string
  downloadUrl: string
  createdAt: string
}

export interface Comment {
  id: string
  content: string
  entityType: string
  entityId: string
  entityName?: string | null
  authorId: string
  authorName: string
  status: 'open' | 'resolved'
  resolvedById?: string | null
  resolvedByName?: string | null
  resolvedAt?: string | null
  createdAt: string
  updatedAt: string
  documents: CommentDocument[]
}
  • Step 2: Update createComment to accept files and use FormData

Add postFormData to the destructured useApi() call:

const { get, post, patch, postFormData, delete: del } = useApi()

Update createComment:

const createComment = async (
  entityType: string,
  entityId: string,
  content: string,
  entityName?: string,
  files?: File[],
): Promise<CommentResult> => {
  loading.value = true
  try {
    let result
    if (files && files.length > 0) {
      const formData = new FormData()
      formData.append('content', content)
      formData.append('entityType', entityType)
      formData.append('entityId', entityId)
      if (entityName) formData.append('entityName', entityName)
      for (const file of files) {
        formData.append('files[]', file)
      }
      result = await postFormData('/comments', formData)
    } else {
      const payload: Record<string, string> = { entityType, entityId, content }
      if (entityName) payload.entityName = entityName
      result = await post('/comments', payload)
    }
    if (result.success) {
      showSuccess('Commentaire ajouté')
      return { success: true, data: result.data as Comment }
    }
    if (result.error) showError(result.error)
    return { success: false, error: result.error }
  } catch (error) {
    const err = error as Error
    showError('Impossible d\'ajouter le commentaire')
    return { success: false, error: err.message }
  } finally {
    loading.value = false
  }
}
  • Step 3: Run lint + typecheck

Run: cd frontend && npm run lint:fix && npx nuxi typecheck

  • Step 4: Commit (in frontend submodule)
cd frontend
git add app/composables/useComments.ts
git commit -m "feat(comments) : support file attachments in createComment"

Task 7: Frontend — update CommentSection.vue

Files:

  • Modify: frontend/app/components/CommentSection.vue

  • Step 1: Add file input and file list display to the template

Replace the form section (lines 22-40) with:

<!-- Formulaire d'ajout -->
<div class="space-y-2">
  <div class="flex gap-2">
    <textarea
      v-model="newContent"
      class="textarea textarea-bordered flex-1 text-sm"
      rows="2"
      placeholder="Ajouter un commentaire..."
      :disabled="submitting"
      @keydown.ctrl.enter="handleSubmit"
    />
    <div class="flex flex-col gap-1 self-end">
      <label
        class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
        data-tip="Joindre des fichiers"
      >
        <IconLucidePaperclip class="w-4 h-4" />
        <input
          ref="fileInputRef"
          type="file"
          multiple
          class="hidden"
          @change="handleFilesSelected"
        />
      </label>
      <button
        type="button"
        class="btn btn-primary btn-sm btn-square"
        :disabled="!newContent.trim() || submitting"
        @click="handleSubmit"
      >
        <span v-if="submitting" class="loading loading-spinner loading-xs" />
        <IconLucideSend v-else class="w-4 h-4" />
      </button>
    </div>
  </div>
  <!-- Selected files preview -->
  <div v-if="selectedFiles.length" class="flex flex-wrap gap-1">
    <span
      v-for="(file, i) in selectedFiles"
      :key="i"
      class="badge badge-sm badge-outline gap-1"
    >
      <IconLucideFile class="w-3 h-3" />
      {{ file.name }}
      <button type="button" class="ml-1" @click="removeFile(i)">
        <IconLucideX class="w-3 h-3" />
      </button>
    </span>
  </div>
</div>

Add after each comment's content (<p class="text-sm whitespace-pre-wrap">) in both open and resolved sections:

<!-- Documents attachés -->
<div v-if="comment.documents?.length" class="flex flex-wrap gap-1 mt-1">
  <a
    v-for="doc in comment.documents"
    :key="doc.id"
    :href="`${apiBase}${doc.downloadUrl}`"
    target="_blank"
    class="badge badge-sm badge-ghost gap-1 hover:badge-primary"
  >
    <IconLucideFile class="w-3 h-3" />
    {{ doc.filename }}
  </a>
</div>
  • Step 2: Update script setup

Add new imports:

import IconLucidePaperclip from '~icons/lucide/paperclip'
import IconLucideFile from '~icons/lucide/file'
import IconLucideX from '~icons/lucide/x'

Add after existing refs:

const selectedFiles = ref<File[]>([])
const fileInputRef = ref<HTMLInputElement | null>(null)
const apiBase = useRuntimeConfig().public.apiBase || ''

Add file management functions:

const handleFilesSelected = (e: Event) => {
  const input = e.target as HTMLInputElement
  if (input.files) {
    selectedFiles.value.push(...Array.from(input.files))
  }
  // Reset input so the same file can be re-selected
  input.value = ''
}

const removeFile = (index: number) => {
  selectedFiles.value.splice(index, 1)
}

Update handleSubmit:

const handleSubmit = async () => {
  const content = newContent.value.trim()
  if (!content) return
  submitting.value = true
  const result = await createComment(
    props.entityType,
    props.entityId,
    content,
    props.entityName,
    selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
  )
  submitting.value = false
  if (result.success) {
    newContent.value = ''
    selectedFiles.value = []
    await loadComments()
  }
}
  • Step 3: Run lint + typecheck

Run: cd frontend && npm run lint:fix && npx nuxi typecheck

  • Step 4: Commit (in frontend submodule)
cd frontend
git add app/components/CommentSection.vue
git commit -m "feat(comments) : add file attachment UI to CommentSection"

Task 8: Update API Platform filter and submodule pointer

Files:

  • Modify: src/Entity/Document.php (add ExistsFilter for comment)

  • Step 1: Add comment to ExistsFilter on Document entity

Update the ApiFilter(ExistsFilter...) line in Document.php:

#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product', 'comment'])]
  • Step 2: Run php-cs-fixer + all backend tests

Run: make php-cs-fixer-allow-risky && make test

  • Step 3: Commit backend
git add src/Entity/Document.php
git commit -m "feat(documents) : add comment ExistsFilter"
  • Step 4: Update submodule pointer
git add frontend
git commit -m "chore(submodule) : update frontend pointer (comment documents feature)"

Task 9: Manual verification

  • Step 1: Start the app

Run: make start

  • Step 2: Test creating a comment without files — should work exactly as before, response now includes "documents": []

  • Step 3: Test creating a comment with files — use the paperclip button, select 1-2 files, submit. Files should appear as badges on the comment.

  • Step 4: Click a file badge — should download the file.

  • Step 5: Run full test suite one last time

Run: make test