Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s
Auto Tag Develop / tag (push) Successful in 9s
## Migration modular monolith DDD — Lesstime (0.1 → 3.3) Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici. **Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle. ### Périmètre — 9 modules sous `src/Module/` | Phase | Module | Contenu | |------|--------|---------| | 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module | | 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` | | 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier | | 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) | | 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) | | 2.1 | **TimeTracking** | TimeEntry + MCP + export | | 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools | | 2.3 | **Absence** | demandes, soldes, policies, justificatifs | | 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) | | 2.5 | **Mail** | intégration IMAP OVH + liens tâches | | 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share | | 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) | | 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) | | 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire | ### Architecture - Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy). - Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées. - Reporting en DBAL read-only pur (aucun import d'entité d'un autre module). - Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif). ### Sécurité - ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne. - Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement). ### QA non-régression (branche reconstruite from scratch) - Migrations from scratch + fixtures : OK. - Compilation dev + prod : OK. - **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`. - Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche. - Build Nuxt OK, 9 layers, 0 import legacy résiduel. ### Points à arbitrer (hors périmètre de cette migration) - Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé. - Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque). - **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO. --- ## ⚠️ Déploiement / migration des données — à ne pas oublier ### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…). À lancer **juste après chaque restore/import** : ```sql DO $$ DECLARE r RECORD; maxid BIGINT; seq TEXT; BEGIN FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public' LOOP seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name); IF seq IS NOT NULL THEN EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid; PERFORM setval(seq, GREATEST(maxid,1), maxid > 0); END IF; END LOOP; END $$; ``` > Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque. ### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche) Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression. --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div ref="pageHeaderEl" class="sticky top-8 z-20 flex-shrink-0 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Suivi des temps</h1>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="shrink-0"
|
||||
@click="openCreateDrawer()"
|
||||
>
|
||||
<span class="hidden sm:inline">Ajouter une Activité</span>
|
||||
<span class="sm:hidden">Activité</span>
|
||||
</MalioButton>
|
||||
</div>
|
||||
|
||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
||||
<div class="flex shrink-0 items-center gap-1 h-8">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:chevron-left"
|
||||
aria-label="Précédent"
|
||||
variant="ghost"
|
||||
@click="navigatePrev"
|
||||
/>
|
||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:chevron-right"
|
||||
aria-label="Suivant"
|
||||
variant="ghost"
|
||||
@click="navigateNext"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
||||
<button
|
||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||
:key="mode"
|
||||
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
||||
:class="viewMode === mode
|
||||
? 'bg-primary-500 text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="viewMode = mode"
|
||||
>
|
||||
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
label="User"
|
||||
empty-option-label="Tous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Projet"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Tag"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioButton
|
||||
:label="$t('timeEntries.export')"
|
||||
variant="secondary"
|
||||
icon-name="mdi:download"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
@click="exportDrawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-0 mt-4 -mb-24 min-h-0 flex-1">
|
||||
<TimeEntryList
|
||||
v-if="viewMode === 'list'"
|
||||
:entries="filteredEntries"
|
||||
@edit-entry="openEditDrawer"
|
||||
@delete-entry="onDelete"
|
||||
/>
|
||||
<TimeTrackingCalendar
|
||||
v-else
|
||||
:entries="filteredEntries"
|
||||
:start-date="startDate"
|
||||
:view-mode="viewMode"
|
||||
:sticky-offset="pageHeaderHeight"
|
||||
@edit-entry="openEditDrawer"
|
||||
@create-entry="openCreateDrawer"
|
||||
@move-entry="onMoveEntry"
|
||||
@resize-entry="onResizeEntry"
|
||||
@contextmenu="onContextMenu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TimeEntryDrawer
|
||||
v-model="drawerOpen"
|
||||
:entry="editingEntry"
|
||||
:prefill-started-at="prefillStartedAt"
|
||||
:users="users"
|
||||
:projects="projects"
|
||||
:tags="tags"
|
||||
@saved="loadEntries"
|
||||
/>
|
||||
|
||||
<TimeEntryContextMenu
|
||||
:visible="contextMenu.visible"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
:entry="contextMenu.entry"
|
||||
:can-paste="!!clipboard"
|
||||
@close="contextMenu.visible = false"
|
||||
@copy="onCopy"
|
||||
@paste="onPaste"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
|
||||
<TimeTrackingExportDrawer
|
||||
v-model="exportDrawerOpen"
|
||||
:users="users"
|
||||
:projects="projects"
|
||||
:tags="tags"
|
||||
:clients="clients"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
useHead({ title: 'Suivi des temps' })
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const timeEntryService = useTimeEntryService()
|
||||
|
||||
const viewMode = ref<'week' | 'day' | 'list'>('week')
|
||||
const startDate = ref(getMonday(new Date()))
|
||||
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
|
||||
|
||||
const entries = ref<TimeEntry[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const exportDrawerOpen = ref(false)
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const editingEntry = ref<TimeEntry | null>(null)
|
||||
const prefillStartedAt = ref<string | null>(null)
|
||||
const clipboard = ref<TimeEntry | null>(null)
|
||||
const pageHeaderEl = ref<HTMLElement | null>(null)
|
||||
const pageHeaderHeight = ref(0)
|
||||
|
||||
const contextMenu = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
entry: null as TimeEntry | null,
|
||||
targetDate: null as string | null,
|
||||
})
|
||||
|
||||
const currentMonthLabel = computed(() => {
|
||||
const d = startDate.value
|
||||
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
|
||||
return `${months[d.getMonth()]} ${d.getFullYear()}`
|
||||
})
|
||||
|
||||
const userOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
tags.value.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
let pageHeaderResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function updatePageHeaderHeight() {
|
||||
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
||||
}
|
||||
|
||||
const filteredEntries = computed(() => entries.value)
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
const date = new Date(d)
|
||||
const day = date.getDay()
|
||||
const diff = date.getDate() - day + (day === 0 ? -6 : 1)
|
||||
date.setDate(diff)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date
|
||||
}
|
||||
|
||||
function navigatePrev() {
|
||||
const d = new Date(startDate.value)
|
||||
if (viewMode.value === 'day') {
|
||||
d.setDate(d.getDate() - 1)
|
||||
startDate.value = d
|
||||
} else if (viewMode.value === 'list') {
|
||||
d.setMonth(d.getMonth() - 1)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
d.setDate(d.getDate() - 7)
|
||||
startDate.value = getMonday(d)
|
||||
}
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
function navigateNext() {
|
||||
const d = new Date(startDate.value)
|
||||
if (viewMode.value === 'day') {
|
||||
d.setDate(d.getDate() + 1)
|
||||
startDate.value = d
|
||||
} else if (viewMode.value === 'list') {
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
d.setDate(d.getDate() + 7)
|
||||
startDate.value = getMonday(d)
|
||||
}
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
function openCreateDrawer(startedAt?: string) {
|
||||
editingEntry.value = null
|
||||
prefillStartedAt.value = startedAt ?? null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(entry: TimeEntry) {
|
||||
editingEntry.value = entry
|
||||
prefillStartedAt.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function onMoveEntry(entry: TimeEntry, newStartedAt: string, newStoppedAt: string) {
|
||||
// Optimistic update — instant visual feedback
|
||||
const idx = entries.value.findIndex((e) => e.id === entry.id)
|
||||
if (idx === -1) return
|
||||
const original = entries.value[idx]!
|
||||
entries.value[idx] = { ...original, startedAt: newStartedAt, stoppedAt: newStoppedAt }
|
||||
|
||||
try {
|
||||
await timeEntryService.update(entry.id, { startedAt: newStartedAt, stoppedAt: newStoppedAt })
|
||||
} catch {
|
||||
entries.value[idx] = original
|
||||
}
|
||||
}
|
||||
|
||||
async function onResizeEntry(entry: TimeEntry, newStartedAt: string, newStoppedAt: string) {
|
||||
// Optimistic update — instant visual feedback
|
||||
const idx = entries.value.findIndex((e) => e.id === entry.id)
|
||||
if (idx === -1) return
|
||||
const original = entries.value[idx]!
|
||||
entries.value[idx] = { ...original, startedAt: newStartedAt, stoppedAt: newStoppedAt }
|
||||
|
||||
try {
|
||||
await timeEntryService.update(entry.id, { startedAt: newStartedAt, stoppedAt: newStoppedAt })
|
||||
} catch {
|
||||
entries.value[idx] = original
|
||||
}
|
||||
}
|
||||
|
||||
function onContextMenu(event: MouseEvent, entry: TimeEntry | null) {
|
||||
contextMenu.visible = true
|
||||
contextMenu.x = event.clientX
|
||||
contextMenu.y = event.clientY
|
||||
contextMenu.entry = entry
|
||||
}
|
||||
|
||||
function onCopy(entry: TimeEntry) {
|
||||
clipboard.value = entry
|
||||
}
|
||||
|
||||
async function onPaste() {
|
||||
if (!clipboard.value) return
|
||||
const { create } = useTimeEntryService()
|
||||
await create({
|
||||
title: clipboard.value.title ?? undefined,
|
||||
description: clipboard.value.description ?? undefined,
|
||||
startedAt: clipboard.value.startedAt,
|
||||
stoppedAt: clipboard.value.stoppedAt ?? undefined,
|
||||
user: `/api/users/${selectedUserId.value}`,
|
||||
project: clipboard.value.project ? `/api/projects/${clipboard.value.project.id}` : null,
|
||||
tags: clipboard.value.tags.map((t) => `/api/task_tags/${t.id}`),
|
||||
})
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pageHeaderResizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
async function onDelete(entry: TimeEntry) {
|
||||
await timeEntryService.remove(entry.id)
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
async function onExport(params: {
|
||||
after: string
|
||||
before: string
|
||||
users?: number[]
|
||||
projects?: number[]
|
||||
client?: number
|
||||
tags?: number[]
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const { t } = useNuxtApp().$i18n as { t: (key: string) => string }
|
||||
|
||||
toast.info({ message: t('timeEntries.exportLoading') })
|
||||
|
||||
try {
|
||||
const result = await timeEntryService.downloadExport(params)
|
||||
|
||||
const url = URL.createObjectURL(result.blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = result.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
toast.success({ message: t('timeEntries.exportSuccess') })
|
||||
} catch {
|
||||
toast.error({ message: t('timeEntries.exportError') })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntries() {
|
||||
const end = new Date(startDate.value)
|
||||
if (viewMode.value === 'day') {
|
||||
end.setDate(end.getDate() + 1)
|
||||
} else if (viewMode.value === 'list') {
|
||||
end.setMonth(end.getMonth() + 1)
|
||||
} else {
|
||||
end.setDate(end.getDate() + 7)
|
||||
}
|
||||
|
||||
entries.value = await timeEntryService.getByDateRange({
|
||||
after: startDate.value.toISOString(),
|
||||
before: end.toISOString(),
|
||||
user: selectedUserId.value ?? undefined,
|
||||
project: selectedProjectId.value ?? undefined,
|
||||
tag: selectedTagId.value ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function loadReferenceData() {
|
||||
const api = useApi()
|
||||
|
||||
const [usersData, projectsData, typesData, clientsData] = await Promise.all([
|
||||
api.get<HydraCollection<UserData>>('/users'),
|
||||
api.get<HydraCollection<Project>>('/projects'),
|
||||
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
||||
api.get<HydraCollection<Client>>('/clients'),
|
||||
])
|
||||
|
||||
users.value = extractHydraMembers(usersData)
|
||||
projects.value = extractHydraMembers(projectsData)
|
||||
tags.value = extractHydraMembers(typesData)
|
||||
clients.value = extractHydraMembers(clientsData)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updatePageHeaderHeight()
|
||||
|
||||
if (pageHeaderEl.value && typeof ResizeObserver !== 'undefined') {
|
||||
pageHeaderResizeObserver = new ResizeObserver(() => {
|
||||
updatePageHeaderHeight()
|
||||
})
|
||||
pageHeaderResizeObserver.observe(pageHeaderEl.value)
|
||||
}
|
||||
|
||||
await loadReferenceData()
|
||||
await loadEntries()
|
||||
})
|
||||
|
||||
watch(viewMode, () => {
|
||||
selectedDateFilter.value = null
|
||||
if (viewMode.value === 'day') {
|
||||
// keep current date
|
||||
} else if (viewMode.value === 'list') {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
startDate.value = getMonday(startDate.value)
|
||||
}
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
watch([selectedUserId, selectedProjectId, selectedTagId], () => {
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
watch(selectedDateFilter, (val) => {
|
||||
if (!val) return
|
||||
if (Array.isArray(val)) {
|
||||
startDate.value = getMonday(val[0])
|
||||
viewMode.value = 'week'
|
||||
} else {
|
||||
startDate.value = val
|
||||
viewMode.value = 'day'
|
||||
}
|
||||
loadEntries()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user