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
commentManyToOne 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
documentsOneToMany 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
commentIdto 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