Files
Lesstime/frontend/modules/absence/services/absences.ts
T
matthieu 8313c759c6
Auto Tag Develop / tag (push) Successful in 9s
Migration modular monolith DDD (0.1 → 3.3) (#17)
## 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
2026-06-23 13:50:42 +00:00

138 lines
4.8 KiB
TypeScript

import type {
AbsenceBalance,
AbsencePolicy,
AbsencePolicyWrite,
AbsencePreviewPayload,
AbsencePreviewResult,
AbsenceRequest,
AbsenceRequestWrite,
AbsenceStatus,
AbsenceType,
} from './dto/absence'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type AbsenceRequestFilters = {
status?: AbsenceStatus
type?: AbsenceType
year?: number
user?: number
}
export function useAbsenceService() {
const api = useApi()
// --- Requests ---
async function getRequests(filters: AbsenceRequestFilters = {}): Promise<AbsenceRequest[]> {
const query: Record<string, unknown> = {}
if (filters.status) query.status = filters.status
if (filters.type) query.type = filters.type
if (filters.year) query.year = filters.year
if (filters.user) query.user = `/api/users/${filters.user}`
const data = await api.get<HydraCollection<AbsenceRequest>>('/absence_requests', query)
return extractHydraMembers(data)
}
async function getRequest(id: number): Promise<AbsenceRequest> {
return api.get<AbsenceRequest>(`/absence_requests/${id}`)
}
async function create(payload: AbsenceRequestWrite): Promise<AbsenceRequest> {
return api.post<AbsenceRequest>('/absence_requests', payload as Record<string, unknown>, {
toastSuccessKey: 'absences.toast.created',
})
}
async function preview(payload: AbsencePreviewPayload): Promise<AbsencePreviewResult> {
return api.post<AbsencePreviewResult>('/absence_requests/preview', payload as Record<string, unknown>, {
toast: false,
})
}
async function approve(id: number): Promise<AbsenceRequest> {
return api.patch<AbsenceRequest>(`/absence_requests/${id}/approve`, {}, {
toastSuccessKey: 'absences.toast.approved',
})
}
async function reject(id: number, rejectionReason: string): Promise<AbsenceRequest> {
return api.patch<AbsenceRequest>(`/absence_requests/${id}/reject`, { rejectionReason }, {
toastSuccessKey: 'absences.toast.rejected',
})
}
async function cancel(id: number): Promise<AbsenceRequest> {
return api.patch<AbsenceRequest>(`/absence_requests/${id}/cancel`, {}, {
toastSuccessKey: 'absences.toast.cancelled',
})
}
async function uploadJustification(id: number, file: File): Promise<AbsenceRequest> {
const form = new FormData()
form.append('file', file)
return api.post<AbsenceRequest>(`/absence_requests/${id}/justificatif`, form as unknown as Record<string, unknown>, {
toastSuccessKey: 'absences.toast.justificationUploaded',
})
}
// --- Balances ---
async function getBalances(filters: { user?: number; period?: string; type?: AbsenceType } = {}): Promise<AbsenceBalance[]> {
const query: Record<string, unknown> = {}
if (filters.user) query.user = `/api/users/${filters.user}`
if (filters.period) query.period = filters.period
if (filters.type) query.type = filters.type
const data = await api.get<HydraCollection<AbsenceBalance>>('/absence_balances', query)
return extractHydraMembers(data)
}
async function adjustBalance(id: number, payload: { acquired?: number; acquiring?: number; taken?: number }): Promise<AbsenceBalance> {
return api.patch<AbsenceBalance>(`/absence_balances/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'absences.toast.balanceAdjusted',
})
}
// --- Policies ---
async function getPolicies(): Promise<AbsencePolicy[]> {
const data = await api.get<HydraCollection<AbsencePolicy>>('/absence_policies')
return extractHydraMembers(data)
}
async function updatePolicy(id: number, payload: AbsencePolicyWrite): Promise<AbsencePolicy> {
return api.patch<AbsencePolicy>(`/absence_policies/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'absences.toast.policyUpdated',
})
}
// --- Admin calendar ---
async function getCalendar(from: string, to: string): Promise<AbsenceRequest[]> {
return api.get<AbsenceRequest[]>('/admin/absences/calendar', { from, to })
}
// --- Public holidays (computed server-side) ---
async function getPublicHolidays(from: string, to: string): Promise<Record<string, string>> {
return api.get<Record<string, string>>('/public_holidays', { from, to }, { toast: false })
}
return {
getRequests,
getRequest,
create,
preview,
approve,
reject,
cancel,
uploadJustification,
getBalances,
adjustBalance,
getPolicies,
updatePolicy,
getCalendar,
getPublicHolidays,
}
}