Compare commits

..

14 Commits

Author SHA1 Message Date
202b516dc3 fix(avatar) : install symfony/mime for server-side MIME type detection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:13:18 +01:00
98782a9849 fix(avatar) : add explicit import for useAvatarService in profile page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:12:38 +01:00
b978adf9ae fix(avatar) : move avatar service to composables for Nuxt auto-import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:11:03 +01:00
e4fc34b90f refactor : simplify codebase and fix critical issues
Backend:
- Add MCP Serializer to centralize entity-to-array conversion (~300 lines deduped)
- Fix race condition in task/ticket number generation (SELECT FOR UPDATE + transaction)
- Add unique constraint on task (project_id, number) with migration
- Fix MIME type validation: use server-detected finfo instead of client-supplied type
- Add allowlist of permitted MIME types for uploads
- Fix TaskDocumentDownloadController: allow ROLE_CLIENT access, add priority:1
- Fix notification sent even when ticket status unchanged
- Remove redundant exception constructors
- Simplify services (BookStackApi double fetch, TokenEncryptor, GiteaApi)
- Consolidate duplicate checks in processors

Frontend:
- Fix useApi isHandlingUnauthorized scope (module-level to prevent double 401 redirect)
- Fix client-tickets toast key copy-paste bug
- Merge duplicated tasks service methods (getByProject + getByProjectArchived)
- Extract shared uploadWithRelation helper in task-documents service
- Extract formatFileSize utility from duplicated component code
- Extract status transition logic into useClientTicketHelpers composable
- Remove dead code (unused router, handleLogout, empty script blocks)
- Merge duplicate watchers and onMounted calls
- Normalize arrow functions to function declarations per convention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:09:16 +01:00
a5144443a4 fix(avatar) : address review findings — security and UX fixes
- Use getMimeType() instead of getClientMimeType() to prevent MIME spoofing
- Change IsGranted to IS_AUTHENTICATED_FULLY so ROLE_CLIENT can access avatars
- Remove Groups from avatarFileName (only avatarUrl needed by frontend)
- Disable aggressive caching to prevent stale avatar images
- Add error handling to avatar upload in profile page
- Use i18n for "Mon profil" button text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:02:27 +01:00
afd4baed92 feat(avatar) : replace initials with UserAvatar component everywhere
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:58:46 +01:00
e8f0202b15 feat(avatar) : add profile page with avatar upload and crop
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:57:55 +01:00
962b3d935c feat(avatar) : add AvatarCropper modal with vue-advanced-cropper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:56:11 +01:00
cea22f977b feat(avatar) : add UserAvatar component with image/initials fallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:55:52 +01:00
5613a7c92b feat(avatar) : add avatar service, DTO update, and cropper dependency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:55:39 +01:00
4d0aa65920 feat(avatar) : add avatar upload/serve/delete controller
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:54:23 +01:00
63315c0a15 feat(avatar) : add avatarFileName field to User entity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:53:43 +01:00
cff16611f4 docs : add user avatar implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:50:07 +01:00
96f5c7c91c docs : add user avatar feature design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:47:38 +01:00
74 changed files with 2311 additions and 609 deletions

View File

@@ -25,6 +25,7 @@
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/mcp-bundle": "^0.6.0",
"symfony/mime": "8.0.*",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/runtime": "8.0.*",

