872 lines
26 KiB
Markdown
872 lines
26 KiB
Markdown
# 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
|
|
<?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**
|
|
|
|
```bash
|
|
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):
|
|
|
|
```php
|
|
#[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:
|
|
|
|
```php
|
|
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:
|
|
```php
|
|
use Doctrine\Common\Collections\ArrayCollection;
|
|
use Doctrine\Common\Collections\Collection;
|
|
```
|
|
|
|
Add property after `$updatedAt`:
|
|
```php
|
|
/** @var Collection<int, Document> */
|
|
#[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'comment', cascade: ['remove'])]
|
|
private Collection $documents;
|
|
```
|
|
|
|
Initialize in constructor:
|
|
```php
|
|
public function __construct()
|
|
{
|
|
$this->createdAt = new DateTimeImmutable();
|
|
$this->updatedAt = new DateTimeImmutable();
|
|
$this->documents = new ArrayCollection();
|
|
}
|
|
```
|
|
|
|
Add getter:
|
|
```php
|
|
/** @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**
|
|
|
|
```bash
|
|
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`:
|
|
```php
|
|
use App\Entity\Document;
|
|
use App\Enum\DocumentType;
|
|
use App\Service\DocumentStorageService;
|
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
|
```
|
|
|
|
```php
|
|
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:
|
|
|
|
```php
|
|
#[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**
|
|
|
|
```php
|
|
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**
|
|
|
|
```bash
|
|
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()`:
|
|
|
|
```php
|
|
$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:
|
|
|
|
```php
|
|
use App\Repository\CommentRepository;
|
|
```
|
|
|
|
Add to constructor:
|
|
```php
|
|
private readonly CommentRepository $commentRepository,
|
|
```
|
|
|
|
Wait — `Comment` has no repository. Use the EntityManager instead. Add the route method:
|
|
|
|
```php
|
|
#[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:
|
|
|
|
```php
|
|
#[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.
|
|
|
|
```php
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
```
|
|
|
|
Add to constructor: `private readonly EntityManagerInterface $em,`
|
|
|
|
```php
|
|
#[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:
|
|
```php
|
|
'commentId' => $document->getComment()?->getId(),
|
|
```
|
|
|
|
- [ ] **Step 4: Run php-cs-fixer + tests**
|
|
|
|
Run: `make php-cs-fixer-allow-risky && make test`
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```php
|
|
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**
|
|
|
|
```php
|
|
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**
|
|
|
|
```php
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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:
|
|
```typescript
|
|
const { get, post, patch, postFormData, delete: del } = useApi()
|
|
```
|
|
|
|
Update `createComment`:
|
|
```typescript
|
|
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)**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```vue
|
|
<!-- 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:
|
|
|
|
```vue
|
|
<!-- 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:
|
|
```typescript
|
|
import IconLucidePaperclip from '~icons/lucide/paperclip'
|
|
import IconLucideFile from '~icons/lucide/file'
|
|
import IconLucideX from '~icons/lucide/x'
|
|
```
|
|
|
|
Add after existing refs:
|
|
```typescript
|
|
const selectedFiles = ref<File[]>([])
|
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
|
const apiBase = useRuntimeConfig().public.apiBase || ''
|
|
```
|
|
|
|
Add file management functions:
|
|
```typescript
|
|
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`:
|
|
```typescript
|
|
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)**
|
|
|
|
```bash
|
|
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`:
|
|
```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**
|
|
|
|
```bash
|
|
git add src/Entity/Document.php
|
|
git commit -m "feat(documents) : add comment ExistsFilter"
|
|
```
|
|
|
|
- [ ] **Step 4: Update submodule pointer**
|
|
|
|
```bash
|
|
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`
|