Migration modular monolith DDD (0.1 → 3.3) (#17)
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:
2026-06-23 13:50:42 +00:00
parent d0a49322e1
commit 8313c759c6
622 changed files with 24802 additions and 2864 deletions
-137
View File
@@ -1,137 +0,0 @@
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,
}
}
-66
View File
@@ -1,66 +0,0 @@
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,
}
}
-32
View File
@@ -1,32 +0,0 @@
import type { Client, ClientWrite } from './dto/client'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useClientService() {
const api = useApi()
async function getAll(): Promise<Client[]> {
const data = await api.get<HydraCollection<Client>>('/clients')
return extractHydraMembers(data)
}
async function create(payload: ClientWrite): Promise<Client> {
return api.post<Client>('/clients', payload as Record<string, unknown>, {
toastSuccessKey: 'clients.created',
})
}
async function update(id: number, payload: Partial<ClientWrite>): Promise<Client> {
return api.patch<Client>(`/clients/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'clients.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/clients/${id}`, {}, {
toastSuccessKey: 'clients.deleted',
})
}
return { getAll, create, update, remove }
}
-93
View File
@@ -1,93 +0,0 @@
export type AbsenceType = 'cp' | 'mariage_pacs' | 'naissance' | 'conge_parental' | 'deces' | 'maladie'
export type AbsenceStatus = 'pending' | 'approved' | 'rejected' | 'cancelled'
export type HalfDay = 'matin' | 'apres_midi'
export type AbsenceUserRef = {
'@id'?: string
id: number
username: string
avatarUrl: string | null
}
export type AbsenceRequest = {
'@id'?: string
id: number
user: AbsenceUserRef
type: AbsenceType
label: string
startDate: string
endDate: string
startHalfDay: HalfDay | null
endHalfDay: HalfDay | null
countedDays: number
reason: string | null
justificationFileName: string | null
justificationUrl: string | null
status: AbsenceStatus
rejectionReason: string | null
createdAt: string
reviewedAt: string | null
reviewedBy: AbsenceUserRef | null
}
export type AbsenceRequestWrite = {
type: AbsenceType
startDate: string
endDate: string
startHalfDay?: HalfDay | null
endHalfDay?: HalfDay | null
reason?: string | null
}
export type AbsenceBalance = {
'@id'?: string
id: number
user: AbsenceUserRef
type: AbsenceType
label: string
period: string
acquired: number
acquiring: number
acquiredTotal: number
taken: number
pending: number
available: number
}
export type AbsencePolicy = {
'@id'?: string
id: number
type: AbsenceType
label: string
daysPerYear: number | null
daysPerEvent: number | null
justificationRequired: boolean
noticeDays: number
countWorkingDaysOnly: boolean
active: boolean
}
export type AbsencePolicyWrite = {
daysPerYear?: number | null
daysPerEvent?: number | null
justificationRequired?: boolean
noticeDays?: number
countWorkingDaysOnly?: boolean
active?: boolean
}
export type AbsencePreviewPayload = {
type: AbsenceType
startDate: string
endDate: string
startHalfDay?: HalfDay | null
endHalfDay?: HalfDay | null
}
export type AbsencePreviewResult = {
countedDays: number
period: string | null
available: number | null
projectedAvailable: number | null
justificationRequired: boolean
}
-42
View File
@@ -1,42 +0,0 @@
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
}
-19
View File
@@ -1,19 +0,0 @@
export type Client = {
id: number
'@id'?: string
name: string
email: string | null
phone: string | null
street: string | null
city: string | null
postalCode: string | null
}
export type ClientWrite = {
name: string
email: string | null
phone: string | null
street: string | null
city: string | null
postalCode: string | null
}
-57
View File
@@ -1,57 +0,0 @@
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
}
-122
View File
@@ -1,122 +0,0 @@
// Lecture de la configuration mail (singleton admin)
export type MailConfigurationDto = {
protocol: string | null
imapHost: string | null
imapPort: number | null
imapEncryption: string | null
smtpHost: string | null
smtpPort: number | null
smtpEncryption: string | null
username: string | null
sentFolderPath: string | null
enabled: boolean
hasPassword: boolean
// password JAMAIS présent dans les réponses GET
}
// Input PATCH configuration (password optionnel, write-only)
export type MailConfigurationUpdateDto = {
protocol?: string | null
imapHost?: string | null
imapPort?: number | null
imapEncryption?: string | null
smtpHost?: string | null
smtpPort?: number | null
smtpEncryption?: string | null
username?: string | null
sentFolderPath?: string | null
enabled?: boolean
password?: string // write-only, jamais retourné
}
// Résultat du test de connexion
export type MailTestConnectionResultDto = {
ok: boolean
foldersCount?: number
error?: string
}
// Dossier mail (peut être imbriqué)
export type MailFolderDto = {
path: string
displayName: string
parentPath: string | null
unreadCount: number
totalCount: number
children?: MailFolderDto[]
}
// Adresse mail (nom + email)
export type MailAddressDto = {
name: string | null
email: string
}
// En-tête d'un message (liste)
export type MailMessageHeaderDto = {
id: number
messageId: string // identifiant IMAP unique
folderPath: string
subject: string | null
fromName: string | null
fromEmail: string | null
toRecipients: MailAddressDto[]
ccRecipients: MailAddressDto[]
sentAt: string | null // ISO 8601
receivedAt: string // ISO 8601
isRead: boolean
isFlagged: boolean
hasAttachments: boolean
linkedTaskIds: number[]
}
// Pièce jointe (métadonnées uniquement, téléchargement via downloadId)
export type MailAttachmentDto = {
downloadId: string
filename: string
mimeType: string
size: number // octets
}
// Détail complet d'un message (enrichi avec body + PJ)
export type MailMessageDetailDto = {
header: MailMessageHeaderDto
bodyHtml: string | null // HTML brut — TOUJOURS passer par sanitizeMailHtml() avant affichage
bodyText: string | null // Fallback texte plain
attachments: MailAttachmentDto[]
}
// Page de messages paginée (cursor-based)
export type MailMessagesPageDto = {
items: MailMessageHeaderDto[]
nextCursor: string | null // null = plus de page suivante
total: number
}
// Input : marquer lu/non-lu
export type MailMessageReadInput = {
read: boolean
}
// Input : marquer étoilé/non-étoilé
export type MailMessageFlagInput = {
flagged: boolean
}
// Input : créer une tâche depuis un mail
export type MailCreateTaskInput = {
projectId: number
taskGroupId?: number | null
assigneeId?: number
statusId?: number
}
// Input : lier une tâche existante à un mail
export type MailLinkTaskInput = {
taskId: number
}
// Résultat de la sync manuelle
export type MailSyncResultDto = {
dispatched: boolean
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type NotificationType = 'ticket_created' | 'ticket_status_changed'
export type NotificationType = 'task_assigned' | 'task_collaborator_added'
export type Notification = {
'@id'?: string
-33
View File
@@ -1,33 +0,0 @@
import type { Client } from './client'
import type { Workflow } from './workflow'
export type Project = {
id: number
'@id'?: string
code: string
name: string
description: string | null
color: string
client: Client | null
workflow: Workflow
giteaOwner: string | null
giteaRepo: string | null
bookstackShelfId: number | null
bookstackShelfName: string | null
archived: boolean
taskCount: number
}
export type ProjectWrite = {
code?: string
name: string
description: string | null
color: string
client: string | null // IRI : "/api/clients/1" ou null
workflow?: string // IRI : "/api/workflows/1"
giteaOwner?: string | null
giteaRepo?: string | null
bookstackShelfId?: number | null
bookstackShelfName?: string | null
archived?: boolean
}
-53
View File
@@ -1,53 +0,0 @@
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
}
-14
View File
@@ -1,14 +0,0 @@
import type { UserData } from './user-data'
export type TaskDocument = {
'@id'?: string
id: number
task: string
originalName: string
fileName?: string | null
sharePath?: string | null
mimeType: string
size: number
createdAt: string
uploadedBy: UserData | null
}
-9
View File
@@ -1,9 +0,0 @@
export type TaskEffort = {
id: number
'@id'?: string
label: string
}
export type TaskEffortWrite = {
label: string
}
-19
View File
@@ -1,19 +0,0 @@
import type { Project } from './project'
export type TaskGroup = {
id: number
'@id'?: string
title: string
description: string | null
color: string
project: Project | null
archived: boolean
}
export type TaskGroupWrite = {
title: string
description: string | null
color: string
project: string
archived?: boolean
}
-11
View File
@@ -1,11 +0,0 @@
export type TaskPriority = {
id: number
'@id'?: string
label: string
color: string
}
export type TaskPriorityWrite = {
label: string
color: string
}
-22
View File
@@ -1,22 +0,0 @@
export type TaskRecurrence = {
id: number
'@id'?: string
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval: number
daysOfWeek: string[] | null
dayOfMonth: number | null
weekOfMonth: number | null
endDate: string | null
maxOccurrences: number | null
occurrenceCount: number
}
export type TaskRecurrenceWrite = {
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval: number
daysOfWeek?: string[] | null
dayOfMonth?: number | null
weekOfMonth?: number | null
endDate?: string | null
maxOccurrences?: number | null
}
-21
View File
@@ -1,21 +0,0 @@
import type { StatusCategory } from './workflow'
export type TaskStatus = {
id: number
'@id'?: string
label: string
color: string
position: number
isFinal: boolean
category: StatusCategory
workflow?: { '@id': string, id: number } | string
}
export type TaskStatusWrite = {
label: string
color: string
position: number
isFinal: boolean
category: StatusCategory
workflow?: string
}
-11
View File
@@ -1,11 +0,0 @@
export type TaskTag = {
id: number
'@id'?: string
label: string
color: string
}
export type TaskTagWrite = {
label: string
color: string
}
-62
View File
@@ -1,62 +0,0 @@
import type { TaskStatus } from './task-status'
import type { TaskEffort } from './task-effort'
import type { TaskPriority } from './task-priority'
import type { TaskTag } from './task-tag'
import type { TaskGroup } from './task-group'
import type { UserData } from './user-data'
import type { Project } from './project'
import type { TaskDocument } from './task-document'
export type Task = {
id: number
'@id'?: string
number: number
title: string
description: string | null
status: TaskStatus | null
effort: TaskEffort | null
priority: TaskPriority | null
assignee: UserData | null
collaborators: UserData[]
group: TaskGroup | null
project: Project | null
tags: TaskTag[]
documents: TaskDocument[]
archived: boolean
scheduledStart: string | null
scheduledEnd: string | null
deadline: string | null
syncToCalendar: boolean
calendarSyncError: string | null
recurrence: {
id: number
'@id'?: string
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval: number
daysOfWeek: string[] | null
dayOfMonth: number | null
weekOfMonth: number | null
endDate: string | null
maxOccurrences: number | null
occurrenceCount: number
} | null
}
export type TaskWrite = {
title: string
description: string | null
status: string | null
effort: string | null
priority: string | null
assignee: string | null
collaborators?: string[]
group: string | null
project: string
tags: string[]
archived?: boolean
scheduledStart?: string | null
scheduledEnd?: string | null
deadline?: string | null
syncToCalendar?: boolean
recurrence?: string | null
}
-28
View File
@@ -1,28 +0,0 @@
import type { UserData } from './user-data'
import type { Project } from './project'
import type { Task } from './task'
import type { TaskTag } from './task-tag'
export type TimeEntry = {
id: number
'@id'?: string
title: string | null
description: string | null
startedAt: string
stoppedAt: string | null
user: UserData
project: Project | null
task: Task | null
tags: TaskTag[]
}
export type TimeEntryWrite = {
title?: string | null
description?: string | null
startedAt: string
stoppedAt?: string | null
user: string
project?: string | null
task?: string | null
tags?: string[]
}
+1
View File
@@ -7,6 +7,7 @@ export type UserData = {
firstName?: string | null
lastName?: string | null
roles: string[]
effectivePermissions?: string[]
avatarUrl?: string | null
apiToken?: string | null
// HR / absence management
-46
View File
@@ -1,46 +0,0 @@
import type { TaskStatus, TaskStatusWrite } from './task-status'
export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done'
export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
todo: 'À faire',
in_progress: 'En cours',
blocked: 'Bloqué',
review: 'En validation',
done: 'Terminé',
}
/** Palette canonique des catégories (couleurs « classiques »), indépendante des workflows. */
export const STATUS_CATEGORY_COLOR: Record<StatusCategory, string> = {
todo: '#222783',
in_progress: '#4A90D9',
blocked: '#C62828',
review: '#FF8F00',
done: '#26A69A',
}
/** Renvoie '#1f2937' (foncé) ou '#ffffff' (blanc) selon la luminance du fond, pour rester lisible. */
export function contrastText(hex: string): string {
const c = hex.replace('#', '')
const r = parseInt(c.slice(0, 2), 16)
const g = parseInt(c.slice(2, 4), 16)
const b = parseInt(c.slice(4, 6), 16)
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return lum > 0.6 ? '#1f2937' : '#ffffff'
}
export type Workflow = {
id: number
'@id'?: string
name: string
isDefault: boolean
position: number
statuses: TaskStatus[]
}
export type WorkflowWrite = {
name: string
isDefault: boolean
position: number
statuses?: TaskStatusWrite[]
}
-19
View File
@@ -1,19 +0,0 @@
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
}
-66
View File
@@ -1,66 +0,0 @@
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,
}
}
-276
View File
@@ -1,276 +0,0 @@
import type {
MailConfigurationDto,
MailConfigurationUpdateDto,
MailTestConnectionResultDto,
MailFolderDto,
MailMessageHeaderDto,
MailMessageDetailDto,
MailMessagesPageDto,
MailMessageReadInput,
MailMessageFlagInput,
MailCreateTaskInput,
MailLinkTaskInput,
MailSyncResultDto,
} from './dto/mail'
import type { Task } from './dto/task'
type BackendMailMessage = {
id: number
messageId: string
uid: number
folderPath?: string
subject: string | null
fromAddress: string | null
fromName: string | null
toAddresses: string[] | null
ccAddresses: string[] | null
sentAt: string | null
isRead: boolean
isFlagged: boolean
hasAttachments: boolean
snippet?: string | null
linkedTaskIds?: number[]
}
function toAddressList(values: string[] | null | undefined): { email: string; name: string | null }[] {
return (values ?? []).map((email) => ({ email, name: null }))
}
function mapHeader(m: BackendMailMessage, fallbackFolderPath = ''): MailMessageHeaderDto {
return {
id: m.id,
messageId: m.messageId,
folderPath: m.folderPath ?? fallbackFolderPath,
subject: m.subject,
fromName: m.fromName,
fromEmail: m.fromAddress,
toRecipients: toAddressList(m.toAddresses),
ccRecipients: toAddressList(m.ccAddresses),
sentAt: m.sentAt,
receivedAt: m.sentAt ?? '',
isRead: m.isRead,
isFlagged: m.isFlagged,
hasAttachments: m.hasAttachments,
linkedTaskIds: m.linkedTaskIds ?? [],
}
}
export function useMailService() {
const api = useApi()
// ─── Configuration (Admin) ────────────────────────────────────────────────
/**
* Récupère la configuration mail singleton.
* Requiert ROLE_ADMIN — 403 sinon.
*/
async function getConfiguration(): Promise<MailConfigurationDto> {
return api.get<MailConfigurationDto>('/mail/configuration')
}
/**
* Met à jour la configuration mail (PATCH merge).
* Si payload.password est fourni, il sera chiffré côté backend.
* Jamais retourné en clair dans la réponse.
*/
async function updateConfiguration(
payload: MailConfigurationUpdateDto,
): Promise<MailConfigurationDto> {
return api.patch<MailConfigurationDto>(
'/mail/configuration',
payload as Record<string, unknown>,
{ toastSuccessKey: 'mail.configuration.saved' },
)
}
/**
* Teste la connexion IMAP avec la configuration actuelle.
* Requiert ROLE_ADMIN.
*/
async function testConfiguration(): Promise<MailTestConnectionResultDto> {
return api.post<MailTestConnectionResultDto>('/mail/configuration/test', {})
}
// ─── Dossiers ─────────────────────────────────────────────────────────────
/**
* Liste tous les dossiers mail depuis la base (cache BDD, pas live IMAP).
* Retourne une liste plate — la construction de l'arbre est faite dans le store
* via le getter `folderTree`.
*/
async function listFolders(): Promise<MailFolderDto[]> {
return api.get<MailFolderDto[]>('/mail/folders')
}
// ─── Messages ─────────────────────────────────────────────────────────────
/**
* Liste les messages d'un dossier, paginés par cursor.
* @param folderPath - Chemin du dossier (ex: "INBOX", "INBOX.Sent")
* @param cursor - Opaque cursor retourné par la page précédente (undefined = première page)
* @param limit - Nombre de messages par page (défaut backend : 50)
*/
async function listMessages(
folderPath: string,
cursor?: string,
limit?: number,
): Promise<MailMessagesPageDto> {
const query: Record<string, unknown> = {}
if (cursor) query.cursor = cursor
if (limit) query.limit = limit
const path = `/mail/folders/${encodeURIComponent(folderPath)}/messages`
const response = await api.get<{ messages: BackendMailMessage[]; nextCursor: string | null }>(
path,
query,
)
return {
items: response.messages.map((m) => mapHeader(m, folderPath)),
nextCursor: response.nextCursor,
total: response.messages.length,
}
}
/**
* Récupère le détail complet d'un message (body live IMAP, cached 5 min).
* @param id - ID BDD du message (MailMessage.id)
*/
async function getMessage(id: number): Promise<MailMessageDetailDto> {
const response = await api.get<
BackendMailMessage & {
bodyHtml: string | null
bodyText: string | null
attachments: MailMessageDetailDto['attachments']
}
>(`/mail/messages/${id}`)
return {
header: mapHeader(response),
bodyHtml: response.bodyHtml,
bodyText: response.bodyText,
attachments: response.attachments,
}
}
// ─── Actions sur les messages ─────────────────────────────────────────────
/**
* Marque un message comme lu ou non-lu.
*/
async function markRead(id: number, read: boolean): Promise<MailMessageHeaderDto> {
const payload: MailMessageReadInput = { read }
return api.post<MailMessageHeaderDto>(
`/mail/messages/${id}/read`,
payload as unknown as Record<string, unknown>,
)
}
/**
* Marque un message comme étoilé ou non-étoilé.
*/
async function markFlagged(id: number, flagged: boolean): Promise<MailMessageHeaderDto> {
const payload: MailMessageFlagInput = { flagged }
return api.post<MailMessageHeaderDto>(
`/mail/messages/${id}/flag`,
payload as unknown as Record<string, unknown>,
)
}
// ─── Intégration tâches ───────────────────────────────────────────────────
/**
* Crée une nouvelle tâche à partir d'un mail (subject → titre, body → description).
* @param mailId - ID BDD du message
* @param input - Paramètres de la tâche à créer
*/
async function createTaskFromMail(
mailId: number,
input: MailCreateTaskInput,
): Promise<Task> {
return api.post<Task>(
`/mail/messages/${mailId}/create-task`,
input as unknown as Record<string, unknown>,
{ toastSuccessKey: 'mail.task.created' },
)
}
/**
* Lie un mail à une tâche existante.
* @param mailId - ID BDD du message
* @param taskId - ID de la tâche existante
*/
async function linkTask(mailId: number, taskId: number): Promise<void> {
const payload: MailLinkTaskInput = { taskId }
await api.post<void>(
`/mail/messages/${mailId}/link-task`,
payload as unknown as Record<string, unknown>,
{ toastSuccessKey: 'mail.task.linked' },
)
}
/**
* Supprime le lien entre un mail et une tâche.
* @param mailId - ID BDD du message
* @param taskId - ID de la tâche
*/
async function unlinkTask(mailId: number, taskId: number): Promise<void> {
await api.delete<void>(`/mail/messages/${mailId}/link-task/${taskId}`, {}, {
toastSuccessKey: 'mail.task.unlinked',
})
}
/**
* Liste les mails liés à une tâche (pour l'onglet "Mails" du TaskDrawer — Phase 6).
* @param taskId - ID de la tâche
*/
async function listMailsForTask(taskId: number): Promise<MailMessageHeaderDto[]> {
return api.get<MailMessageHeaderDto[]>(`/tasks/${taskId}/mails`)
}
// ─── Pièces jointes ───────────────────────────────────────────────────────
/**
* Télécharge une pièce jointe et retourne le Blob + headers.
* Content-Disposition: attachment est géré côté backend (jamais inline).
* @param downloadId - Identifiant opaque retourné dans MailAttachmentDto.downloadId
*/
async function downloadAttachment(
downloadId: string,
): Promise<{ data: Blob; headers: Headers }> {
return api.getBlob(`/mail/attachments/${downloadId}`)
}
// ─── Synchronisation ─────────────────────────────────────────────────────
/**
* Déclenche une synchronisation IMAP asynchrone via Symfony Messenger.
* Retourne immédiatement ({ dispatched: true }) — la sync se fait en arrière-plan.
*/
async function triggerSync(): Promise<MailSyncResultDto> {
return api.post<MailSyncResultDto>('/mail/sync', {}, {
toastSuccessKey: 'mail.sync.dispatched',
})
}
return {
// Config
getConfiguration,
updateConfiguration,
testConfiguration,
// Dossiers
listFolders,
// Messages
listMessages,
getMessage,
// Actions
markRead,
markFlagged,
// Tâches
createTaskFromMail,
linkTask,
unlinkTask,
listMailsForTask,
// Pièces jointes
downloadAttachment,
// Sync
triggerSync,
}
}
-37
View File
@@ -1,37 +0,0 @@
import type { Project, ProjectWrite } from './dto/project'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useProjectService() {
const api = useApi()
async function getAll(params?: { archived?: boolean }): Promise<Project[]> {
const query = params?.archived !== undefined ? `?archived=${params.archived}` : ''
const data = await api.get<HydraCollection<Project>>(`/projects${query}`)
return extractHydraMembers(data)
}
async function getById(id: number): Promise<Project> {
return api.get<Project>(`/projects/${id}`)
}
async function create(payload: ProjectWrite): Promise<Project> {
return api.post<Project>('/projects', payload as Record<string, unknown>, {
toastSuccessKey: 'projects.created',
})
}
async function update(id: number, payload: Partial<ProjectWrite>, options?: { toastSuccessKey?: string }): Promise<Project> {
return api.patch<Project>(`/projects/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: options?.toastSuccessKey ?? 'projects.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/projects/${id}`, {}, {
toastSuccessKey: 'projects.deleted',
})
}
return { getAll, getById, create, update, remove }
}
-21
View File
@@ -1,21 +0,0 @@
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 }
}
-26
View File
@@ -1,26 +0,0 @@
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 }
}
-61
View File
@@ -1,61 +0,0 @@
import type { TaskDocument } from './dto/task-document'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
import { $fetch } from 'ofetch'
export function useTaskDocumentService() {
const api = useApi()
const config = useRuntimeConfig()
const baseURL = config.public.apiBase || '/api'
async function getByTask(taskId: number): Promise<TaskDocument[]> {
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
task: `/api/tasks/${taskId}`,
})
return extractHydraMembers(data)
}
async function uploadWithRelation(relationField: string, relationIri: string, file: File): Promise<TaskDocument> {
const formData = new FormData()
formData.append('file', file)
formData.append(relationField, relationIri)
return $fetch<TaskDocument>(`${baseURL}/task_documents`, {
method: 'POST',
body: formData,
credentials: 'include',
})
}
async function upload(taskId: number, file: File): Promise<TaskDocument> {
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
}
async function linkShare(taskId: number, sharePath: string): Promise<TaskDocument> {
return $fetch<TaskDocument>(`${baseURL}/task_documents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task: `/api/tasks/${taskId}`, sharePath }),
credentials: 'include',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_documents/${id}`, {}, {
toastSuccessKey: 'taskDocuments.deleted',
})
}
function getDownloadUrl(id: number): string {
return `${baseURL}/task_documents/${id}/download`
}
async function getContent(id: number): Promise<string> {
return $fetch<string>(`${baseURL}/task_documents/${id}/download`, {
credentials: 'include',
responseType: 'text',
})
}
return { getByTask, upload, linkShare, remove, getDownloadUrl, getContent }
}
-32
View File
@@ -1,32 +0,0 @@
import type { TaskEffort, TaskEffortWrite } from './dto/task-effort'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useTaskEffortService() {
const api = useApi()
async function getAll(): Promise<TaskEffort[]> {
const data = await api.get<HydraCollection<TaskEffort>>('/task_efforts')
return extractHydraMembers(data)
}
async function create(payload: TaskEffortWrite): Promise<TaskEffort> {
return api.post<TaskEffort>('/task_efforts', payload as Record<string, unknown>, {
toastSuccessKey: 'taskEfforts.created',
})
}
async function update(id: number, payload: Partial<TaskEffortWrite>): Promise<TaskEffort> {
return api.patch<TaskEffort>(`/task_efforts/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'taskEfforts.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_efforts/${id}`, {}, {
toastSuccessKey: 'taskEfforts.deleted',
})
}
return { getAll, create, update, remove }
}
-39
View File
@@ -1,39 +0,0 @@
import type { TaskGroup, TaskGroupWrite } from './dto/task-group'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useTaskGroupService() {
const api = useApi()
async function getAll(): Promise<TaskGroup[]> {
const data = await api.get<HydraCollection<TaskGroup>>('/task_groups')
return extractHydraMembers(data)
}
async function getByProject(projectId: number): Promise<TaskGroup[]> {
const data = await api.get<HydraCollection<TaskGroup>>('/task_groups', {
project: `/api/projects/${projectId}`,
})
return extractHydraMembers(data)
}
async function create(payload: TaskGroupWrite): Promise<TaskGroup> {
return api.post<TaskGroup>('/task_groups', payload as Record<string, unknown>, {
toastSuccessKey: 'taskGroups.created',
})
}
async function update(id: number, payload: Partial<TaskGroupWrite>): Promise<TaskGroup> {
return api.patch<TaskGroup>(`/task_groups/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'taskGroups.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_groups/${id}`, {}, {
toastSuccessKey: 'taskGroups.deleted',
})
}
return { getAll, getByProject, create, update, remove }
}
-32
View File
@@ -1,32 +0,0 @@
import type { TaskPriority, TaskPriorityWrite } from './dto/task-priority'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useTaskPriorityService() {
const api = useApi()
async function getAll(): Promise<TaskPriority[]> {
const data = await api.get<HydraCollection<TaskPriority>>('/task_priorities')
return extractHydraMembers(data)
}
async function create(payload: TaskPriorityWrite): Promise<TaskPriority> {
return api.post<TaskPriority>('/task_priorities', payload as Record<string, unknown>, {
toastSuccessKey: 'taskPriorities.created',
})
}
async function update(id: number, payload: Partial<TaskPriorityWrite>): Promise<TaskPriority> {
return api.patch<TaskPriority>(`/task_priorities/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'taskPriorities.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_priorities/${id}`, {}, {
toastSuccessKey: 'taskPriorities.deleted',
})
}
return { getAll, create, update, remove }
}
-25
View File
@@ -1,25 +0,0 @@
import type { TaskRecurrence, TaskRecurrenceWrite } from './dto/task-recurrence'
export function useTaskRecurrenceService() {
const api = useApi()
async function create(payload: TaskRecurrenceWrite): Promise<TaskRecurrence> {
return api.post<TaskRecurrence>('/task_recurrences', payload as Record<string, unknown>, {
toastSuccessKey: 'taskRecurrence.created',
})
}
async function update(id: number, payload: Partial<TaskRecurrenceWrite>): Promise<TaskRecurrence> {
return api.patch<TaskRecurrence>(`/task_recurrences/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'taskRecurrence.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_recurrences/${id}`, {}, {
toastSuccessKey: 'taskRecurrence.deleted',
})
}
return { create, update, remove }
}
-32
View File
@@ -1,32 +0,0 @@
import type { TaskStatus, TaskStatusWrite } from './dto/task-status'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useTaskStatusService() {
const api = useApi()
async function getAll(): Promise<TaskStatus[]> {
const data = await api.get<HydraCollection<TaskStatus>>('/task_statuses')
return extractHydraMembers(data)
}
async function create(payload: TaskStatusWrite): Promise<TaskStatus> {
return api.post<TaskStatus>('/task_statuses', payload as Record<string, unknown>, {
toastSuccessKey: 'taskStatuses.created',
})
}
async function update(id: number, payload: Partial<TaskStatusWrite>): Promise<TaskStatus> {
return api.patch<TaskStatus>(`/task_statuses/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'taskStatuses.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_statuses/${id}`, {}, {
toastSuccessKey: 'taskStatuses.deleted',
})
}
return { getAll, create, update, remove }
}
-32
View File
@@ -1,32 +0,0 @@
import type { TaskTag, TaskTagWrite } from './dto/task-tag'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useTaskTagService() {
const api = useApi()
async function getAll(): Promise<TaskTag[]> {
const data = await api.get<HydraCollection<TaskTag>>('/task_tags')
return extractHydraMembers(data)
}
async function create(payload: TaskTagWrite): Promise<TaskTag> {
return api.post<TaskTag>('/task_tags', payload as Record<string, unknown>, {
toastSuccessKey: 'taskTags.created',
})
}
async function update(id: number, payload: Partial<TaskTagWrite>): Promise<TaskTag> {
return api.patch<TaskTag>(`/task_tags/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'taskTags.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_tags/${id}`, {}, {
toastSuccessKey: 'taskTags.deleted',
})
}
return { getAll, create, update, remove }
}
-45
View File
@@ -1,45 +0,0 @@
import type { Task, TaskWrite } from './dto/task'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useTaskService() {
const api = useApi()
async function getAll(): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks')
return extractHydraMembers(data)
}
async function getByProject(projectId: number, archived = false): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks', {
project: `/api/projects/${projectId}`,
archived,
})
return extractHydraMembers(data)
}
async function getFiltered(params: Record<string, string | number | boolean | string[]>): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks', params as Record<string, unknown>)
return extractHydraMembers(data)
}
async function create(payload: TaskWrite): Promise<Task> {
return api.post<Task>('/tasks', payload as Record<string, unknown>, {
toastSuccessKey: 'tasks.created',
})
}
async function update(id: number, payload: Partial<TaskWrite>): Promise<Task> {
return api.patch<Task>(`/tasks/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'tasks.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/tasks/${id}`, {}, {
toastSuccessKey: 'tasks.deleted',
})
}
return { getAll, getByProject, getFiltered, create, update, remove }
}
-101
View File
@@ -1,101 +0,0 @@
import type { TimeEntry, TimeEntryWrite } from './dto/time-entry'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useTimeEntryService() {
const api = useApi()
async function getByDateRange(params: {
after: string
before: string
user?: number
project?: number
tag?: number
}): Promise<TimeEntry[]> {
const query: Record<string, unknown> = {
'startedAt[after]': params.after,
'startedAt[before]': params.before,
}
if (params.user) {
query.user = `/api/users/${params.user}`
}
if (params.project) {
query.project = `/api/projects/${params.project}`
}
if (params.tag) {
query['tags[]'] = `/api/task_tags/${params.tag}`
}
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries/range', query)
return extractHydraMembers(data)
}
async function getActive(): Promise<TimeEntry | null> {
try {
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries/active', {}, { toast: false })
const members = extractHydraMembers(data)
return members[0] ?? null
} catch {
return null
}
}
async function create(payload: TimeEntryWrite): Promise<TimeEntry> {
return api.post<TimeEntry>('/time_entries', payload as Record<string, unknown>, {
toastSuccessKey: 'timeEntries.created',
})
}
async function update(id: number, payload: Partial<TimeEntryWrite>): Promise<TimeEntry> {
return api.patch<TimeEntry>(`/time_entries/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'timeEntries.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/time_entries/${id}`, {}, {
toastSuccessKey: 'timeEntries.deleted',
})
}
function getExportUrl(params: {
after: string
before: string
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}): string {
const query = new URLSearchParams()
query.set('after', params.after)
query.set('before', params.before)
if (params.users?.length) {
params.users.forEach(id => query.append('users[]', String(id)))
}
if (params.client) query.set('client', String(params.client))
if (params.projects?.length) {
params.projects.forEach(id => query.append('projects[]', String(id)))
}
if (params.tags?.length) {
params.tags.forEach(id => query.append('tags[]', String(id)))
}
return `/time_entries/export?${query.toString()}`
}
async function downloadExport(params: {
after: string
before: string
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}): Promise<{ blob: Blob; filename: string }> {
const url = getExportUrl(params)
const response = await api.getBlob(url)
const disposition = response.headers.get('content-disposition') ?? ''
const filenameMatch = disposition.match(/filename="?([^";\n]+)"?/)
const filename = filenameMatch?.[1] ?? `export-temps-${params.after}_${params.before}.xlsx`
return { blob: response.data, filename }
}
return { getByDateRange, getActive, create, update, remove, getExportUrl, downloadExport }
}
+5 -1
View File
@@ -10,6 +10,10 @@ export function useUserService() {
return extractHydraMembers(data)
}
async function getById(id: number): Promise<UserData> {
return api.get<UserData>(`/users/${id}`)
}
async function create(payload: UserWrite): Promise<UserData> {
return api.post<UserData>('/users', payload as Record<string, unknown>, {
toastSuccessKey: 'users.created',
@@ -28,5 +32,5 @@ export function useUserService() {
})
}
return { getAll, create, update, remove }
return { getAll, getById, create, update, remove }
}
-55
View File
@@ -1,55 +0,0 @@
import type { Workflow, WorkflowWrite } from './dto/workflow'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
type SwitchPayload = {
workflowId: number
mapping: Record<string, number | null>
}
type SwitchResult = {
projectId: number
workflowId: number
migratedTaskCount: number
}
export function useWorkflowService() {
const api = useApi()
async function getAll(): Promise<Workflow[]> {
const data = await api.get<HydraCollection<Workflow>>('/workflows')
return extractHydraMembers(data)
}
async function getOne(id: number): Promise<Workflow> {
return api.get<Workflow>(`/workflows/${id}`)
}
async function create(payload: WorkflowWrite): Promise<Workflow> {
return api.post<Workflow>('/workflows', payload as Record<string, unknown>, {
toastSuccessKey: 'workflows.created',
})
}
async function update(id: number, payload: Partial<WorkflowWrite>): Promise<Workflow> {
return api.patch<Workflow>(`/workflows/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'workflows.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/workflows/${id}`, {}, {
toastSuccessKey: 'workflows.deleted',
})
}
async function switchOnProject(projectId: number, payload: SwitchPayload): Promise<SwitchResult> {
return api.post<SwitchResult>(
`/projects/${projectId}/switch-workflow`,
payload as unknown as Record<string, unknown>,
{ toastSuccessKey: 'workflows.switched' },
)
}
return { getAll, getOne, create, update, remove, switchOnProject }
}
-21
View File
@@ -1,21 +0,0 @@
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 }
}