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,23 @@
|
||||
import { useShareService } from '~/modules/integration/services/share'
|
||||
|
||||
export function useShareStatus() {
|
||||
const enabled = useState<boolean | null>('share-enabled', () => null)
|
||||
const { getStatus } = useShareService()
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const status = await getStatus()
|
||||
enabled.value = status.enabled
|
||||
} catch {
|
||||
enabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (enabled.value === null) {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
return { enabled, refresh, ensureLoaded }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
BookStackSettings,
|
||||
BookStackSettingsWrite,
|
||||
BookStackTestResult,
|
||||
BookStackShelf,
|
||||
BookStackLink,
|
||||
BookStackLinkCreate,
|
||||
BookStackSearchResult,
|
||||
} from './dto/bookstack'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useBookStackService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getSettings(): Promise<BookStackSettings> {
|
||||
return api.get<BookStackSettings>('/settings/bookstack')
|
||||
}
|
||||
|
||||
async function saveSettings(payload: BookStackSettingsWrite): Promise<BookStackSettings> {
|
||||
return api.put<BookStackSettings>('/settings/bookstack', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'bookstack.settings.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<BookStackTestResult> {
|
||||
return api.post<BookStackTestResult>('/settings/bookstack/test')
|
||||
}
|
||||
|
||||
async function listShelves(): Promise<BookStackShelf[]> {
|
||||
const data = await api.get<HydraCollection<BookStackShelf>>('/bookstack/shelves')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getLinks(taskId: number): Promise<BookStackLink[]> {
|
||||
const data = await api.get<HydraCollection<BookStackLink>>(`/tasks/${taskId}/bookstack/links`)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function addLink(taskId: number, payload: BookStackLinkCreate): Promise<BookStackLink> {
|
||||
return api.post<BookStackLink>(`/tasks/${taskId}/bookstack/links`, payload as Record<string, unknown>)
|
||||
}
|
||||
|
||||
async function removeLink(taskId: number, linkId: number): Promise<void> {
|
||||
await api.delete(`/tasks/${taskId}/bookstack/links/${linkId}`)
|
||||
}
|
||||
|
||||
async function search(taskId: number, query: string): Promise<BookStackSearchResult[]> {
|
||||
const data = await api.get<HydraCollection<BookStackSearchResult>>(
|
||||
`/tasks/${taskId}/bookstack/search`,
|
||||
{ q: query },
|
||||
)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
return {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
testConnection,
|
||||
listShelves,
|
||||
getLinks,
|
||||
addLink,
|
||||
removeLink,
|
||||
search,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export type BookStackSettings = {
|
||||
url: string | null
|
||||
hasToken: boolean
|
||||
}
|
||||
|
||||
export type BookStackSettingsWrite = {
|
||||
url: string | null
|
||||
tokenId: string | null
|
||||
tokenSecret: string | null
|
||||
}
|
||||
|
||||
export type BookStackTestResult = {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export type BookStackShelf = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type BookStackLink = {
|
||||
id: number
|
||||
bookstackId: number
|
||||
bookstackType: 'page' | 'book'
|
||||
title: string
|
||||
url: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type BookStackLinkCreate = {
|
||||
bookstackId: number
|
||||
bookstackType: 'page' | 'book'
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type BookStackSearchResult = {
|
||||
id: number
|
||||
type: 'page' | 'book'
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export type GiteaSettings = {
|
||||
url: string | null
|
||||
hasToken: boolean
|
||||
}
|
||||
|
||||
export type GiteaSettingsWrite = {
|
||||
url: string | null
|
||||
token: string | null
|
||||
}
|
||||
|
||||
export type GiteaRepository = {
|
||||
fullName: string
|
||||
name: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
export type GiteaBranch = {
|
||||
name: string
|
||||
commits: GiteaCommit[]
|
||||
}
|
||||
|
||||
export type GiteaCommit = {
|
||||
sha: string
|
||||
message: string
|
||||
author: string
|
||||
date: string
|
||||
}
|
||||
|
||||
export type GiteaBranchCreate = {
|
||||
type: string
|
||||
baseBranch: string
|
||||
}
|
||||
|
||||
export type GiteaPullRequest = {
|
||||
number: number
|
||||
title: string
|
||||
state: string
|
||||
merged: boolean
|
||||
headBranch: string
|
||||
author: string
|
||||
url: string
|
||||
ciStatuses: GiteaCiStatus[]
|
||||
}
|
||||
|
||||
export type GiteaCiStatus = {
|
||||
context: string
|
||||
status: string
|
||||
target_url: string
|
||||
}
|
||||
|
||||
export type GiteaBranchName = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type GiteaTestResult = {
|
||||
success: boolean
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export type FileEntry = {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
size: number
|
||||
modifiedAt: number | null
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export type Breadcrumb = {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export type ShareBrowseResult = {
|
||||
path: string
|
||||
breadcrumb: Breadcrumb[]
|
||||
entries: FileEntry[]
|
||||
}
|
||||
|
||||
export type ShareSearchResult = {
|
||||
query: string
|
||||
entries: FileEntry[]
|
||||
}
|
||||
|
||||
export type ShareStatus = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ShareSettings = {
|
||||
host: string | null
|
||||
shareName: string | null
|
||||
basePath: string | null
|
||||
domain: string | null
|
||||
username: string | null
|
||||
enabled: boolean
|
||||
hasPassword: boolean
|
||||
}
|
||||
|
||||
export type ShareSettingsWrite = {
|
||||
host: string | null
|
||||
shareName: string | null
|
||||
basePath: string | null
|
||||
domain: string | null
|
||||
username: string | null
|
||||
password?: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ShareTestResult = {
|
||||
success: boolean
|
||||
message: string | null
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type ZimbraSettings = {
|
||||
serverUrl: string | null
|
||||
username: string | null
|
||||
calendarPath: string | null
|
||||
enabled: boolean
|
||||
hasPassword: boolean
|
||||
}
|
||||
|
||||
export type ZimbraSettingsWrite = {
|
||||
serverUrl: string | null
|
||||
username: string | null
|
||||
calendarPath: string | null
|
||||
password?: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ZimbraTestResult = {
|
||||
success: boolean
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
GiteaSettings,
|
||||
GiteaSettingsWrite,
|
||||
GiteaRepository,
|
||||
GiteaBranch,
|
||||
GiteaBranchCreate,
|
||||
GiteaPullRequest,
|
||||
GiteaBranchName,
|
||||
GiteaTestResult,
|
||||
} from './dto/gitea'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useGiteaService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getSettings(): Promise<GiteaSettings> {
|
||||
return api.get<GiteaSettings>('/settings/gitea')
|
||||
}
|
||||
|
||||
async function saveSettings(payload: GiteaSettingsWrite): Promise<GiteaSettings> {
|
||||
return api.put<GiteaSettings>('/settings/gitea', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'gitea.settings.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<GiteaTestResult> {
|
||||
return api.post<GiteaTestResult>('/settings/gitea/test')
|
||||
}
|
||||
|
||||
async function listRepositories(): Promise<GiteaRepository[]> {
|
||||
const data = await api.get<HydraCollection<GiteaRepository>>('/gitea/repositories')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function listBranches(taskId: number): Promise<GiteaBranch[]> {
|
||||
const data = await api.get<HydraCollection<GiteaBranch>>(`/tasks/${taskId}/gitea/branches`)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function createBranch(taskId: number, payload: GiteaBranchCreate): Promise<GiteaBranch> {
|
||||
return api.post<GiteaBranch>(`/tasks/${taskId}/gitea/branches`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'gitea.branch.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function listPullRequests(taskId: number): Promise<GiteaPullRequest[]> {
|
||||
const data = await api.get<HydraCollection<GiteaPullRequest>>(`/tasks/${taskId}/gitea/pull-requests`)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getBranchName(taskId: number, type: string): Promise<GiteaBranchName> {
|
||||
return api.get<GiteaBranchName>(`/tasks/${taskId}/gitea/branch-name/${type}`)
|
||||
}
|
||||
|
||||
return {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
testConnection,
|
||||
listRepositories,
|
||||
listBranches,
|
||||
createBranch,
|
||||
listPullRequests,
|
||||
getBranchName,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ShareSettings, ShareSettingsWrite, ShareTestResult } from './dto/share'
|
||||
|
||||
export function useShareSettingsService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getSettings(): Promise<ShareSettings> {
|
||||
return api.get<ShareSettings>('/settings/share')
|
||||
}
|
||||
|
||||
async function saveSettings(payload: ShareSettingsWrite): Promise<ShareSettings> {
|
||||
return api.put<ShareSettings>('/settings/share', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'adminShare.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<ShareTestResult> {
|
||||
return api.post<ShareTestResult>('/settings/share/test', {})
|
||||
}
|
||||
|
||||
return { getSettings, saveSettings, testConnection }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ShareBrowseResult, ShareSearchResult, ShareStatus } from './dto/share'
|
||||
|
||||
export function useShareService() {
|
||||
const api = useApi()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
async function browse(path: string): Promise<ShareBrowseResult> {
|
||||
const query = path ? `?path=${encodeURIComponent(path)}` : ''
|
||||
return api.get<ShareBrowseResult>(`/share/browse${query}`)
|
||||
}
|
||||
|
||||
async function search(query: string): Promise<ShareSearchResult> {
|
||||
return api.get<ShareSearchResult>(`/share/search?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
async function getStatus(): Promise<ShareStatus> {
|
||||
return api.get<ShareStatus>('/share/status')
|
||||
}
|
||||
|
||||
function getDownloadUrl(path: string, disposition: 'inline' | 'attachment' = 'inline'): string {
|
||||
const base = config.public.apiBase || '/api'
|
||||
return `${base}/share/download?path=${encodeURIComponent(path)}&disposition=${disposition}`
|
||||
}
|
||||
|
||||
return { browse, search, getStatus, getDownloadUrl }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ZimbraSettings, ZimbraSettingsWrite, ZimbraTestResult } from './dto/zimbra'
|
||||
|
||||
export function useZimbraService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getSettings(): Promise<ZimbraSettings> {
|
||||
return api.get<ZimbraSettings>('/settings/zimbra')
|
||||
}
|
||||
|
||||
async function saveSettings(payload: ZimbraSettingsWrite): Promise<ZimbraSettings> {
|
||||
return api.put<ZimbraSettings>('/settings/zimbra', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'zimbra.settings.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<ZimbraTestResult> {
|
||||
return api.post<ZimbraTestResult>('/settings/zimbra/test', {})
|
||||
}
|
||||
|
||||
return { getSettings, saveSettings, testConnection }
|
||||
}
|
||||
Reference in New Issue
Block a user