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>
This commit is contained in:
2026-03-15 22:09:16 +01:00
parent a5144443a4
commit e4fc34b90f
52 changed files with 662 additions and 569 deletions

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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`
}