175
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "75b9dbecf38167d0554dfd64a986a40e",
"content-hash": "3e2146f74bbda750c75ab52eb437d2d4",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -5703,6 +5703,92 @@
],
"time": "2026-03-04T16:39:24+00:00"
},
{
"name": "symfony/mime",
"version": "v8.0.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b",
"reference": "5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"egulias/email-validator": "~3.0.0",
"phpdocumentor/reflection-docblock": "<5.2|>=7",
"phpdocumentor/type-resolver": "<1.5.1"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/property-access": "^7.4|^8.0",
"symfony/property-info": "^7.4|^8.0",
"symfony/serializer": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Mime\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows manipulating MIME messages",
"homepage": "https://symfony.com",
"keywords": [
"mime",
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v8.0.7"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-06T13:17:40+00:00"
},
{
"name": "symfony/password-hasher",
"version": "v8.0.6",
@@ -5858,6 +5944,93 @@
],
"time": "2025-06-27T09:58:17+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"symfony/polyfill-intl-normalizer": "^1.10"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Idn\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Laurent Bassin",
"email": "laurent@bassin.info"
},
{
"name": "Trevor Rowbotham",
"email": "trevor.rowbotham@pm.me"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"idn",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-10T14:38:51+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.33.0",

View File

@@ -8,6 +8,7 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'
imports:
- { resource: version.yaml }
@@ -39,3 +40,7 @@ services:
App\Controller\TaskDocumentDownloadController:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Controller\UserAvatarController:
arguments:
$avatarUploadDir: '%avatar_upload_dir%'

View File

@@ -0,0 +1,802 @@
# User Avatar Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users upload a cropped profile avatar that replaces initials everywhere in the app.
**Architecture:** New `avatarFileName` column on User entity, dedicated upload/serve/delete controllers, `UserAvatar.vue` component with `vue-advanced-cropper` for circular crop, and a `/profile` page for management.
**Tech Stack:** PHP 8.4/Symfony 8, Doctrine migration, `vue-advanced-cropper`, Nuxt 4 SPA
---
## File Structure
### Backend (create)
- `src/Controller/UserAvatarController.php` — upload, serve, delete avatar (3 routes)
### Backend (modify)
- `src/Entity/User.php` — add `avatarFileName` field + `getAvatarUrl()` virtual getter
- `config/services.yaml` — add `avatar_upload_dir` parameter + wire controller
### Frontend (create)
- `frontend/components/user/UserAvatar.vue` — reusable avatar display (image or initials fallback)
- `frontend/components/user/AvatarCropper.vue` — crop modal using `vue-advanced-cropper`
- `frontend/services/avatar.ts` — avatar API service (upload, remove, getUrl)
- `frontend/pages/profile.vue` — profile page with avatar management
### Frontend (modify)
- `frontend/services/dto/user-data.ts` — add `avatarUrl` to `UserData`
- `frontend/stores/auth.ts` — add `refreshUser()` action
- `frontend/components/ui/AppTopNav.vue` — use `UserAvatar` + link "Mon profil" to `/profile`
- `frontend/components/task/TaskCard.vue:47-59` — replace initials with `UserAvatar`
- `frontend/pages/projects/[id]/archives.vue:49-55` — replace initials with `UserAvatar`
- `frontend/components/admin/AdminClientTicketTab.vue:82` — use `UserAvatar` for submitter
- `frontend/middleware/auth.global.ts` — allow `/profile` for all authenticated users
---
## Task 1: Backend — User entity + migration
**Files:**
- Modify: `src/Entity/User.php`
- Create: migration file (generated)
- [ ] **Step 1: Add `avatarFileName` field to User entity**
In `src/Entity/User.php`, add after the `$apiToken` field:
```php
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['me:read', 'user:list'])]
private ?string $avatarFileName = null;
```
Add getter/setter:
```php
public function getAvatarFileName(): ?string
{
return $this->avatarFileName;
}
public function setAvatarFileName(?string $avatarFileName): static
{
$this->avatarFileName = $avatarFileName;
return $this;
}
```
Add virtual `avatarUrl` getter (serialized, read-only):
```php
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])]
public function getAvatarUrl(): ?string
{
if (null === $this->avatarFileName) {
return null;
}
return '/api/users/' . $this->id . '/avatar';
}
```
- [ ] **Step 2: Generate and run migration**
```bash
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:diff
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction
```
- [ ] **Step 3: Commit**
```bash
git add src/Entity/User.php migrations/
git commit -m "feat(avatar) : add avatarFileName field to User entity"
```
---
## Task 2: Backend — Avatar controller
**Files:**
- Create: `src/Controller/UserAvatarController.php`
- Modify: `config/services.yaml`
- [ ] **Step 1: Add `avatar_upload_dir` parameter in `config/services.yaml`**
Add to `parameters:` section:
```yaml
avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'
```
Add service wiring:
```yaml
App\Controller\UserAvatarController:
arguments:
$avatarUploadDir: '%avatar_upload_dir%'
```
- [ ] **Step 2: Create `UserAvatarController.php`**
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
class UserAvatarController extends AbstractController
{
private const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly string $avatarUploadDir,
) {}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_upload', methods: ['POST'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function upload(int $id, Request $request): JsonResponse
{
$user = $this->findUserOrFail($id);
$this->assertCanManageAvatar($user);
$file = $request->files->get('file');
if (null === $file || !$file->isValid()) {
throw new BadRequestHttpException('No valid file uploaded.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('File size exceeds 5 MB limit.');
}
$mimeType = $file->getClientMimeType();
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new BadRequestHttpException('Invalid file type. Allowed: JPEG, PNG, WebP, GIF.');
}
// Delete previous avatar file if exists
$this->deleteAvatarFile($user);
$extension = $file->guessExtension() ?? 'bin';
$fileName = Uuid::v4()->toRfc4122() . '.' . $extension;
if (!is_dir($this->avatarUploadDir)) {
mkdir($this->avatarUploadDir, 0o775, true);
}
$file->move($this->avatarUploadDir, $fileName);
$user->setAvatarFileName($fileName);
$this->entityManager->flush();
return new JsonResponse(['avatarUrl' => $user->getAvatarUrl()]);
}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_serve', methods: ['GET'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function serve(int $id): BinaryFileResponse
{
$user = $this->findUserOrFail($id);
if (null === $user->getAvatarFileName()) {
throw new NotFoundHttpException('No avatar set.');
}
$filePath = $this->avatarUploadDir . '/' . $user->getAvatarFileName();
if (!file_exists($filePath)) {
throw new NotFoundHttpException('Avatar file not found on disk.');
}
$response = new BinaryFileResponse($filePath);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $user->getAvatarFileName());
$extension = pathinfo($user->getAvatarFileName(), PATHINFO_EXTENSION);
$mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'];
$response->headers->set('Content-Type', $mimeMap[$extension] ?? 'application/octet-stream');
$response->headers->set('Cache-Control', 'public, max-age=86400');
return $response;
}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_delete', methods: ['DELETE'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function delete(int $id): Response
{
$user = $this->findUserOrFail($id);
$this->assertCanManageAvatar($user);
$this->deleteAvatarFile($user);
$user->setAvatarFileName(null);
$this->entityManager->flush();
return new Response(null, Response::HTTP_NO_CONTENT);
}
private function findUserOrFail(int $id): User
{
$user = $this->entityManager->getRepository(User::class)->find($id);
if (null === $user) {
throw new NotFoundHttpException('User not found.');
}
return $user;
}
private function assertCanManageAvatar(User $user): void
{
$currentUser = $this->getUser();
if ($currentUser !== $user && !$this->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('You can only manage your own avatar.');
}
}
private function deleteAvatarFile(User $user): void
{
if (null === $user->getAvatarFileName()) {
return;
}
$filePath = $this->avatarUploadDir . '/' . $user->getAvatarFileName();
if (file_exists($filePath)) {
unlink($filePath);
}
}
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Controller/UserAvatarController.php config/services.yaml
git commit -m "feat(avatar) : add avatar upload/serve/delete controller"
```
---
## Task 3: Frontend — Install vue-advanced-cropper + DTO + service
**Files:**
- Modify: `frontend/services/dto/user-data.ts`
- Create: `frontend/services/avatar.ts`
- Modify: `frontend/stores/auth.ts`
- [ ] **Step 1: Install vue-advanced-cropper**
```bash
cd frontend && npm install vue-advanced-cropper
```
- [ ] **Step 2: Update `UserData` DTO**
In `frontend/services/dto/user-data.ts`, add `avatarUrl` to `UserData`:
```typescript
export type UserData = {
id: number
'@id'?: string
username: string
roles: string[]
client?: { id: number; name: string } | null
allowedProjects?: Project[]
avatarUrl?: string | null
}
```
- [ ] **Step 3: Create `frontend/services/avatar.ts`**
```typescript
export function useAvatarService() {
const api = useApi()
async function upload(userId: number, file: Blob): Promise<{ avatarUrl: string }> {
const formData = new FormData()
formData.append('file', file, 'avatar.png')
return $fetch(`/api/users/${userId}/avatar`, {
method: 'POST',
body: formData,
credentials: 'include',
})
}
async function remove(userId: number): Promise<void> {
await api.delete(`/users/${userId}/avatar`)
}
function getUrl(userId: number): string {
return `/api/users/${userId}/avatar`
}
return { upload, remove, getUrl }
}
```
- [ ] **Step 4: Add `refreshUser` to auth store**
In `frontend/stores/auth.ts`, add to actions:
```typescript
async refreshUser() {
try {
const me = await getCurrentUser()
this.user = me
} catch {
// Silently fail — user session might have expired
}
}
```
- [ ] **Step 5: Commit**
```bash
git add frontend/package.json frontend/package-lock.json frontend/services/dto/user-data.ts frontend/services/avatar.ts frontend/stores/auth.ts
git commit -m "feat(avatar) : add avatar service, DTO update, and cropper dependency"
```
---
## Task 4: Frontend — UserAvatar component
**Files:**
- Create: `frontend/components/user/UserAvatar.vue`
- [ ] **Step 1: Create `UserAvatar.vue`**
```vue
<template>
<span
class="inline-flex shrink-0 items-center justify-center rounded-full"
:class="sizeClasses"
:title="user.username"
>
<img
v-if="user.avatarUrl && !imgError"
:src="user.avatarUrl"
:alt="user.username"
class="h-full w-full rounded-full object-cover"
@error="imgError = true"
/>
<span
v-else
class="flex h-full w-full items-center justify-center rounded-full bg-primary-500 font-bold text-white"
:class="textSizeClass"
>
{{ user.username.substring(0, 2).toUpperCase() }}
</span>
</span>
</template>
<script setup lang="ts">
const props = defineProps<{
user: { id?: number; username: string; avatarUrl?: string | null }
size?: 'xs' | 'sm' | 'md' | 'lg'
}>()
const imgError = ref(false)
watch(() => props.user.avatarUrl, () => {
imgError.value = false
})
const sizeClasses = computed(() => {
const map = {
xs: 'h-5 w-5',
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-12 w-12',
}
return map[props.size ?? 'sm']
})
const textSizeClass = computed(() => {
const map = {
xs: 'text-[10px]',
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
}
return map[props.size ?? 'sm']
})
</script>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/components/user/UserAvatar.vue
git commit -m "feat(avatar) : add UserAvatar component with image/initials fallback"
```
---
## Task 5: Frontend — AvatarCropper component
**Files:**
- Create: `frontend/components/user/AvatarCropper.vue`
- [ ] **Step 1: Create `AvatarCropper.vue`**
```vue
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<h3 class="mb-4 text-lg font-bold text-neutral-900">
{{ $t('profile.cropAvatar') }}
</h3>
<div class="mx-auto mb-4 h-72 w-72">
<Cropper
ref="cropperRef"
:src="imageSrc"
:stencil-component="CircleStencil"
:stencil-props="{ aspectRatio: 1 }"
:canvas="{ width: 256, height: 256 }"
class="h-full w-full"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
@click="emit('cancel')"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
:disabled="cropping"
@click="onConfirm"
>
{{ $t('common.confirm') }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
const props = defineProps<{
imageFile: File
}>()
const emit = defineEmits<{
(e: 'crop', blob: Blob): void
(e: 'cancel'): void
}>()
const cropperRef = ref()
const cropping = ref(false)
const imageSrc = ref('')
onMounted(() => {
imageSrc.value = URL.createObjectURL(props.imageFile)
})
onUnmounted(() => {
if (imageSrc.value) {
URL.revokeObjectURL(imageSrc.value)
}
})
async function onConfirm() {
cropping.value = true
try {
const { canvas } = cropperRef.value.getResult()
if (!canvas) return
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png')
})
if (blob) {
emit('crop', blob)
}
} finally {
cropping.value = false
}
}
</script>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/components/user/AvatarCropper.vue
git commit -m "feat(avatar) : add AvatarCropper modal with vue-advanced-cropper"
```
---
## Task 6: Frontend — Profile page
**Files:**
- Create: `frontend/pages/profile.vue`
- Modify: `frontend/middleware/auth.global.ts`
- [ ] **Step 1: Create `frontend/pages/profile.vue`**
```vue
<template>
<div class="mx-auto max-w-lg px-4 py-10">
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
<!-- Current avatar -->
<UserAvatar
v-if="auth.user"
:user="auth.user"
size="lg"
/>
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
<div class="flex gap-3">
<label
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
>
{{ $t('profile.changeAvatar') }}
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="hidden"
@change="onFileSelect"
/>
</label>
<button
v-if="auth.user?.avatarUrl"
type="button"
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
:disabled="removing"
@click="onRemove"
>
{{ $t('profile.removeAvatar') }}
</button>
</div>
</div>
<!-- Crop modal -->
<AvatarCropper
v-if="selectedFile"
:image-file="selectedFile"
@crop="onCrop"
@cancel="selectedFile = null"
/>
</div>
</template>
<script setup lang="ts">
const auth = useAuthStore()
const { upload, remove } = useAvatarService()
const selectedFile = ref<File | null>(null)
const removing = ref(false)
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
selectedFile.value = file
}
input.value = ''
}
async function onCrop(blob: Blob) {
selectedFile.value = null
if (!auth.user) return
await upload(auth.user.id, blob)
await auth.refreshUser()
}
async function onRemove() {
if (!auth.user) return
removing.value = true
try {
await remove(auth.user.id)
await auth.refreshUser()
} finally {
removing.value = false
}
}
</script>
```
- [ ] **Step 2: Allow `/profile` for ROLE_CLIENT in middleware**
In `frontend/middleware/auth.global.ts`, update the client redirect block to also allow `/profile`:
Change:
```typescript
if (!isPortalRoute && !isLoginRoute) {
```
To:
```typescript
const isProfileRoute = to.path === '/profile'
if (!isPortalRoute && !isLoginRoute && !isProfileRoute) {
```
- [ ] **Step 3: Add i18n keys**
In `frontend/i18n/locales/fr.json`, add under a `"profile"` key:
```json
"profile": {
"title": "Mon profil",
"changeAvatar": "Changer l'avatar",
"removeAvatar": "Supprimer l'avatar",
"cropAvatar": "Recadrer l'avatar"
}
```
- [ ] **Step 4: Commit**
```bash
git add frontend/pages/profile.vue frontend/middleware/auth.global.ts frontend/i18n/locales/fr.json
git commit -m "feat(avatar) : add profile page with avatar upload and crop"
```
---
## Task 7: Frontend — Replace initials everywhere
**Files:**
- Modify: `frontend/components/ui/AppTopNav.vue`
- Modify: `frontend/components/task/TaskCard.vue`
- Modify: `frontend/pages/projects/[id]/archives.vue`
- Modify: `frontend/components/admin/AdminClientTicketTab.vue`
- [ ] **Step 1: Update `AppTopNav.vue`**
Replace the icon + username display (lines 12-14):
```vue
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
```
With:
```vue
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
<Icon v-else name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
```
Make "Mon profil" button navigate to `/profile`:
```vue
<button
type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
@click="navigateTo('/profile')"
>
Mon profil
</button>
```
- [ ] **Step 2: Update `TaskCard.vue`**
Replace lines 47-59 (the assignee initials span + empty state):
```vue
<UserAvatar
v-if="task.assignee"
:user="task.assignee"
size="xs"
class="ml-auto"
/>
<span
v-else
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
>
<Icon name="mdi:account-outline" size="14" />
</span>
```
- [ ] **Step 3: Update `archives.vue`**
Replace lines 49-55 (the assignee initials span):
```vue
<UserAvatar
v-if="task.assignee"
:user="task.assignee"
size="xs"
/>
```
- [ ] **Step 4: Update `AdminClientTicketTab.vue`**
Replace the submitter `<td>` at line 82. The `getSubmitterName` function returns a username string. We need to look up the full user to get `avatarUrl`. Modify the function and display:
Change the `<td>`:
```vue
<td class="px-3 py-3 text-neutral-600">
<div class="flex items-center gap-2">
<UserAvatar
v-if="getSubmitterUser(ticket.submittedBy)"
:user="getSubmitterUser(ticket.submittedBy)!"
size="sm"
/>
{{ getSubmitterName(ticket.submittedBy) }}
</div>
</td>
```
Add helper function:
```typescript
function getSubmitterUser(iri: string | null): UserData | undefined {
if (!iri) return undefined
const match = iri.match(/\/api\/users\/(\d+)/)
if (!match) return undefined
const id = Number(match[1])
return users.value.find(u => u.id === id)
}
```
- [ ] **Step 5: Commit**
```bash
git add frontend/components/ui/AppTopNav.vue frontend/components/task/TaskCard.vue frontend/pages/projects/[id]/archives.vue frontend/components/admin/AdminClientTicketTab.vue
git commit -m "feat(avatar) : replace initials with UserAvatar component everywhere"
```
---
## Task 8: Manual testing
- [ ] **Step 1: Rebuild and test**
```bash
make dev-nuxt
```
- [ ] **Step 2: Test flow**
1. Login as `admin` / `admin`
2. Navigate to profile via header dropdown → "Mon profil"
3. Upload an image → verify crop modal appears with circular stencil
4. Confirm crop → verify avatar appears on profile page
5. Check header — avatar should replace the icon
6. Navigate to a project board — assignee cards should show avatar
7. Navigate to archives — same check
8. Go to admin ticket tab — submitter should show avatar + name
9. Remove avatar → verify initials return everywhere
10. Login as `client-liot` / `client` → verify profile page accessible from portal
- [ ] **Step 3: Final commit if any fixes needed**

View File

@@ -0,0 +1,112 @@
# User Avatar — Design Spec
## Goal
Allow users to upload a profile avatar image (with client-side circular crop) that replaces initials everywhere in the app.
## Backend
### Entity Changes
**User** — add nullable field:
- `avatarFileName: ?string` (length 255) — UUID-based filename stored on disk
### Storage
- Directory: `var/uploads/avatars/`
- Parameter in `services.yaml`: `avatar_upload_dir`
### Endpoints
All under `/api/users/{id}/avatar`:
| Method | Description | Auth |
|--------|-------------|------|
| `POST` | Upload avatar (multipart file) | Owner or ROLE_ADMIN |
| `GET` | Serve avatar image (inline) | ROLE_USER or ROLE_CLIENT |
| `DELETE` | Remove avatar | Owner or ROLE_ADMIN |
**POST** accepts a single `file` field. Validates: image MIME (jpeg, png, webp, gif), max 5 MB. Stores with UUID filename, updates `avatarFileName`. Deletes previous file if exists.
**GET** returns the image with proper `Content-Type`. Returns 404 if no avatar.
**DELETE** removes file from disk, sets `avatarFileName` to null.
These are custom Symfony controllers (not API Platform resources) under `/api/` with `priority: 1`.
### Serialization
Add a virtual `avatarUrl` field to User serialization (group `user:read`):
- If `avatarFileName` is set: `/api/users/{id}/avatar`
- If null: `null`
This way the frontend knows if an avatar exists from any user payload.
### Migration
- Add `avatar_file_name` column (VARCHAR 255, nullable) to `user` table.
## Frontend
### New Components
**`UserAvatar.vue`** (`frontend/components/user/UserAvatar.vue`):
- Props: `user: { id: number, username: string, avatarUrl?: string | null }`, `size: 'xs' | 'sm' | 'md' | 'lg'`
- Sizes: xs=20px, sm=24px, md=32px, lg=48px
- If `avatarUrl`: `<img>` rounded-full, object-cover
- Else: initials badge (current bg-primary-500 style), 2 first chars of username uppercased
- Handles `@error` on img to fallback to initials (broken image)
**`AvatarCropper.vue`** (`frontend/components/user/AvatarCropper.vue`):
- Uses `vue-advanced-cropper` with `CircleStencil`
- Props: `imageFile: File`
- Emits: `crop(blob: Blob)`, `cancel`
- Fixed output size: 256x256px
- Modal overlay with crop area + confirm/cancel buttons
### New Page
**`/profile`** (`frontend/pages/profile.vue`):
- Shows current avatar (large) with "Change" button
- File input triggers AvatarCropper modal
- On confirm: POST blob to `/api/users/{id}/avatar`
- On success: refresh auth store user data
- "Remove avatar" button if avatar exists
- Accessible from "Mon profil" button in AppTopNav dropdown
### New Service
**`frontend/services/avatar.ts`**:
- `upload(userId: number, file: Blob): Promise<void>` — POST multipart
- `remove(userId: number): Promise<void>` — DELETE
- `getUrl(userId: number): string` — returns URL path
### DTO Update
**`UserData`** — add: `avatarUrl?: string | null`
### Replacement Points
Replace initials/icon with `<UserAvatar>` in:
| File | Current display | Size |
|------|----------------|------|
| `TaskCard.vue:48-53` | Initials badge (h-5 w-5) | xs |
| `archives.vue:50-55` | Initials badge (h-5 w-5) | xs |
| `AppTopNav.vue:13` | `mdi:account-circle-outline` icon | md |
| `AdminClientTicketTab.vue` | Username text for submitter | sm |
| `ClientTicketDetailModal.vue` | submittedBy display | sm |
### Auth Store
After avatar upload/delete, re-fetch current user data so `avatarUrl` updates everywhere reactively.
## Dependencies
- `vue-advanced-cropper` — npm install in frontend/
## Out of Scope
- Server-side image processing/resize
- Multiple image formats conversion
- Avatar for clients (entities), only users

View File

@@ -10,15 +10,13 @@
input-class="w-full"
/>
<div>
<MalioInputText
v-model="form.tokenId"
:label="$t('bookstack.settings.tokenId')"
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
input-class="w-full"
type="password"
/>
</div>
<MalioInputText
v-model="form.tokenId"
:label="$t('bookstack.settings.tokenId')"
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
input-class="w-full"
type="password"
/>
<div>
<MalioInputText

View File

@@ -79,7 +79,16 @@
</span>
</td>
<td class="px-3 py-3 text-neutral-600">{{ getProjectName(ticket.project) }}</td>
<td class="px-3 py-3 text-neutral-600">{{ getSubmitterName(ticket.submittedBy) }}</td>
<td class="px-3 py-3 text-neutral-600">
<div class="flex items-center gap-2">
<UserAvatar
v-if="getSubmitterUser(ticket.submittedBy)"
:user="getSubmitterUser(ticket.submittedBy)!"
size="sm"
/>
{{ getSubmitterName(ticket.submittedBy) }}
</div>
</td>
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
<td class="px-3 py-3">
<div class="flex items-center gap-2">
@@ -216,7 +225,7 @@ const { t } = useI18n()
const clientTicketService = useClientTicketService()
const projectService = useProjectService()
const userService = useUserService()
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
const tickets = ref<ClientTicket[]>([])
const projects = ref<Project[]>([])
@@ -261,19 +270,7 @@ const detailTicket = ref<ClientTicket | null>(null)
const availableStatusTransitions = computed(() => {
if (!statusTarget.value) return []
const current = statusTarget.value.status
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
{ label: t('clientTicket.status.new'), value: 'new' },
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
{ label: t('clientTicket.status.done'), value: 'done' },
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
]
// Filter out forbidden transitions
return allStatuses.filter(s => {
if (s.value === current) return false
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
return true
})
return getAvailableStatusTransitions(statusTarget.value.status, t)
})
function getProjectName(iri: string): string {
@@ -291,6 +288,14 @@ function getSubmitterName(iri: string | null): string {
return users.value.find(u => u.id === id)?.username ?? ''
}
function getSubmitterUser(iri: string | null): UserData | undefined {
if (!iri) return undefined
const match = iri.match(/\/api\/users\/(\d+)/)
if (!match) return undefined
const id = Number(match[1])
return users.value.find(u => u.id === id)
}
function openDetail(ticket: ClientTicket) {
detailTicket.value = ticket
detailOpen.value = true

View File

@@ -211,7 +211,7 @@ const props = defineProps<{
const { t } = useI18n()
const clientTicketService = useClientTicketService()
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
const isOpen = ref(false)
const isLoading = ref(false)
@@ -238,18 +238,7 @@ const isUpdatingStatus = ref(false)
const availableStatusTransitions = computed(() => {
if (!statusTarget.value) return []
const current = statusTarget.value.status
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
{ label: t('clientTicket.status.new'), value: 'new' },
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
{ label: t('clientTicket.status.done'), value: 'done' },
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
]
return allStatuses.filter(s => {
if (s.value === current) return false
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
return true
})
return getAvailableStatusTransitions(statusTarget.value.status, t)
})
async function loadTickets() {

View File

@@ -117,7 +117,7 @@ async function loadItems() {
const [g, t, at] = await Promise.all([
groupService.getByProject(props.projectId),
taskService.getByProject(props.projectId),
taskService.getByProjectArchived(props.projectId),
taskService.getByProject(props.projectId, true),
])
allGroups.value = g
activeTasks.value = t

View File

@@ -44,13 +44,12 @@
>
{{ tag.label }}
</span>
<span
<UserAvatar
v-if="task.assignee"
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
:title="task.assignee.username"
>
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
</span>
:user="task.assignee"
size="xs"
class="ml-auto"
/>
<span
v-else
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"

View File

@@ -28,7 +28,7 @@
<!-- File info -->
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
<p class="text-xs text-neutral-400">{{ formatSize(doc.size) }}</p>
<p class="text-xs text-neutral-400">{{ formatFileSize(doc.size) }}</p>
</div>
<!-- Delete button -->
@@ -47,6 +47,7 @@
<script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
defineProps<{
documents: TaskDocument[]
@@ -72,9 +73,4 @@ function getIconForMime(mimeType: string): string {
return 'heroicons:paper-clip'
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}
</script>

View File

@@ -56,7 +56,7 @@
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p>
<p class="text-sm text-neutral-400">{{ formatSize(document.size) }}</p>
<p class="text-sm text-neutral-400">{{ formatFileSize(document.size) }}</p>
<a
:href="downloadUrl"
download
@@ -77,6 +77,7 @@
<script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
const props = defineProps<{
document: TaskDocument | null
@@ -98,12 +99,6 @@ const downloadUrl = computed(() => props.document ? getDownloadUrl(props.documen
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}
// Focus overlay for keyboard events
watch(() => props.document, (doc) => {
if (doc) {

View File

@@ -154,7 +154,7 @@
/>
<TaskDocumentList
v-if="isEditing && task"
:documents="documents"
:documents="localDocuments"
:is-admin="isAdmin"
@preview="openPreview"
@delete="handleDeleteDocument"
@@ -164,7 +164,7 @@
<TaskDocumentPreview
:document="previewDoc"
:has-prev="previewIndex > 0"
:has-next="previewIndex < documents.length - 1"
:has-next="previewIndex < localDocuments.length - 1"
@close="previewDoc = null"
@prev="prevPreview"
@next="nextPreview"
@@ -396,6 +396,14 @@ watch(() => props.modelValue, async (open) => {
} catch {
clientTickets.value = []
}
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
try {
const settings = await getGiteaSettings()
giteaUrl.value = settings.url ?? ''
} catch {
// Gitea not available
}
}
}
})
@@ -405,17 +413,6 @@ watch(() => props.task, (task) => {
}
})
watch(() => props.modelValue, async (open) => {
if (open && props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
try {
const settings = await getGiteaSettings()
giteaUrl.value = settings.url ?? ''
} catch {
// Gitea not available
}
}
})
const { create, update, remove } = useTaskService()
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
const clientTicketService = useClientTicketService()
@@ -440,7 +437,6 @@ function ticketStatusClass(status: string): string {
}
const localDocuments = ref<TaskDocument[]>([])
const documents = computed(() => localDocuments.value)
const previewDoc = ref<TaskDocument | null>(null)
// Sync documents from task prop when modal opens or task changes
@@ -455,7 +451,7 @@ async function refreshDocuments() {
const previewIndex = computed(() => {
if (!previewDoc.value) return -1
return documents.value.findIndex(d => d.id === previewDoc.value!.id)
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
})
function openPreview(doc: TaskDocument) {
@@ -464,13 +460,13 @@ function openPreview(doc: TaskDocument) {
function prevPreview() {
if (previewIndex.value > 0) {
previewDoc.value = documents.value[previewIndex.value - 1]
previewDoc.value = localDocuments.value[previewIndex.value - 1]
}
}
function nextPreview() {
if (previewIndex.value < documents.value.length - 1) {
previewDoc.value = documents.value[previewIndex.value + 1]
if (previewIndex.value < localDocuments.value.length - 1) {
previewDoc.value = localDocuments.value[previewIndex.value + 1]
}
}

View File

@@ -10,14 +10,16 @@
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<NotificationBell />
<div class="group relative flex gap-2 sm:gap-4">
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
<Icon v-else name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
<div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
<button
type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
@click="navigateTo('/profile')"
>
Mon profil
{{ $t('profile.title') }}
</button>
<button
type="button"
@@ -43,7 +45,7 @@ defineProps<{
const auth = useAuthStore()
const ui = useUiStore()
const handleLogout = async () => {
async function handleLogout() {
await auth.logout()
await navigateTo('/login')
}

View File

@@ -0,0 +1,88 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<h3 class="mb-4 text-lg font-bold text-neutral-900">
{{ $t('profile.cropAvatar') }}
</h3>
<div class="mx-auto mb-4 h-72 w-72">
<Cropper
ref="cropperRef"
:src="imageSrc"
:stencil-component="CircleStencil"
:stencil-props="{ aspectRatio: 1 }"
:canvas="{ width: 256, height: 256 }"
class="h-full w-full"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
@click="emit('cancel')"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
:disabled="cropping"
@click="onConfirm"
>
{{ $t('common.confirm') }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
const props = defineProps<{
imageFile: File
}>()
const emit = defineEmits<{
(e: 'crop', blob: Blob): void
(e: 'cancel'): void
}>()
const cropperRef = ref()
const cropping = ref(false)
const imageSrc = ref('')
onMounted(() => {
imageSrc.value = URL.createObjectURL(props.imageFile)
})
onUnmounted(() => {
if (imageSrc.value) {
URL.revokeObjectURL(imageSrc.value)
}
})
async function onConfirm() {
cropping.value = true
try {
const { canvas } = cropperRef.value.getResult()
if (!canvas) return
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png')
})
if (blob) {
emit('crop', blob)
}
} finally {
cropping.value = false
}
}
</script>

View File

@@ -0,0 +1,55 @@
<template>
<span
class="inline-flex shrink-0 items-center justify-center rounded-full"
:class="sizeClasses"
:title="user.username"
>
<img
v-if="user.avatarUrl && !imgError"
:src="user.avatarUrl"
:alt="user.username"
class="h-full w-full rounded-full object-cover"
@error="imgError = true"
/>
<span
v-else
class="flex h-full w-full items-center justify-center rounded-full bg-primary-500 font-bold text-white"
:class="textSizeClass"
>
{{ user.username.substring(0, 2).toUpperCase() }}
</span>
</span>
</template>
<script setup lang="ts">
const props = defineProps<{
user: { id?: number; username: string; avatarUrl?: string | null }
size?: 'xs' | 'sm' | 'md' | 'lg'
}>()
const imgError = ref(false)
watch(() => props.user.avatarUrl, () => {
imgError.value = false
})
const sizeClasses = computed(() => {
const map = {
xs: 'h-5 w-5',
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-12 w-12',
}
return map[props.size ?? 'sm']
})
const textSizeClass = computed(() => {
const map = {
xs: 'text-[10px]',
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
}
return map[props.size ?? 'sm']
})
</script>

View File

@@ -29,13 +29,14 @@ export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
toastSuccessKey?: string
}
export const useApi = (): ApiClient => {
let isHandlingUnauthorized = false
export function useApi(): ApiClient {
const config = useRuntimeConfig()
const baseURL = config.public.apiBase || '/api'
const toast = useToast()
const auth = useAuthStore()
const nuxtApp = useNuxtApp()
let isHandlingUnauthorized = false
const i18n = nuxtApp.$i18n as
| {
t: (key: string) => string
@@ -45,7 +46,7 @@ export const useApi = (): ApiClient => {
const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key)
const te = (key: string) => (i18n?.te ? i18n.te(key) : false)
const extractErrorMessage = (error: unknown, responseData?: unknown): string => {
function extractErrorMessage(error: unknown, responseData?: unknown): string {
const data = responseData ?? (error as FetchError)?.data
if (typeof data === 'string') {
@@ -169,11 +170,11 @@ export const useApi = (): ApiClient => {
}
})
const request = <T>(
function request<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
url: string,
options: ApiFetchOptions<'json'> = {}
) => {
) {
const needsJsonBody = method === 'POST' || method === 'PUT'
const needsMergePatch = method === 'PATCH'

View File

@@ -1,8 +1,8 @@
export const useAppVersion = () => {
export function useAppVersion() {
const api = useApi()
const version = useState<string | null>('app-version', () => null)
const load = async () => {
async function load(): Promise<string | null> {
if (version.value) {
return version.value
}

View File

@@ -0,0 +1,24 @@
export function useAvatarService() {
const api = useApi()
async function upload(userId: number, file: Blob): Promise<{ avatarUrl: string }> {
const formData = new FormData()
formData.append('file', file, 'avatar.png')
return $fetch(`/api/users/${userId}/avatar`, {
method: 'POST',
body: formData,
credentials: 'include',
})
}
async function remove(userId: number): Promise<void> {
await api.delete(`/users/${userId}/avatar`)
}
function getUrl(userId: number): string {
return `/api/users/${userId}/avatar`
}
return { upload, remove, getUrl }
}

View File

@@ -1,3 +1,5 @@
import type { ClientTicketStatus } from '~/services/dto/client-ticket'
export function useClientTicketHelpers() {
function typeBadgeClass(type: string): string {
switch (type) {
@@ -25,5 +27,22 @@ export function useClientTicketHelpers() {
})
}
return { typeBadgeClass, statusBadgeClass, formatDate }
function getAvailableStatusTransitions(
current: ClientTicketStatus,
t: (key: string) => string,
): { label: string; value: ClientTicketStatus }[] {
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
{ label: t('clientTicket.status.new'), value: 'new' },
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
{ label: t('clientTicket.status.done'), value: 'done' },
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
]
return allStatuses.filter(s => {
if (s.value === current) return false
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
return true
})
}
return { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions }
}

View File

@@ -237,6 +237,7 @@
"new": "Nouveau ticket",
"created": "Ticket créé avec succès.",
"deleted": "Ticket supprimé avec succès.",
"updated": "Ticket mis à jour avec succès.",
"statusUpdated": "Statut du ticket mis à jour.",
"type": {
"bug": "Bug",
@@ -290,6 +291,12 @@
"days": "Il y a {n}j"
}
},
"profile": {
"title": "Mon profil",
"changeAvatar": "Changer l'avatar",
"removeAvatar": "Supprimer l'avatar",
"cropAvatar": "Recadrer l'avatar"
},
"bookstack": {
"settings": {
"title": "Configuration BookStack",

View File

@@ -5,7 +5,3 @@
</main>
</div>
</template>
<script setup lang="ts">
const { version } = useAppVersion()
</script>

View File

@@ -242,11 +242,6 @@ function onCompleteSaved() {
timerStore.clearPendingEntry()
})
}
const handleLogout = async () => {
await auth.logout()
await navigateTo('/login')
}
</script>
<style scoped>

View File

@@ -10,17 +10,16 @@ export default defineNuxtRouteMiddleware(async (to) => {
return navigateTo('/login')
}
const isClientOnly = auth.isAuthenticated
&& auth.user?.roles?.includes('ROLE_CLIENT')
&& !auth.user?.roles?.includes('ROLE_ADMIN')
if (isLogin && auth.isAuthenticated) {
const isClientOnly = auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
return navigateTo(isClientOnly ? '/portal' : '/')
}
// ROLE_CLIENT without ROLE_ADMIN: redirect to /portal, block internal pages
if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')) {
const isPortalRoute = to.path.startsWith('/portal')
const isLoginRoute = to.path === '/login'
if (!isPortalRoute && !isLoginRoute) {
return navigateTo('/portal')
}
const isProfileRoute = to.path === '/profile'
if (isClientOnly && !to.path.startsWith('/portal') && !isProfileRoute) {
return navigateTo('/portal')
}
})

View File

@@ -18,6 +18,7 @@
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-advanced-cropper": "^2.8.9",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4"
}
@@ -6789,6 +6790,12 @@
"consola": "^3.2.3"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/clipboardy": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz",
@@ -7301,6 +7308,12 @@
}
}
},
"node_modules/debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -7578,6 +7591,12 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/easy-bem": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz",
"integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==",
"license": "MIT"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -13869,6 +13888,24 @@
}
}
},
"node_modules/vue-advanced-cropper": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz",
"integrity": "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==",
"license": "MIT",
"dependencies": {
"classnames": "^2.2.6",
"debounce": "^1.2.0",
"easy-bem": "^1.0.2"
},
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-bundle-renderer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/vue-bundle-renderer/-/vue-bundle-renderer-2.2.0.tgz",

View File

@@ -22,6 +22,7 @@
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-advanced-cropper": "^2.8.9",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4"
}

View File

@@ -48,7 +48,6 @@ useHead({
title: 'Connexion'
})
const router = useRouter()
const auth = useAuthStore()
const {version} = useAppVersion()
@@ -56,7 +55,7 @@ const username = ref('')
const password = ref('')
const isSubmitting = ref(false)
const handleSubmit = async () => {
async function handleSubmit() {
if (isSubmitting.value) return
isSubmitting.value = true
@@ -64,7 +63,7 @@ const handleSubmit = async () => {
await auth.login(username.value, password.value)
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
await router.push(isClient ? '/portal' : '/')
await navigateTo(isClient ? '/portal' : '/')
} finally {
isSubmitting.value = false
}

View File

@@ -0,0 +1,91 @@
<template>
<div class="mx-auto max-w-lg px-4 py-10">
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
<!-- Current avatar -->
<UserAvatar
v-if="auth.user"
:user="auth.user"
size="lg"
/>
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
<div class="flex gap-3">
<label
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
>
{{ $t('profile.changeAvatar') }}
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="hidden"
@change="onFileSelect"
/>
</label>
<button
v-if="auth.user?.avatarUrl"
type="button"
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
:disabled="removing"
@click="onRemove"
>
{{ $t('profile.removeAvatar') }}
</button>
</div>
</div>
<!-- Crop modal -->
<AvatarCropper
v-if="selectedFile"
:image-file="selectedFile"
@crop="onCrop"
@cancel="selectedFile = null"
/>
</div>
</template>
<script setup lang="ts">
import { useAvatarService } from '~/composables/useAvatarService'
const auth = useAuthStore()
const { upload, remove } = useAvatarService()
const selectedFile = ref<File | null>(null)
const removing = ref(false)
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
selectedFile.value = file
}
input.value = ''
}
async function onCrop(blob: Blob) {
selectedFile.value = null
if (!auth.user) return
try {
await upload(auth.user.id, blob)
await auth.refreshUser()
} catch {
// Upload error — $fetch will throw on non-2xx
}
}
async function onRemove() {
if (!auth.user) return
removing.value = true
try {
await remove(auth.user.id)
await auth.refreshUser()
} finally {
removing.value = false
}
}
</script>

View File

@@ -46,13 +46,11 @@
>
{{ task.group.title }}
</span>
<span
<UserAvatar
v-if="task.assignee"
class="flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
:title="task.assignee.username"
>
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
</span>
:user="task.assignee"
size="xs"
/>
</div>
</div>
</div>
@@ -130,7 +128,7 @@ const filteredTasks = computed(() => {
async function loadData() {
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
projectService.getById(projectId.value),
taskService.getByProjectArchived(projectId.value),
taskService.getByProject(projectId.value, true),
statusService.getAll(),
effortService.getAll(),
priorityService.getAll(),

View File

@@ -284,24 +284,10 @@ async function onPaste() {
await loadEntries()
}
onMounted(() => {
updatePageHeaderHeight()
if (!pageHeaderEl.value || typeof ResizeObserver === 'undefined') {
return
}
pageHeaderResizeObserver = new ResizeObserver(() => {
updatePageHeaderHeight()
})
pageHeaderResizeObserver.observe(pageHeaderEl.value)
})
onBeforeUnmount(() => {
pageHeaderResizeObserver?.disconnect()
})
async function onDelete(entry: TimeEntry) {
await timeEntryService.remove(entry.id)
await loadEntries()
@@ -333,6 +319,15 @@ async function loadReferenceData() {
}
onMounted(async () => {
updatePageHeaderHeight()
if (pageHeaderEl.value && typeof ResizeObserver !== 'undefined') {
pageHeaderResizeObserver = new ResizeObserver(() => {
updatePageHeaderHeight()
})
pageHeaderResizeObserver.observe(pageHeaderEl.value)
}
await loadReferenceData()
await loadEntries()
})

View File

@@ -1,22 +1,22 @@
import type { UserData } from './dto/user-data'
export const getCurrentUser = () => {
const api = useApi()
return api.get<UserData>('/me', {}, { toastErrorKey: 'errors.auth.session' })
export function getCurrentUser() {
const api = useApi()
return api.get<UserData>('/me', {}, { toastErrorKey: 'errors.auth.session' })
}
export const login = (username: string, password: string) => {
const api = useApi()
return api.post('/login_check', { username, password }, {
toastOn401: true,
toastErrorKey: 'errors.auth.login'
})
export function login(username: string, password: string) {
const api = useApi()
return api.post('/login_check', { username, password }, {
toastOn401: true,
toastErrorKey: 'errors.auth.login'
})
}
export const logout = () => {
const api = useApi()
return api.post('/logout', {}, {
toastErrorKey: 'errors.auth.logout',
toastSuccessKey: 'success.auth.logout'
})
export function logout() {
const api = useApi()
return api.post('/logout', {}, {
toastErrorKey: 'errors.auth.logout',
toastSuccessKey: 'success.auth.logout'
})
}

View File

@@ -32,7 +32,7 @@ export function useClientTicketService() {
async function update(id: number, data: Partial<ClientTicketWrite>): Promise<ClientTicket> {
return api.patch<ClientTicket>(`/client_tickets/${id}`, data as Record<string, unknown>, {
toastSuccessKey: 'clientTicket.statusUpdated',
toastSuccessKey: 'clientTicket.updated',
})
}

View File

@@ -7,6 +7,7 @@ export type UserData = {
roles: string[]
client?: { id: number; name: string } | null
allowedProjects?: Project[]
avatarUrl?: string | null
}
export type UserWrite = {

View File

@@ -15,30 +15,24 @@ export function useTaskDocumentService() {
return extractHydraMembers(data)
}
async function upload(taskId: number, file: File): Promise<TaskDocument> {
async function uploadWithRelation(relationField: string, relationIri: string, file: File): Promise<TaskDocument> {
const formData = new FormData()
formData.append('file', file)
formData.append('task', `/api/tasks/${taskId}`)
formData.append(relationField, relationIri)
return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
return $fetch<TaskDocument>(`${baseURL}/task_documents`, {
method: 'POST',
body: formData,
credentials: 'include',
// Do NOT set Content-Type — browser sets multipart boundary automatically
})
}
async function uploadForTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
const formData = new FormData()
formData.append('file', file)
formData.append('clientTicket', `/api/client_tickets/${clientTicketId}`)
async function upload(taskId: number, file: File): Promise<TaskDocument> {
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
}
return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
method: 'POST',
body: formData,
credentials: 'include',
// Do NOT set Content-Type — browser sets multipart boundary automatically
})
async function uploadForTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
return uploadWithRelation('clientTicket', `/api/client_tickets/${clientTicketId}`, file)
}
async function getByTicket(clientTicketId: number): Promise<TaskDocument[]> {

View File

@@ -10,18 +10,10 @@ export function useTaskService() {
return extractHydraMembers(data)
}
async function getByProject(projectId: number): Promise<Task[]> {
async function getByProject(projectId: number, archived = false): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks', {
project: `/api/projects/${projectId}`,
archived: false,
})
return extractHydraMembers(data)
}
async function getByProjectArchived(projectId: number): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks', {
project: `/api/projects/${projectId}`,
archived: true,
archived,
})
return extractHydraMembers(data)
}
@@ -49,5 +41,5 @@ export function useTaskService() {
})
}
return { getAll, getByProject, getByProjectArchived, getFiltered, create, update, remove }
return { getAll, getByProject, getFiltered, create, update, remove }
}

View File

@@ -58,6 +58,14 @@ export const useAuthStore = defineStore('auth', {
this.checked = true
this.isLoading = false
}
},
async refreshUser() {
try {
const me = await getCurrentUser()
this.user = me
} catch {
// Silently fail — user session might have expired
}
}
}
})

View File

@@ -66,6 +66,11 @@ export const useTimerStore = defineStore('timer', () => {
startTicking()
}
function toIri<T extends { '@id'?: string; id: number }>(entity: T | string, prefix: string): string {
if (typeof entity === 'string') return entity
return entity['@id'] ?? `${prefix}/${entity.id}`
}
async function startFromTask(task: Task) {
const authStore = useAuthStore()
if (!authStore.user) return
@@ -79,11 +84,9 @@ export const useTimerStore = defineStore('timer', () => {
startedAt: new Date().toISOString(),
user: `/api/users/${authStore.user.id}`,
title: task.title,
project: task.project
? (typeof task.project === 'string' ? task.project : (task.project['@id'] ?? (task.project.id ? `/api/projects/${task.project.id}` : null)))
: null,
task: typeof task === 'string' ? task : (task['@id'] ?? `/api/tasks/${task.id}`),
tags: task.tags?.map((t) => typeof t === 'string' ? t : (t['@id'] ?? `/api/task_tags/${t.id}`)) ?? [],
project: task.project ? toIri(task.project, '/api/projects') : null,
task: toIri(task, '/api/tasks'),
tags: task.tags?.map(t => toIri(t, '/api/task_tags')) ?? [],
})
startTicking()
}

5
frontend/utils/format.ts Normal file
View File

@@ -0,0 +1,5 @@
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260315205331 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ADD avatar_file_name VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" DROP avatar_file_name');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260315210619 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE UNIQUE INDEX uniq_task_project_number ON task (project_id, number)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP INDEX uniq_task_project_number');
}
}

View File

@@ -7,8 +7,10 @@ namespace App\Controller;
use App\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -17,11 +19,12 @@ class TaskDocumentDownloadController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly string $uploadDir,
) {}
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(int $id): BinaryFileResponse
{
$document = $this->entityManager->getRepository(TaskDocument::class)->find($id);
@@ -30,6 +33,14 @@ class TaskDocumentDownloadController extends AbstractController
throw new NotFoundHttpException('Document not found.');
}
// ROLE_CLIENT can only download documents from their own tickets
if (!$this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_USER')) {
$ticket = $document->getClientTicket();
if (null === $ticket || $ticket->getSubmittedBy() !== $this->security->getUser()) {
throw new AccessDeniedHttpException('You do not have access to this document.');
}
}
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (!file_exists($filePath)) {

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
class UserAvatarController extends AbstractController
{
private const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly string $avatarUploadDir,
) {}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_upload', methods: ['POST'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function upload(int $id, Request $request): JsonResponse
{
$user = $this->findUserOrFail($id);
$this->assertCanManageAvatar($user);
$file = $request->files->get('file');
if (null === $file || !$file->isValid()) {
throw new BadRequestHttpException('No valid file uploaded.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('File size exceeds 5 MB limit.');
}
$mimeType = $file->getMimeType();
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new BadRequestHttpException('Invalid file type. Allowed: JPEG, PNG, WebP, GIF.');
}
// Delete previous avatar file if exists
$this->deleteAvatarFile($user);
$extension = $file->guessExtension() ?? 'bin';
$fileName = Uuid::v4()->toRfc4122().'.'.$extension;
if (!is_dir($this->avatarUploadDir)) {
mkdir($this->avatarUploadDir, 0o775, true);
}
$file->move($this->avatarUploadDir, $fileName);
$user->setAvatarFileName($fileName);
$this->entityManager->flush();
return new JsonResponse(['avatarUrl' => $user->getAvatarUrl()]);
}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_serve', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function serve(int $id): BinaryFileResponse
{
$user = $this->findUserOrFail($id);
if (null === $user->getAvatarFileName()) {
throw new NotFoundHttpException('No avatar set.');
}
$filePath = $this->avatarUploadDir.'/'.$user->getAvatarFileName();
if (!file_exists($filePath)) {
throw new NotFoundHttpException('Avatar file not found on disk.');
}
$response = new BinaryFileResponse($filePath);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $user->getAvatarFileName());
$extension = pathinfo($user->getAvatarFileName(), PATHINFO_EXTENSION);
$mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'];
$response->headers->set('Content-Type', $mimeMap[$extension] ?? 'application/octet-stream');
$response->headers->set('Cache-Control', 'no-cache, must-revalidate');
return $response;
}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_delete', methods: ['DELETE'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function delete(int $id): Response
{
$user = $this->findUserOrFail($id);
$this->assertCanManageAvatar($user);
$this->deleteAvatarFile($user);
$user->setAvatarFileName(null);
$this->entityManager->flush();
return new Response(null, Response::HTTP_NO_CONTENT);
}
private function findUserOrFail(int $id): User
{
$user = $this->entityManager->getRepository(User::class)->find($id);
if (null === $user) {
throw new NotFoundHttpException('User not found.');
}
return $user;
}
private function assertCanManageAvatar(User $user): void
{
$currentUser = $this->getUser();
if ($currentUser !== $user && !$this->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('You can only manage your own avatar.');
}
}
private function deleteAvatarFile(User $user): void
{
if (null === $user->getAvatarFileName()) {
return;
}
$filePath = $this->avatarUploadDir.'/'.$user->getAvatarFileName();
if (file_exists($filePath)) {
unlink($filePath);
}
}
}

View File

@@ -35,6 +35,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
#[ORM\Entity(repositoryClass: TaskRepository::class)]
#[ORM\Table(name: 'task')]
#[ORM\UniqueConstraint(name: 'uniq_task_project_number', columns: ['project_id', 'number'])]
class Task
{
#[ORM\Id]

View File

@@ -70,6 +70,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 64, unique: true, nullable: true)]
private ?string $apiToken = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $avatarFileName = null;
#[ORM\ManyToOne(targetEntity: Client::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:list', 'user:write'])]
@@ -199,5 +202,27 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getAvatarFileName(): ?string
{
return $this->avatarFileName;
}
public function setAvatarFileName(?string $avatarFileName): static
{
$this->avatarFileName = $avatarFileName;
return $this;
}
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])]
public function getAvatarUrl(): ?string
{
if (null === $this->avatarFileName) {
return null;
}
return '/api/users/'.$this->id.'/avatar';
}
public function eraseCredentials(): void {}
}

View File

@@ -5,12 +5,5 @@ declare(strict_types=1);
namespace App\Exception;
use RuntimeException;
use Throwable;
final class BookStackApiException extends RuntimeException
{
public function __construct(string $message, int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
final class BookStackApiException extends RuntimeException {}

View File

@@ -5,12 +5,5 @@ declare(strict_types=1);
namespace App\Exception;
use RuntimeException;
use Throwable;
final class GiteaApiException extends RuntimeException
{
public function __construct(string $message, int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
final class GiteaApiException extends RuntimeException {}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Entity\Project;
use App\Mcp\Tool\Serializer;
use App\Repository\ClientRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -48,17 +49,6 @@ class CreateProjectTool
$this->entityManager->persist($project);
$this->entityManager->flush();
return json_encode([
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
'description' => $project->getDescription(),
'color' => $project->getColor(),
'client' => $project->getClient() ? [
'id' => $project->getClient()->getId(),
'name' => $project->getClient()->getName(),
] : null,
'archived' => $project->isArchived(),
]);
return json_encode(Serializer::project($project));
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use InvalidArgumentException;
@@ -45,17 +46,7 @@ class GetProjectTool
$totalTasks += $count;
}
return json_encode([
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
'description' => $project->getDescription(),
'color' => $project->getColor(),
'client' => $project->getClient() ? [
'id' => $project->getClient()->getId(),
'name' => $project->getClient()->getName(),
] : null,
'archived' => $project->isArchived(),
return json_encode(Serializer::project($project) + [
'taskSummary' => $statusCounts,
'totalTasks' => $totalTasks,
]);

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
use Mcp\Capability\Attribute\McpTool;
@@ -18,17 +19,6 @@ class ListProjectsTool
{
$projects = $this->projectRepository->findBy(['archived' => $archived], ['name' => 'ASC']);
return json_encode(array_map(fn ($project) => [
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
'description' => $project->getDescription(),
'color' => $project->getColor(),
'client' => $project->getClient() ? [
'id' => $project->getClient()->getId(),
'name' => $project->getClient()->getName(),
] : null,
'archived' => $project->isArchived(),
], $projects));
return json_encode(array_map(Serializer::project(...), $projects));
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Repository\ClientRepository;
use App\Repository\ProjectRepository;
use Doctrine\ORM\EntityManagerInterface;
@@ -61,17 +62,6 @@ class UpdateProjectTool
$this->entityManager->flush();
return json_encode([
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
'description' => $project->getDescription(),
'color' => $project->getColor(),
'client' => $project->getClient() ? [
'id' => $project->getClient()->getId(),
'name' => $project->getClient()->getName(),
] : null,
'archived' => $project->isArchived(),
]);
return json_encode(Serializer::project($project));
}
}

277
src/Mcp/Tool/Serializer.php Normal file
View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool;
use App\Entity\Project;
use App\Entity\Task;
use App\Entity\TaskDocument;
use App\Entity\TaskEffort;
use App\Entity\TaskGroup;
use App\Entity\TaskPriority;
use App\Entity\TaskStatus;
use App\Entity\TaskTag;
use App\Entity\TimeEntry;
use App\Entity\User;
use Doctrine\Common\Collections\Collection;
/**
* Shared serialization helpers for MCP tools.
*
* Keeps JSON output consistent across all tools.
*/
final class Serializer
{
/**
* @return array{id: ?int, code: ?string, name: ?string}
*/
public static function projectRef(Project $project): array
{
return [
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
];
}
/**
* @return array<string, mixed>
*/
public static function project(Project $project): array
{
return [
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
'description' => $project->getDescription(),
'color' => $project->getColor(),
'client' => $project->getClient() ? [
'id' => $project->getClient()->getId(),
'name' => $project->getClient()->getName(),
] : null,
'archived' => $project->isArchived(),
];
}
/**
* @return null|array{id: ?int, label: ?string, color: ?string}
*/
public static function status(?TaskStatus $status): ?array
{
if (null === $status) {
return null;
}
return [
'id' => $status->getId(),
'label' => $status->getLabel(),
'color' => $status->getColor(),
];
}
/**
* @return null|array{id: ?int, label: ?string, color: ?string, isFinal: bool}
*/
public static function statusFull(?TaskStatus $status): ?array
{
if (null === $status) {
return null;
}
return [
'id' => $status->getId(),
'label' => $status->getLabel(),
'color' => $status->getColor(),
'isFinal' => $status->getIsFinal(),
];
}
/**
* @return null|array{id: ?int, label: ?string, color: ?string}
*/
public static function priority(?TaskPriority $priority): ?array
{
if (null === $priority) {
return null;
}
return [
'id' => $priority->getId(),
'label' => $priority->getLabel(),
'color' => $priority->getColor(),
];
}
/**
* @return null|array{id: ?int, label: ?string}
*/
public static function effort(?TaskEffort $effort): ?array
{
if (null === $effort) {
return null;
}
return [
'id' => $effort->getId(),
'label' => $effort->getLabel(),
];
}
/**
* @return null|array{id: ?int, username: ?string}
*/
public static function user(?User $user): ?array
{
if (null === $user) {
return null;
}
return [
'id' => $user->getId(),
'username' => $user->getUsername(),
];
}
/**
* @return null|array{id: ?int, title: ?string, color: ?string}
*/
public static function group(?TaskGroup $group): ?array
{
if (null === $group) {
return null;
}
return [
'id' => $group->getId(),
'title' => $group->getTitle(),
'color' => $group->getColor(),
];
}
/**
* @return null|array{id: ?int, title: ?string}
*/
public static function groupRef(?TaskGroup $group): ?array
{
if (null === $group) {
return null;
}
return [
'id' => $group->getId(),
'title' => $group->getTitle(),
];
}
/**
* Full group serialization for MCP group tools (includes description, project, archived).
*
* @return array<string, mixed>
*/
public static function groupFull(TaskGroup $group): array
{
return [
'id' => $group->getId(),
'title' => $group->getTitle(),
'description' => $group->getDescription(),
'color' => $group->getColor(),
'project' => self::projectRef($group->getProject()),
'archived' => $group->isArchived(),
];
}
/**
* @param Collection<int, TaskTag> $tags
*
* @return list<array{id: ?int, label: ?string}>
*/
public static function tags(Collection $tags): array
{
return $tags->map(fn (TaskTag $t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray();
}
/**
* @param Collection<int, TaskTag> $tags
*
* @return list<array{id: ?int, label: ?string, color: ?string}>
*/
public static function tagsWithColor(Collection $tags): array
{
return $tags->map(fn (TaskTag $t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
'color' => $t->getColor(),
])->toArray();
}
/**
* Compute duration in minutes between two timestamps, or null if still active.
*/
public static function durationMinutes(TimeEntry $entry): ?int
{
$started = $entry->getStartedAt();
$stopped = $entry->getStoppedAt();
if (null === $stopped || null === $started) {
return null;
}
return (int) round(($stopped->getTimestamp() - $started->getTimestamp()) / 60);
}
/**
* @return null|array{id: ?int, number: ?int, title: ?string}
*/
public static function taskRef(?Task $task): ?array
{
if (null === $task) {
return null;
}
return [
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
];
}
/**
* @return array<string, mixed>
*/
public static function timeEntry(TimeEntry $entry): array
{
return [
'id' => $entry->getId(),
'title' => $entry->getTitle(),
'description' => $entry->getDescription(),
'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => self::durationMinutes($entry),
'user' => self::user($entry->getUser()),
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
'task' => self::taskRef($entry->getTask()),
'tags' => self::tags($entry->getTags()),
];
}
/**
* @param Collection<int, TaskDocument> $documents
*
* @return list<array<string, mixed>>
*/
public static function documents(Collection $documents): array
{
return $documents->map(fn (TaskDocument $doc) => [
'id' => $doc->getId(),
'originalName' => $doc->getOriginalName(),
'mimeType' => $doc->getMimeType(),
'size' => $doc->getSize(),
'createdAt' => $doc->getCreatedAt()?->format('c'),
'uploadedBy' => self::user($doc->getUploadedBy()),
])->toArray();
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\Task;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
use App\Repository\TaskEffortRepository;
use App\Repository\TaskGroupRepository;
@@ -111,38 +112,14 @@ class CreateTaskTool
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => $task->getStatus() ? [
'id' => $task->getStatus()->getId(),
'label' => $task->getStatus()->getLabel(),
'color' => $task->getStatus()->getColor(),
] : null,
'priority' => $task->getPriority() ? [
'id' => $task->getPriority()->getId(),
'label' => $task->getPriority()->getLabel(),
'color' => $task->getPriority()->getColor(),
] : null,
'effort' => $task->getEffort() ? [
'id' => $task->getEffort()->getId(),
'label' => $task->getEffort()->getLabel(),
] : null,
'assignee' => $task->getAssignee() ? [
'id' => $task->getAssignee()->getId(),
'username' => $task->getAssignee()->getUsername(),
] : null,
'group' => $task->getGroup() ? [
'id' => $task->getGroup()->getId(),
'title' => $task->getGroup()->getTitle(),
] : null,
'project' => [
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
],
'tags' => $task->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
'archived' => $task->isArchived(),
'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($project),
'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
]);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer;
use App\Repository\TaskRepository;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -30,52 +31,15 @@ class GetTaskTool
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => $task->getStatus() ? [
'id' => $task->getStatus()->getId(),
'label' => $task->getStatus()->getLabel(),
'color' => $task->getStatus()->getColor(),
'isFinal' => $task->getStatus()->getIsFinal(),
] : null,
'priority' => $task->getPriority() ? [
'id' => $task->getPriority()->getId(),
'label' => $task->getPriority()->getLabel(),
'color' => $task->getPriority()->getColor(),
] : null,
'effort' => $task->getEffort() ? [
'id' => $task->getEffort()->getId(),
'label' => $task->getEffort()->getLabel(),
] : null,
'assignee' => $task->getAssignee() ? [
'id' => $task->getAssignee()->getId(),
'username' => $task->getAssignee()->getUsername(),
] : null,
'group' => $task->getGroup() ? [
'id' => $task->getGroup()->getId(),
'title' => $task->getGroup()->getTitle(),
'color' => $task->getGroup()->getColor(),
] : null,
'project' => [
'id' => $task->getProject()->getId(),
'code' => $task->getProject()->getCode(),
'name' => $task->getProject()->getName(),
],
'tags' => $task->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
'color' => $t->getColor(),
])->toArray(),
'documents' => $task->getDocuments()->map(fn ($doc) => [
'id' => $doc->getId(),
'originalName' => $doc->getOriginalName(),
'mimeType' => $doc->getMimeType(),
'size' => $doc->getSize(),
'createdAt' => $doc->getCreatedAt()?->format('c'),
'uploadedBy' => $doc->getUploadedBy() ? [
'id' => $doc->getUploadedBy()->getId(),
'username' => $doc->getUploadedBy()->getUsername(),
] : null,
])->toArray(),
'archived' => $task->isArchived(),
'status' => Serializer::statusFull($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'group' => Serializer::group($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tagsWithColor($task->getTags()),
'documents' => Serializer::documents($task->getDocuments()),
'archived' => $task->isArchived(),
]);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer;
use App\Repository\TaskRepository;
use Mcp\Capability\Attribute\McpTool;
@@ -67,40 +68,16 @@ class ListTasksTool
}
return json_encode(array_map(fn ($task) => [
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'status' => $task->getStatus() ? [
'id' => $task->getStatus()->getId(),
'label' => $task->getStatus()->getLabel(),
'color' => $task->getStatus()->getColor(),
] : null,
'priority' => $task->getPriority() ? [
'id' => $task->getPriority()->getId(),
'label' => $task->getPriority()->getLabel(),
'color' => $task->getPriority()->getColor(),
] : null,
'assignee' => $task->getAssignee() ? [
'id' => $task->getAssignee()->getId(),
'username' => $task->getAssignee()->getUsername(),
] : null,
'effort' => $task->getEffort() ? [
'id' => $task->getEffort()->getId(),
'label' => $task->getEffort()->getLabel(),
] : null,
'group' => $task->getGroup() ? [
'id' => $task->getGroup()->getId(),
'title' => $task->getGroup()->getTitle(),
] : null,
'project' => [
'id' => $task->getProject()->getId(),
'code' => $task->getProject()->getCode(),
'name' => $task->getProject()->getName(),
],
'tags' => $task->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'assignee' => Serializer::user($task->getAssignee()),
'effort' => Serializer::effort($task->getEffort()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
], array_values($tasks)));
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer;
use App\Repository\TaskEffortRepository;
use App\Repository\TaskGroupRepository;
use App\Repository\TaskPriorityRepository;
@@ -114,38 +115,14 @@ class UpdateTaskTool
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => $task->getStatus() ? [
'id' => $task->getStatus()->getId(),
'label' => $task->getStatus()->getLabel(),
'color' => $task->getStatus()->getColor(),
] : null,
'priority' => $task->getPriority() ? [
'id' => $task->getPriority()->getId(),
'label' => $task->getPriority()->getLabel(),
'color' => $task->getPriority()->getColor(),
] : null,
'effort' => $task->getEffort() ? [
'id' => $task->getEffort()->getId(),
'label' => $task->getEffort()->getLabel(),
] : null,
'assignee' => $task->getAssignee() ? [
'id' => $task->getAssignee()->getId(),
'username' => $task->getAssignee()->getUsername(),
] : null,
'group' => $task->getGroup() ? [
'id' => $task->getGroup()->getId(),
'title' => $task->getGroup()->getTitle(),
] : null,
'project' => [
'id' => $task->getProject()->getId(),
'code' => $task->getProject()->getCode(),
'name' => $task->getProject()->getName(),
],
'tags' => $task->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
'archived' => $task->isArchived(),
'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
]);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskGroup;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -45,17 +46,6 @@ class CreateGroupTool
$this->entityManager->persist($group);
$this->entityManager->flush();
return json_encode([
'id' => $group->getId(),
'title' => $group->getTitle(),
'description' => $group->getDescription(),
'color' => $group->getColor(),
'project' => [
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
],
'archived' => $group->isArchived(),
]);
return json_encode(Serializer::groupFull($group));
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Mcp\Tool\Serializer;
use App\Repository\TaskGroupRepository;
use Mcp\Capability\Attribute\McpTool;
@@ -23,17 +24,6 @@ class ListGroupsTool
$groups = $this->taskGroupRepository->findBy($criteria, ['title' => 'ASC']);
return json_encode(array_map(fn ($g) => [
'id' => $g->getId(),
'title' => $g->getTitle(),
'description' => $g->getDescription(),
'color' => $g->getColor(),
'project' => [
'id' => $g->getProject()->getId(),
'code' => $g->getProject()->getCode(),
'name' => $g->getProject()->getName(),
],
'archived' => $g->isArchived(),
], $groups));
return json_encode(array_map(Serializer::groupFull(...), $groups));
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Mcp\Tool\Serializer;
use App\Repository\TaskGroupRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -47,17 +48,6 @@ class UpdateGroupTool
$this->entityManager->flush();
return json_encode([
'id' => $group->getId(),
'title' => $group->getTitle(),
'description' => $group->getDescription(),
'color' => $group->getColor(),
'project' => [
'id' => $group->getProject()->getId(),
'code' => $group->getProject()->getCode(),
'name' => $group->getProject()->getName(),
],
'archived' => $group->isArchived(),
]);
return json_encode(Serializer::groupFull($group));
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Entity\TimeEntry;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
@@ -92,30 +93,6 @@ class CreateTimeEntryTool
$this->entityManager->persist($entry);
$this->entityManager->flush();
return json_encode([
'id' => $entry->getId(),
'title' => $entry->getTitle(),
'description' => $entry->getDescription(),
'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => $entry->getStoppedAt() && $entry->getStartedAt()
? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60)
: null,
'user' => ['id' => $user->getId(), 'username' => $user->getUsername()],
'project' => $entry->getProject() ? [
'id' => $entry->getProject()->getId(),
'code' => $entry->getProject()->getCode(),
'name' => $entry->getProject()->getName(),
] : null,
'task' => $entry->getTask() ? [
'id' => $entry->getTask()->getId(),
'number' => $entry->getTask()->getNumber(),
'title' => $entry->getTask()->getTitle(),
] : null,
'tags' => $entry->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
]);
return json_encode(Serializer::timeEntry($entry));
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Mcp\Tool\Serializer;
use App\Repository\TimeEntryRepository;
use DateTimeImmutable;
use Mcp\Capability\Attribute\McpTool;
@@ -56,33 +57,6 @@ class ListTimeEntriesTool
$entries = $qb->getQuery()->getResult();
return json_encode(array_map(fn ($entry) => [
'id' => $entry->getId(),
'title' => $entry->getTitle(),
'description' => $entry->getDescription(),
'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => $entry->getStoppedAt() && $entry->getStartedAt()
? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60)
: null,
'user' => [
'id' => $entry->getUser()->getId(),
'username' => $entry->getUser()->getUsername(),
],
'project' => $entry->getProject() ? [
'id' => $entry->getProject()->getId(),
'code' => $entry->getProject()->getCode(),
'name' => $entry->getProject()->getName(),
] : null,
'task' => $entry->getTask() ? [
'id' => $entry->getTask()->getId(),
'number' => $entry->getTask()->getNumber(),
'title' => $entry->getTask()->getTitle(),
] : null,
'tags' => $entry->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
], $entries));
return json_encode(array_map(Serializer::timeEntry(...), $entries));
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
@@ -83,29 +84,6 @@ class UpdateTimeEntryTool
$this->entityManager->flush();
return json_encode([
'id' => $entry->getId(),
'title' => $entry->getTitle(),
'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => $entry->getStoppedAt() && $entry->getStartedAt()
? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60)
: null,
'user' => ['id' => $entry->getUser()->getId(), 'username' => $entry->getUser()->getUsername()],
'project' => $entry->getProject() ? [
'id' => $entry->getProject()->getId(),
'code' => $entry->getProject()->getCode(),
'name' => $entry->getProject()->getName(),
] : null,
'task' => $entry->getTask() ? [
'id' => $entry->getTask()->getId(),
'number' => $entry->getTask()->getNumber(),
'title' => $entry->getTask()->getTitle(),
] : null,
'tags' => $entry->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
]);
return json_encode(Serializer::timeEntry($entry));
}
}

View File

@@ -19,15 +19,18 @@ class ClientTicketRepository extends ServiceEntityRepository
parent::__construct($registry, ClientTicket::class);
}
public function findNextNumberForProject(Project $project): int
/**
* Returns the next ticket number for a project, using a row-level lock
* to prevent race conditions when creating tickets concurrently.
*/
public function findNextNumberForProjectForUpdate(Project $project): int
{
$result = $this->createQueryBuilder('ct')
->select('MAX(ct.number)')
->where('ct.project = :project')
->setParameter('project', $project)
->getQuery()
->getSingleScalarResult()
;
$conn = $this->getEntityManager()->getConnection();
$result = $conn->fetchOne(
'SELECT COALESCE(MAX(number), 0) FROM client_ticket WHERE project_id = :project FOR UPDATE',
['project' => $project->getId()],
);
return ((int) $result) + 1;
}

View File

@@ -9,6 +9,9 @@ use App\Entity\Task;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Task>
*/
class TaskRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
@@ -16,16 +19,19 @@ class TaskRepository extends ServiceEntityRepository
parent::__construct($registry, Task::class);
}
public function findMaxNumberByProject(Project $project): int
/**
* Returns the max task number for a project, using a row-level lock
* to prevent race conditions when creating tasks concurrently.
*/
public function findMaxNumberByProjectForUpdate(Project $project): int
{
$result = $this->createQueryBuilder('t')
->select('MAX(t.number)')
->where('t.project = :project')
->setParameter('project', $project)
->getQuery()
->getSingleScalarResult()
;
$conn = $this->getEntityManager()->getConnection();
return (int) ($result ?? 0);
$result = $conn->fetchOne(
'SELECT COALESCE(MAX(number), 0) FROM task WHERE project_id = :project FOR UPDATE',
['project' => $project->getId()],
);
return (int) $result;
}
}

View File

@@ -59,7 +59,7 @@ final class BookStackApiService
* Search for pages and books within a specific shelf.
*
* Algorithm:
* 1. Fetch the shelf's book IDs
* 1. Fetch the shelf data (book IDs + slugs)
* 2. Run two search queries (one for pages, one for books)
* 3. Filter results: pages must belong to a book on the shelf, books must be on the shelf
*
@@ -67,17 +67,27 @@ final class BookStackApiService
*/
public function searchInShelf(int $shelfId, string $query): array
{
$bookIds = $this->getShelfBookIds($shelfId);
$shelfData = $this->request('GET', sprintf('/api/shelves/%d', $shelfId));
$books = $shelfData['books'] ?? [];
if (empty($bookIds)) {
if (empty($books)) {
return [];
}
$bookIds = array_map(static fn (array $book): int => $book['id'], $books);
$bookSlugs = [];
foreach ($books as $book) {
$bookSlugs[$book['id']] = $book['slug'] ?? '';
}
// Update cache for getShelfBookIds
$this->shelfBookCache[$shelfId] = $bookIds;
$config = $this->getConfiguration();
$baseUrl = rtrim($config->getUrl() ?? '', '/');
$trimmed = trim($query);
// BookStack search API accepts {type:X} for one type at a time run two queries
// BookStack search API accepts {type:X} for one type at a time -- run two queries
$pageResults = $this->request('GET', '/api/search', [
'query' => ['query' => $trimmed.' {type:page}', 'count' => 50],
]);
@@ -87,13 +97,6 @@ final class BookStackApiService
$allResults = array_merge($pageResults['data'] ?? [], $bookResults['data'] ?? []);
// Build a map of bookId → bookSlug for URL construction
$shelfData = $this->request('GET', sprintf('/api/shelves/%d', $shelfId));
$bookSlugs = [];
foreach ($shelfData['books'] ?? [] as $book) {
$bookSlugs[$book['id']] = $book['slug'] ?? '';
}
$filtered = [];
foreach ($allResults as $item) {
$type = $item['type'] ?? '';
@@ -101,23 +104,20 @@ final class BookStackApiService
if ('page' === $type) {
$bookId = $item['book_id'] ?? 0;
if (in_array($bookId, $bookIds, true)) {
$bookSlug = $bookSlugs[$bookId] ?? '';
$filtered[] = [
'id' => $item['id'],
'type' => 'page',
'name' => $item['name'] ?? '',
'url' => $baseUrl.'/books/'.$bookSlug.'/page/'.$item['slug'],
];
}
} elseif ('book' === $type) {
if (in_array($item['id'], $bookIds, true)) {
$filtered[] = [
'id' => $item['id'],
'type' => 'book',
'name' => $item['name'] ?? '',
'url' => $baseUrl.'/books/'.$item['slug'],
'url' => $baseUrl.'/books/'.($bookSlugs[$bookId] ?? '').'/page/'.$item['slug'],
];
}
} elseif ('book' === $type && in_array($item['id'], $bookIds, true)) {
$filtered[] = [
'id' => $item['id'],
'type' => 'book',
'name' => $item['name'] ?? '',
'url' => $baseUrl.'/books/'.$item['slug'],
];
}
}

View File

@@ -126,9 +126,10 @@ final readonly class GiteaApiService
$regex = sprintf('#^[^/]+/%s($|-.+)#', preg_quote($taskCode, '#'));
return array_values(array_filter($allBranches, static function (array $branch) use ($regex): bool {
return 1 === preg_match($regex, $branch['name']);
}));
return array_values(array_filter(
$allBranches,
static fn (array $branch): bool => 1 === preg_match($regex, $branch['name']),
));
}
/**

View File

@@ -52,12 +52,12 @@ final readonly class NotificationService
return;
}
$number = sprintf('CT-%03d', $ticket->getNumber());
$statusLabel = $ticket->getStatus();
$message = 'Nouveau statut : '.$statusLabel;
$number = sprintf('CT-%03d', $ticket->getNumber());
$statusComment = $ticket->getStatusComment();
$message = 'Nouveau statut : '.$ticket->getStatus();
if (null !== $ticket->getStatusComment() && '' !== $ticket->getStatusComment()) {
$message .= ' — '.$ticket->getStatusComment();
if (null !== $statusComment && '' !== $statusComment) {
$message .= ' — '.$statusComment;
}
$notification = new Notification();

View File

@@ -17,31 +17,10 @@ final class TokenEncryptor
#[Autowire('%env(ENCRYPTION_KEY)%')]
string $encryptionKey,
) {
if ('' === $encryptionKey) {
$this->key = '';
$this->configured = false;
$key = $this->tryDecodeKey($encryptionKey);
return;
}
try {
$key = sodium_hex2bin($encryptionKey);
} catch (SodiumException) {
$this->key = '';
$this->configured = false;
return;
}
if (SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== mb_strlen($key, '8bit')) {
$this->key = '';
$this->configured = false;
return;
}
$this->key = $key;
$this->configured = true;
$this->key = $key ?? '';
$this->configured = null !== $key;
}
public function encrypt(string $plaintext): string
@@ -71,6 +50,25 @@ final class TokenEncryptor
return $plaintext;
}
private function tryDecodeKey(string $encryptionKey): ?string
{
if ('' === $encryptionKey) {
return null;
}
try {
$key = sodium_hex2bin($encryptionKey);
} catch (SodiumException) {
return null;
}
if (SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== mb_strlen($key, '8bit')) {
return null;
}
return $key;
}
private function assertConfigured(): void
{
if (!$this->configured) {

View File

@@ -51,12 +51,13 @@ final readonly class ClientTicketNumberProcessor implements ProcessorInterface
}
}
$nextNumber = $this->clientTicketRepository->findNextNumberForProject($project);
$data->setNumber($nextNumber);
$now = new DateTimeImmutable();
$data->setNumber($this->clientTicketRepository->findNextNumberForProjectForUpdate($project));
$data->setSubmittedBy($user);
$data->setStatus('new');
$data->setCreatedAt(new DateTimeImmutable());
$data->setUpdatedAt(new DateTimeImmutable());
$data->setCreatedAt($now);
$data->setUpdatedAt($now);
$this->entityManager->persist($data);
$this->entityManager->flush();

View File

@@ -54,17 +54,27 @@ final readonly class ClientTicketProvider implements ProviderInterface
// Apply filters from query parameters
$filters = $context['filters'] ?? [];
if (isset($filters['project'])) {
$projectId = is_numeric($filters['project']) ? (int) $filters['project'] : (int) basename($filters['project']);
$qb->andWhere('ct.project = :project')->setParameter('project', $projectId);
$qb->andWhere('ct.project = :project')
->setParameter('project', self::extractId($filters['project']))
;
}
if (isset($filters['status'])) {
$qb->andWhere('ct.status = :status')->setParameter('status', $filters['status']);
}
if (isset($filters['submittedBy']) && $this->security->isGranted('ROLE_ADMIN')) {
$submittedById = is_numeric($filters['submittedBy']) ? (int) $filters['submittedBy'] : (int) basename($filters['submittedBy']);
$qb->andWhere('ct.submittedBy = :submittedBy')->setParameter('submittedBy', $submittedById);
$qb->andWhere('ct.submittedBy = :submittedBy')
->setParameter('submittedBy', self::extractId($filters['submittedBy']))
;
}
return $qb->getQuery()->getResult();
}
/**
* Extract an entity ID from a value that may be a numeric ID or an IRI string.
*/
private static function extractId(string $value): int
{
return is_numeric($value) ? (int) $value : (int) basename($value);
}
}

View File

@@ -35,18 +35,21 @@ final readonly class ClientTicketStatusProcessor implements ProcessorInterface
$originalData = $context['previous_data'] ?? null;
// ROLE_CLIENT: can only edit content fields, not status
if (!$this->security->isGranted('ROLE_ADMIN') && $originalData instanceof ClientTicket) {
$data->setStatus($originalData->getStatus());
$data->setStatusComment($originalData->getStatusComment());
}
$statusChanged = false;
if ($originalData instanceof ClientTicket) {
// ROLE_CLIENT: can only edit content fields, not status
if (!$this->security->isGranted('ROLE_ADMIN')) {
$data->setStatus($originalData->getStatus());
$data->setStatusComment($originalData->getStatusComment());
}
$oldStatus = $originalData->getStatus();
$newStatus = $data->getStatus();
if ($oldStatus !== $newStatus) {
$forbidden = self::FORBIDDEN_TRANSITIONS[$oldStatus] ?? [];
$statusChanged = true;
$forbidden = self::FORBIDDEN_TRANSITIONS[$oldStatus] ?? [];
if (in_array($newStatus, $forbidden, true)) {
throw new BadRequestHttpException(sprintf('Transition from "%s" to "%s" is not allowed.', $oldStatus, $newStatus));
}
@@ -62,7 +65,9 @@ final readonly class ClientTicketStatusProcessor implements ProcessorInterface
$this->entityManager->persist($data);
$this->entityManager->flush();
$this->notificationService->createForStatusChange($data);
if ($statusChanged) {
$this->notificationService->createForStatusChange($data);
}
return $data;
}

View File

@@ -15,6 +15,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class GiteaBranchNameProvider implements ProviderInterface
{
/** @see GiteaBranchProcessor::ALLOWED_TYPES */
private const array ALLOWED_TYPES = ['feature', 'fix', 'refactor', 'hotfix', 'chore'];
public function __construct(

View File

@@ -24,6 +24,35 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
{
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
private const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain', 'text/csv',
'application/zip', 'application/x-rar-compressed', 'application/gzip',
'application/json', 'application/xml', 'text/xml',
];
private const MIME_TO_EXTENSION = [
'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif',
'image/webp' => 'webp', 'image/svg+xml' => 'svg',
'application/pdf' => 'pdf',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/vnd.ms-powerpoint' => 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
'text/plain' => 'txt', 'text/csv' => 'csv',
'application/zip' => 'zip', 'application/x-rar-compressed' => 'rar', 'application/gzip' => 'gz',
'application/json' => 'json', 'application/xml' => 'xml', 'text/xml' => 'xml',
];
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
@@ -52,50 +81,48 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
}
$taskIri = $request->request->get('task');
$clientTicketIri = $request->request->get('clientTicket');
$taskIri = $request->request->get('task', '');
$clientTicketIri = $request->request->get('clientTicket', '');
if ((null === $taskIri || '' === $taskIri) && (null === $clientTicketIri || '' === $clientTicketIri)) {
if ('' === $taskIri && '' === $clientTicketIri) {
throw new BadRequestHttpException('Either task or clientTicket IRI is required.');
}
$task = null;
$clientTicket = null;
if (null !== $taskIri && '' !== $taskIri) {
// Extract task ID from IRI (e.g., "/api/tasks/42" -> 42)
$taskId = (int) basename((string) $taskIri);
$task = $this->entityManager->getRepository(Task::class)->find($taskId);
if ('' !== $taskIri) {
$task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri));
if (null === $task) {
throw new BadRequestHttpException('Task not found.');
}
}
if (null !== $clientTicketIri && '' !== $clientTicketIri) {
$clientTicketId = (int) basename((string) $clientTicketIri);
$clientTicket = $this->entityManager->getRepository(ClientTicket::class)->find($clientTicketId);
if ('' !== $clientTicketIri) {
$clientTicket = $this->entityManager->getRepository(ClientTicket::class)->find((int) basename($clientTicketIri));
if (null === $clientTicket) {
throw new BadRequestHttpException('Client ticket not found.');
}
// Ownership validation for ROLE_CLIENT
if (!$this->security->isGranted('ROLE_ADMIN')) {
$currentUser = $this->security->getUser();
if ($clientTicket->getSubmittedBy() !== $currentUser) {
throw new AccessDeniedHttpException('You can only upload documents to your own tickets.');
}
if (!$this->security->isGranted('ROLE_ADMIN') && $clientTicket->getSubmittedBy() !== $this->security->getUser()) {
throw new AccessDeniedHttpException('You can only upload documents to your own tickets.');
}
}
// Capture file metadata BEFORE move() — move invalidates the temp file
// Use server-detected MIME type (finfo), not the client-supplied one
$originalName = $file->getClientOriginalName();
$extension = $file->getClientOriginalExtension() ?: 'bin';
$mimeType = $file->getClientMimeType() ?? 'application/octet-stream';
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
$fileSize = $file->getSize();
$uuid = Uuid::v4()->toRfc4122();
$fileName = $uuid.'.'.$extension;
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $mimeType));
}
$extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin';
$uuid = Uuid::v4()->toRfc4122();
$fileName = $uuid.'.'.$extension;
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0o775, true);

View File

@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Task;
use App\Repository\TaskRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
@@ -23,6 +24,7 @@ final readonly class TaskNumberProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private TaskRepository $taskRepository,
private EntityManagerInterface $entityManager,
) {}
/**
@@ -31,8 +33,12 @@ final readonly class TaskNumberProcessor implements ProcessorInterface
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($operation instanceof Post && null !== $data->getProject()) {
$maxNumber = $this->taskRepository->findMaxNumberByProject($data->getProject());
$data->setNumber($maxNumber + 1);
return $this->entityManager->wrapInTransaction(function () use ($data, $operation, $uriVariables, $context) {
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($data->getProject());
$data->setNumber($maxNumber + 1);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
});
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);

View File

@@ -29,10 +29,10 @@ final readonly class UserPasswordHasherProcessor implements ProcessorInterface
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (null !== $data->getPassword() && !str_starts_with($data->getPassword(), '$')) {
$data->setPassword(
$this->passwordHasher->hashPassword($data, $data->getPassword())
);
$plainPassword = $data->getPassword();
if (null !== $plainPassword && !str_starts_with($plainPassword, '$')) {
$data->setPassword($this->passwordHasher->hashPassword($data, $plainPassword));
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);