From e4fc34b90fa7604109851c3af2f431cfc713fb34 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 22:09:16 +0100 Subject: [PATCH] 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) --- .../components/admin/AdminBookStackTab.vue | 16 +- .../client-ticket/ProjectClientTickets.vue | 15 +- .../components/project/ProjectGroupTab.vue | 2 +- frontend/components/task/TaskDocumentList.vue | 8 +- .../components/task/TaskDocumentPreview.vue | 9 +- frontend/components/task/TaskModal.vue | 32 +- frontend/composables/useApi.ts | 11 +- frontend/composables/useAppVersion.ts | 4 +- .../composables/useClientTicketHelpers.ts | 21 +- frontend/layouts/auth.vue | 4 - frontend/layouts/default.vue | 5 - frontend/pages/login.vue | 5 +- frontend/pages/time-tracking.vue | 23 +- frontend/services/auth.ts | 30 +- frontend/services/client-tickets.ts | 2 +- frontend/services/task-documents.ts | 22 +- frontend/services/tasks.ts | 14 +- frontend/stores/timer.ts | 13 +- frontend/utils/format.ts | 5 + migrations/Version20260315210619.php | 31 ++ .../TaskDocumentDownloadController.php | 15 +- src/Entity/Task.php | 2 + src/Exception/BookStackApiException.php | 9 +- src/Exception/GiteaApiException.php | 9 +- src/Mcp/Tool/Project/CreateProjectTool.php | 14 +- src/Mcp/Tool/Project/GetProjectTool.php | 13 +- src/Mcp/Tool/Project/ListProjectsTool.php | 14 +- src/Mcp/Tool/Project/UpdateProjectTool.php | 14 +- src/Mcp/Tool/Serializer.php | 277 ++++++++++++++++++ src/Mcp/Tool/Task/CreateTaskTool.php | 41 +-- src/Mcp/Tool/Task/GetTaskTool.php | 56 +--- src/Mcp/Tool/Task/ListTasksTool.php | 45 +-- src/Mcp/Tool/Task/UpdateTaskTool.php | 41 +-- src/Mcp/Tool/TaskMeta/CreateGroupTool.php | 14 +- src/Mcp/Tool/TaskMeta/ListGroupsTool.php | 14 +- src/Mcp/Tool/TaskMeta/UpdateGroupTool.php | 14 +- .../Tool/TimeEntry/CreateTimeEntryTool.php | 27 +- .../Tool/TimeEntry/ListTimeEntriesTool.php | 30 +- .../Tool/TimeEntry/UpdateTimeEntryTool.php | 26 +- src/Repository/ClientTicketRepository.php | 19 +- src/Repository/TaskRepository.php | 24 +- src/Service/BookStackApiService.php | 44 +-- src/Service/GiteaApiService.php | 7 +- src/Service/NotificationService.php | 10 +- src/Service/TokenEncryptor.php | 46 ++- src/State/ClientTicketNumberProcessor.php | 9 +- src/State/ClientTicketProvider.php | 18 +- src/State/ClientTicketStatusProcessor.php | 19 +- src/State/GiteaBranchNameProvider.php | 1 + src/State/TaskDocumentProcessor.php | 69 +++-- src/State/TaskNumberProcessor.php | 10 +- src/State/UserPasswordHasherProcessor.php | 8 +- 52 files changed, 662 insertions(+), 569 deletions(-) create mode 100644 frontend/utils/format.ts create mode 100644 migrations/Version20260315210619.php create mode 100644 src/Mcp/Tool/Serializer.php diff --git a/frontend/components/admin/AdminBookStackTab.vue b/frontend/components/admin/AdminBookStackTab.vue index 9584353..e846930 100644 --- a/frontend/components/admin/AdminBookStackTab.vue +++ b/frontend/components/admin/AdminBookStackTab.vue @@ -10,15 +10,13 @@ input-class="w-full" /> -
- -
+
{ 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() { diff --git a/frontend/components/project/ProjectGroupTab.vue b/frontend/components/project/ProjectGroupTab.vue index 240ce73..d8d457a 100644 --- a/frontend/components/project/ProjectGroupTab.vue +++ b/frontend/components/project/ProjectGroupTab.vue @@ -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 diff --git a/frontend/components/task/TaskDocumentList.vue b/frontend/components/task/TaskDocumentList.vue index dd4098c..25b0e0e 100644 --- a/frontend/components/task/TaskDocumentList.vue +++ b/frontend/components/task/TaskDocumentList.vue @@ -28,7 +28,7 @@

{{ doc.originalName }}

-

{{ formatSize(doc.size) }}

+

{{ formatFileSize(doc.size) }}

@@ -47,6 +47,7 @@ diff --git a/frontend/components/task/TaskDocumentPreview.vue b/frontend/components/task/TaskDocumentPreview.vue index b9b8543..243c009 100644 --- a/frontend/components/task/TaskDocumentPreview.vue +++ b/frontend/components/task/TaskDocumentPreview.vue @@ -56,7 +56,7 @@

{{ document.originalName }}

-

{{ formatSize(document.size) }}

+

{{ formatFileSize(document.size) }}

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) { diff --git a/frontend/components/task/TaskModal.vue b/frontend/components/task/TaskModal.vue index 0ebdf95..fe6f082 100644 --- a/frontend/components/task/TaskModal.vue +++ b/frontend/components/task/TaskModal.vue @@ -154,7 +154,7 @@ /> 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([]) -const documents = computed(() => localDocuments.value) const previewDoc = ref(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] } } diff --git a/frontend/composables/useApi.ts b/frontend/composables/useApi.ts index b81a602..760df5b 100644 --- a/frontend/composables/useApi.ts +++ b/frontend/composables/useApi.ts @@ -29,13 +29,14 @@ export type ApiFetchOptions = 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 = ( + function request( method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', url: string, options: ApiFetchOptions<'json'> = {} - ) => { + ) { const needsJsonBody = method === 'POST' || method === 'PUT' const needsMergePatch = method === 'PATCH' diff --git a/frontend/composables/useAppVersion.ts b/frontend/composables/useAppVersion.ts index 803278d..921f546 100644 --- a/frontend/composables/useAppVersion.ts +++ b/frontend/composables/useAppVersion.ts @@ -1,8 +1,8 @@ -export const useAppVersion = () => { +export function useAppVersion() { const api = useApi() const version = useState('app-version', () => null) - const load = async () => { + async function load(): Promise { if (version.value) { return version.value } diff --git a/frontend/composables/useClientTicketHelpers.ts b/frontend/composables/useClientTicketHelpers.ts index 2742eef..feab80f 100644 --- a/frontend/composables/useClientTicketHelpers.ts +++ b/frontend/composables/useClientTicketHelpers.ts @@ -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 } } diff --git a/frontend/layouts/auth.vue b/frontend/layouts/auth.vue index 009acb7..117c591 100644 --- a/frontend/layouts/auth.vue +++ b/frontend/layouts/auth.vue @@ -5,7 +5,3 @@
- - diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 1616d11..bbf64ae 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -242,11 +242,6 @@ function onCompleteSaved() { timerStore.clearPendingEntry() }) } - -const handleLogout = async () => { - await auth.logout() - await navigateTo('/login') -}