refactor(client-portal) : remove client portal feature entirely
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m11s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m17s

- drop ClientPortal module, ClientTicket entity, ROLE_CLIENT and all couplings (Task, TaskDocument, User, Notification) back to an internal-only model

- migration drops client_ticket / user_allowed_projects / related FK columns and removes leftover external client accounts (would otherwise be promoted to ROLE_USER)

- remove client-portal frontend module, admin tickets tab, user portal section, portal nav item and portal/clientTicket i18n keys

- fix directory nav icon (invalid mdi:contact-multiple-outline -> mdi:card-account-details-outline)

- add 'make sync-permissions' target, wire it into install/db-reset and the prod deploy script
This commit is contained in:
Matthieu
2026-06-22 09:49:44 +02:00
parent 8a5b115ccd
commit a18e1f575f
55 changed files with 170 additions and 2599 deletions
-2
View File
@@ -8,7 +8,6 @@ declare(strict_types=1);
*/
use App\Module\Absence\AbsenceModule;
use App\Module\ClientPortal\ClientPortalModule;
use App\Module\Core\CoreModule;
use App\Module\Directory\DirectoryModule;
use App\Module\Integration\IntegrationModule;
@@ -26,5 +25,4 @@ return [
MailModule::class,
IntegrationModule::class,
ReportingModule::class,
ClientPortalModule::class,
];
-6
View File
@@ -26,7 +26,6 @@ doctrine:
App\Shared\Domain\Contract\TaskInterface: App\Module\ProjectManagement\Domain\Entity\Task
App\Shared\Domain\Contract\TaskTagInterface: App\Module\ProjectManagement\Domain\Entity\TaskTag
App\Shared\Domain\Contract\ClientInterface: App\Module\Directory\Domain\Entity\Client
App\Shared\Domain\Contract\ClientTicketInterface: App\Module\ClientPortal\Domain\Entity\ClientTicket
mappings:
Core:
type: attribute
@@ -63,11 +62,6 @@ doctrine:
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
prefix: 'App\Module\Integration\Domain\Entity'
ClientPortal:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/ClientPortal/Domain/Entity'
prefix: 'App\Module\ClientPortal\Domain\Entity'
controller_resolver:
auto_mapping: false
+1 -1
View File
@@ -1,6 +1,6 @@
security:
role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
ROLE_ADMIN: [ROLE_USER]
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
-2
View File
@@ -111,8 +111,6 @@ services:
App\Module\Directory\Domain\Repository\ClientRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineClientRepository'
App\Module\ClientPortal\Domain\Repository\ClientTicketRepositoryInterface: '@App\Module\ClientPortal\Infrastructure\Doctrine\DoctrineClientTicketRepository'
App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository'
App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailConfigurationRepository'
+1 -2
View File
@@ -25,7 +25,6 @@ return [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management'],
['label' => 'sidebar.general.portal', 'to' => '/portal', 'icon' => 'mdi:account-box-outline', 'module' => 'client-portal'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'],
// Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout.
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
@@ -37,7 +36,7 @@ return [
'roles' => ['ROLE_ADMIN'],
'items' => [
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:contact-multiple-outline', 'module' => 'directory'],
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
],
-16
View File
@@ -14,22 +14,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
return navigateTo('/')
}
// Cloisonnement portail client : un utilisateur ROLE_CLIENT "pur" (a ROLE_CLIENT
// mais PAS ROLE_USER) n'a accès qu'aux pages /portal. Toute autre route interne
// est redirigée vers /portal. Les ROLE_ADMIN / ROLE_USER ne sont pas concernés
// (ils peuvent aussi visiter /portal pour prévisualiser).
if (auth.isAuthenticated && !isLogin) {
const roles = auth.user?.roles ?? []
const isPureClient = roles.includes('ROLE_CLIENT') && !roles.includes('ROLE_USER')
if (isPureClient) {
const isPortalRoute = to.path === '/portal' || to.path.startsWith('/portal/')
if (!isPortalRoute) {
return navigateTo('/portal')
}
}
}
const { loaded: sidebarLoaded, loadSidebar, resetSidebar } = useSidebar()
const { loaded: modulesLoaded, loadModules, resetModules } = useModules()
-12
View File
@@ -1,12 +0,0 @@
/**
* Named middleware for portal pages (`/portal/**`).
* Ensures the user is authenticated. Access is open to every authenticated user
* (ROLE_CLIENT see their portal, ROLE_ADMIN/ROLE_USER may preview it).
*/
export default defineNuxtRouteMiddleware(() => {
const auth = useAuthStore()
if (!auth.isAuthenticated) {
return navigateTo('/login')
}
})
@@ -1,256 +0,0 @@
<template>
<div>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.adminTitle') }}</h2>
</div>
<!-- Filtres -->
<div class="mt-4 flex flex-wrap gap-3">
<div class="[&>div]:!mt-0">
<MalioSelect
v-model="filters.project"
:options="projectOptions"
:label="$t('clientTicket.filterProject')"
group-class="!w-48"
empty-option-label=""
@update:model-value="load"
/>
</div>
<div class="[&>div]:!mt-0">
<MalioSelect
v-model="filters.status"
:options="statusOptions"
:label="$t('clientTicket.filterStatus')"
group-class="!w-48"
empty-option-label=""
@update:model-value="load"
/>
</div>
</div>
<DataTable
class="mt-4"
:columns="columns"
:items="tickets"
:loading="isLoading"
:empty-message="$t('portal.noTickets')"
@row-click="openDetail"
>
<template #cell-number="{ item }">
<span class="font-mono font-semibold text-primary-500">
{{ formatTicketNumber((item as ClientTicket).number) }}
</span>
</template>
<template #cell-type="{ item }">
<ClientTicketTypeBadge :type="(item as ClientTicket).type" />
</template>
<template #cell-status="{ item }">
<ClientTicketStatusBadge :status="(item as ClientTicket).status" />
</template>
<template #cell-project="{ item }">
{{ projectName((item as ClientTicket).project) }}
</template>
<template #cell-submittedBy="{ item }">
{{ submitterName((item as ClientTicket).submittedBy) }}
</template>
<template #cell-createdAt="{ item }">
{{ formatDate((item as ClientTicket).createdAt) }}
</template>
<template #cell-actions="{ item }">
<MalioButton
:label="$t('clientTicket.changeStatus')"
button-class="w-auto px-3 py-1 text-xs"
@click.stop="openStatus(item as ClientTicket)"
/>
</template>
</DataTable>
<!-- Detail modal (read-only + documents) -->
<ClientTicketDetailModal
v-model="detailOpen"
:ticket="selectedTicket"
/>
<!-- Status change modal -->
<AppModal v-model="statusOpen" :title="$t('clientTicket.changeStatus')" width="md">
<div v-if="statusTicket" class="flex flex-col gap-4">
<p class="text-sm text-neutral-500">
{{ formatTicketNumber(statusTicket.number) }} {{ statusTicket.title }}
</p>
<MalioSelect
v-model="statusForm.status"
:options="statusEditOptions"
:label="$t('clientTicket.status.label')"
group-class="w-full"
/>
<MalioInputTextArea
v-model="statusForm.statusComment"
:label="$t('clientTicket.statusComment')"
:rows="3"
input-class="w-full"
:error="statusError"
/>
</div>
<template #footer>
<MalioButton
:label="$t('common.cancel')"
button-class="w-auto px-4"
variant="secondary"
@click="statusOpen = false"
/>
<MalioButton
:label="$t('common.save')"
button-class="w-auto px-6"
:disabled="isSavingStatus"
@click="saveStatus"
/>
</template>
</AppModal>
</div>
</template>
<script setup lang="ts">
import type {
ClientTicket,
ClientTicketStatus,
} from '~/modules/client-portal/services/dto/client-ticket'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { UserData } from '~/services/dto/user-data'
import { useClientTicketService } from '~/modules/client-portal/services/client-tickets'
import { useProjectService } from '~/modules/project-management/services/projects'
import { useUserService } from '~/services/users'
import { formatTicketNumber, iriToId } from '~/modules/client-portal/utils/ticket'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n()
const { getAll, getById, updateStatus } = useClientTicketService()
const { getAll: getProjects } = useProjectService()
const { getAll: getUsers } = useUserService()
const columns: DataTableColumn[] = [
{ key: 'number', label: 'N°', primary: true },
{ key: 'type', label: t('clientTicket.typeLabel') },
{ key: 'title', label: t('clientTicket.title') },
{ key: 'status', label: t('clientTicket.status.label') },
{ key: 'project', label: t('clientTicket.project') },
{ key: 'submittedBy', label: t('clientTicket.submittedBy') },
{ key: 'createdAt', label: t('clientTicket.date') },
{ key: 'actions', label: '' },
]
const STATUSES: ClientTicketStatus[] = ['new', 'in_progress', 'done', 'rejected']
const statusOptions = computed(() =>
STATUSES.map((s) => ({ label: t(`clientTicket.status.${s}`), value: s })),
)
const statusEditOptions = statusOptions
const tickets = ref<ClientTicket[]>([])
const projects = ref<Project[]>([])
const users = ref<UserData[]>([])
const isLoading = ref(true)
const filters = reactive({
project: null as string | null,
status: null as string | null,
})
const projectOptions = computed(() =>
projects.value.map((p) => ({
label: p.name,
value: p['@id'] ?? `/api/projects/${p.id}`,
})),
)
function projectName(iri: string): string {
const id = iriToId(iri)
return projects.value.find((p) => p.id === id)?.name ?? '—'
}
function submitterName(iri: string | null): string {
if (!iri) return '—'
const id = iriToId(iri)
return users.value.find((u) => u.id === id)?.username ?? '—'
}
function formatDate(value: string): string {
return new Date(value).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
// Detail modal
const detailOpen = ref(false)
const selectedTicket = ref<ClientTicket | null>(null)
async function openDetail(ticket: ClientTicket) {
try {
selectedTicket.value = await getById(ticket.id)
} catch {
selectedTicket.value = ticket
}
detailOpen.value = true
}
// Status modal
const statusOpen = ref(false)
const statusTicket = ref<ClientTicket | null>(null)
const isSavingStatus = ref(false)
const statusForm = reactive({
status: 'new' as ClientTicketStatus,
statusComment: '',
})
const statusError = computed(() => {
if (statusForm.status === 'rejected' && !statusForm.statusComment.trim()) {
return t('clientTicket.rejectionRequired')
}
return ''
})
function openStatus(ticket: ClientTicket) {
statusTicket.value = ticket
statusForm.status = ticket.status
statusForm.statusComment = ticket.statusComment ?? ''
statusOpen.value = true
}
async function saveStatus() {
if (!statusTicket.value) return
if (statusForm.status === 'rejected' && !statusForm.statusComment.trim()) {
return
}
isSavingStatus.value = true
try {
await updateStatus(statusTicket.value.id, {
status: statusForm.status,
statusComment: statusForm.statusComment.trim() || null,
})
statusOpen.value = false
await load()
} finally {
isSavingStatus.value = false
}
}
async function load() {
isLoading.value = true
try {
tickets.value = await getAll({
project: filters.project ?? undefined,
status: filters.status ?? undefined,
})
} finally {
isLoading.value = false
}
}
onMounted(async () => {
const [proj, usr] = await Promise.all([getProjects(), getUsers()])
projects.value = proj
users.value = usr
await load()
})
</script>
@@ -102,22 +102,11 @@ function toggleDropdown() {
}
}
const auth = useAuthStore()
const isAdmin = computed(() => (auth.user?.roles ?? []).includes('ROLE_ADMIN'))
function handleClick(notif: Notification) {
if (!notif.isRead) {
markAsRead(notif.id)
}
isOpen.value = false
// Deep-link to the related ticket when present. The notification payload does
// not carry the ticket's project, so we route to the relevant list view:
// admins to the client-tickets admin tab, clients to their portal.
if (notif.relatedTicket) {
navigateTo(isAdmin.value ? '/admin' : '/portal')
}
}
async function handleMarkAllRead() {
+1 -75
View File
@@ -48,26 +48,6 @@
</div>
</div>
<!-- Compte client (portail) -->
<div v-if="isClient" class="mt-6 border-t border-neutral-200 pt-4">
<p class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('users.clientAccount') }}</p>
<MalioSelect
v-model="form.client"
:options="clientOptions"
:label="$t('users.client')"
group-class="w-full"
empty-option-label=""
/>
<div class="mt-3">
<MalioSelectCheckbox
v-model="form.allowedProjects"
:options="projectOptions"
:label="$t('users.allowedProjects')"
group-class="w-full"
/>
</div>
</div>
<!-- RH / Absences -->
<div class="mt-6 border-t border-neutral-200 pt-4">
<MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
@@ -91,8 +71,6 @@
<script setup lang="ts">
import type { UserData, UserWrite } from '~/services/dto/user-data'
import { useUserService } from '~/services/users'
import { useClientService } from '~/modules/directory/services/clients'
import { useProjectService } from '~/modules/project-management/services/projects'
const props = defineProps<{
modelValue: boolean
@@ -109,7 +87,7 @@ const isOpen = computed({
set: (v) => emit('update:modelValue', v),
})
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_CLIENT']
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER']
const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false)
@@ -121,52 +99,15 @@ const form = reactive({
password: '',
roles: [] as string[],
isEmployee: false,
client: null as string | null,
allowedProjects: [] as string[],
})
const isClient = computed(() => form.roles.includes('ROLE_CLIENT'))
const touched = reactive({
username: false,
password: false,
})
const clientOptions = ref<{ label: string, value: string | null }[]>([])
const projectOptions = ref<{ label: string, value: string }[]>([])
const { getAll: getClients } = useClientService()
const { getAll: getProjects } = useProjectService()
const { create, update, getById } = useUserService()
async function loadOptions() {
if (clientOptions.value.length && projectOptions.value.length) {
return
}
const [clients, projects] = await Promise.all([getClients(), getProjects()])
clientOptions.value = clients.map((c) => ({
label: c.name,
value: c['@id'] ?? `/api/clients/${c.id}`,
}))
projectOptions.value = projects.map((p) => ({
label: p.name,
value: p['@id'] ?? `/api/projects/${p.id}`,
}))
}
/** Normalize allowedProjects (embedded objects or bare IRIs) into IRI strings. */
function toProjectIris(allowed: UserData['allowedProjects'] | undefined): string[] {
if (!allowed) {
return []
}
return allowed.map((p) => {
if (typeof p === 'string') {
return p
}
return p['@id'] ?? `/api/projects/${p.id}`
})
}
function applyUser(user: UserData) {
form.username = user.username ?? ''
form.firstName = user.firstName ?? ''
@@ -174,8 +115,6 @@ function applyUser(user: UserData) {
form.password = ''
form.roles = [...user.roles]
form.isEmployee = user.isEmployee ?? false
form.client = user.client ?? null
form.allowedProjects = toProjectIris(user.allowedProjects)
}
watch(() => props.modelValue, async (open) => {
@@ -185,10 +124,8 @@ watch(() => props.modelValue, async (open) => {
touched.username = false
touched.password = false
await loadOptions()
if (props.item) {
// The list payload (user:list) omits client / allowedProjects → fetch the full item.
applyUser(props.item)
try {
const full = await getById(props.item.id)
@@ -203,8 +140,6 @@ watch(() => props.modelValue, async (open) => {
form.password = ''
form.roles = ['ROLE_USER']
form.isEmployee = false
form.client = null
form.allowedProjects = []
}
})
@@ -227,15 +162,6 @@ async function handleSubmit() {
payload.plainPassword = form.password
}
// Client portal fields: only relevant when the user is a client.
if (isClient.value) {
payload.client = form.client
payload.allowedProjects = form.allowedProjects
} else {
payload.client = null
payload.allowedProjects = []
}
if (isEditing.value && props.item) {
await update(props.item.id, payload)
} else {
+2 -50
View File
@@ -193,10 +193,7 @@
"updated": "Utilisateur mis à jour avec succès.",
"deleted": "Utilisateur supprimé avec succès.",
"addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur",
"clientAccount": "Compte client (portail)",
"client": "Client",
"allowedProjects": "Projets autorisés"
"editUser": "Modifier un utilisateur"
},
"admin": {
"roles": {
@@ -357,8 +354,7 @@
"myTasks": "Mes tâches",
"projects": "Projets",
"timeTracking": "Suivi de temps",
"mail": "Messagerie",
"portal": "Portail client"
"mail": "Messagerie"
},
"admin": {
"section": "Administration",
@@ -949,49 +945,5 @@
"empty": "Aucun prospect trouvé.",
"allStatuses": "Tous les statuts"
}
},
"portal": {
"title": "Portail client",
"projects": "Mes projets",
"openTickets": "tickets ouverts",
"newTicket": "Nouveau ticket",
"ticketDetail": "Détail du ticket",
"noProjects": "Aucun projet accessible.",
"noTickets": "Aucun ticket pour le moment."
},
"clientTicket": {
"typeLabel": "Type",
"type": {
"bug": "Bug",
"improvement": "Amélioration",
"other": "Autre"
},
"status": {
"label": "Statut",
"new": "Nouveau",
"in_progress": "En cours",
"done": "Terminé",
"rejected": "Rejeté"
},
"title": "Titre",
"titleRequired": "Le titre est requis",
"description": "Description",
"descriptionRequired": "La description est requise",
"url": "URL (page concernée)",
"statusComment": "Commentaire de statut",
"project": "Projet",
"submittedBy": "Soumis par",
"date": "Date",
"created": "Ticket créé",
"statusChanged": "Statut mis à jour",
"confirmDelete": "Supprimer ce ticket ?",
"deleted": "Ticket supprimé",
"linkedTooltip": "Lié au ticket client {number}",
"rejectionRequired": "Un commentaire est requis pour rejeter un ticket",
"changeStatus": "Changer le statut",
"adminTab": "Tickets client",
"adminTitle": "Tickets client",
"filterProject": "Projet",
"filterStatus": "Statut"
}
}
@@ -1,132 +0,0 @@
<template>
<AppModal
:model-value="modelValue"
width="lg"
@update:model-value="$emit('update:modelValue', $event)"
>
<template #title>
<span v-if="ticket" class="flex items-center gap-2">
<span class="font-mono text-primary-500">{{ formatTicketNumber(ticket.number) }}</span>
<ClientTicketTypeBadge :type="ticket.type" />
</span>
<span v-else>{{ $t('portal.ticketDetail') }}</span>
</template>
<div v-if="ticket" class="flex flex-col gap-4">
<div class="flex items-start justify-between gap-3">
<h3 class="text-lg font-bold text-neutral-900">{{ ticket.title }}</h3>
<ClientTicketStatusBadge :status="ticket.status" />
</div>
<div>
<p class="mb-1 text-xs font-semibold uppercase text-neutral-400">
{{ $t('clientTicket.description') }}
</p>
<p class="whitespace-pre-wrap text-sm text-neutral-700">{{ ticket.description }}</p>
</div>
<div v-if="ticket.url">
<p class="mb-1 text-xs font-semibold uppercase text-neutral-400">
{{ $t('clientTicket.url') }}
</p>
<a
:href="ticket.url"
target="_blank"
rel="noopener noreferrer"
class="break-all text-sm text-blue-600 hover:underline"
>{{ ticket.url }}</a>
</div>
<div v-if="ticket.statusComment">
<p class="mb-1 text-xs font-semibold uppercase text-neutral-400">
{{ $t('clientTicket.statusComment') }}
</p>
<p class="whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-700">
{{ ticket.statusComment }}
</p>
</div>
<div class="text-xs text-neutral-400">
{{ $t('clientTicket.created') }} : {{ formatDate(ticket.createdAt) }}
</div>
<!-- Documents -->
<div class="border-t border-neutral-100 pt-2">
<div v-if="loadingDocs" class="flex justify-center py-4">
<Icon name="heroicons:arrow-path" class="h-5 w-5 animate-spin text-neutral-400" />
</div>
<TaskDocumentList
v-else
:documents="documents"
:is-admin="false"
@preview="previewDoc = $event"
/>
</div>
</div>
<TaskDocumentPreview
:document="previewDoc"
:has-prev="false"
:has-next="false"
@close="previewDoc = null"
/>
</AppModal>
</template>
<script setup lang="ts">
import type { ClientTicket } from '~/modules/client-portal/services/dto/client-ticket'
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import { formatTicketNumber } from '~/modules/client-portal/utils/ticket'
const props = defineProps<{
modelValue: boolean
ticket: ClientTicket | null
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
const { getByClientTicket } = useTaskDocumentService()
const documents = ref<TaskDocument[]>([])
const loadingDocs = ref(false)
const previewDoc = ref<TaskDocument | null>(null)
function formatDate(value: string): string {
return new Date(value).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
async function loadDocuments(ticketId: number) {
loadingDocs.value = true
try {
documents.value = await getByClientTicket(ticketId)
} catch {
documents.value = []
} finally {
loadingDocs.value = false
}
}
watch(
() => [props.modelValue, props.ticket?.id] as const,
([open, id]) => {
previewDoc.value = null
if (open && id) {
// Prefer documents embedded in the ticket, fall back to a dedicated fetch.
if (props.ticket?.documents?.length) {
documents.value = props.ticket.documents
} else {
documents.value = []
loadDocuments(id)
}
}
},
{ immediate: true },
)
</script>
@@ -1,158 +0,0 @@
<template>
<AppModal
:model-value="modelValue"
width="lg"
:title="$t('portal.newTicket')"
@update:model-value="onModalUpdate"
>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<MalioSelect
v-model="form.type"
:options="typeOptions"
:label="$t('clientTicket.typeLabel')"
group-class="w-full"
/>
<MalioInputText
v-model="form.title"
:label="$t('clientTicket.title')"
input-class="w-full"
:error="touched.title && !form.title.trim() ? $t('clientTicket.titleRequired') : ''"
@blur="touched.title = true"
/>
<MalioInputTextArea
v-model="form.description"
:label="$t('clientTicket.description')"
:rows="5"
input-class="w-full"
:error="touched.description && !form.description.trim() ? $t('clientTicket.descriptionRequired') : ''"
@blur="touched.description = true"
/>
<MalioInputText
v-if="form.type === 'bug'"
v-model="form.url"
:label="$t('clientTicket.url')"
input-class="w-full"
/>
<!-- Documents : uploadable only once the ticket exists -->
<div v-if="createdTicketId">
<p class="text-sm font-semibold text-neutral-700">{{ $t('taskDocuments.title') }}</p>
<TaskDocumentUpload :client-ticket-id="createdTicketId" />
</div>
</form>
<template #footer>
<MalioButton
v-if="!createdTicketId"
:label="$t('common.submit')"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
<MalioButton
v-else
:label="$t('common.close')"
button-class="w-auto px-6"
@click="finish"
/>
</template>
</AppModal>
</template>
<script setup lang="ts">
import type {
ClientTicketCreate,
ClientTicketType,
} from '~/modules/client-portal/services/dto/client-ticket'
import { useClientTicketService } from '~/modules/client-portal/services/client-tickets'
const props = defineProps<{
modelValue: boolean
projectIri: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
created: []
}>()
const { t } = useI18n()
const { create } = useClientTicketService()
const typeOptions = computed(() => ([
{ label: t('clientTicket.type.bug'), value: 'bug' },
{ label: t('clientTicket.type.improvement'), value: 'improvement' },
{ label: t('clientTicket.type.other'), value: 'other' },
]))
const isSubmitting = ref(false)
const createdTicketId = ref<number | null>(null)
const form = reactive({
type: 'bug' as ClientTicketType,
title: '',
description: '',
url: '',
})
const touched = reactive({
title: false,
description: false,
})
function resetForm() {
form.type = 'bug'
form.title = ''
form.description = ''
form.url = ''
touched.title = false
touched.description = false
createdTicketId.value = null
isSubmitting.value = false
}
watch(() => props.modelValue, (open) => {
if (open) {
resetForm()
}
})
function onModalUpdate(value: boolean) {
emit('update:modelValue', value)
if (!value && createdTicketId.value) {
// A ticket was created before closing → refresh the list.
emit('created')
}
}
function finish() {
emit('update:modelValue', false)
emit('created')
}
async function handleSubmit() {
touched.title = true
touched.description = true
if (!form.title.trim() || !form.description.trim()) {
return
}
isSubmitting.value = true
try {
const payload: ClientTicketCreate = {
type: form.type,
title: form.title.trim(),
description: form.description.trim(),
url: form.type === 'bug' && form.url.trim() ? form.url.trim() : null,
project: props.projectIri,
}
const ticket = await create(payload)
createdTicketId.value = ticket.id
} finally {
isSubmitting.value = false
}
}
</script>
@@ -1,25 +0,0 @@
<template>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold"
:class="classes"
>
{{ $t(`clientTicket.status.${status}`) }}
</span>
</template>
<script setup lang="ts">
import type { ClientTicketStatus } from '~/modules/client-portal/services/dto/client-ticket'
const props = defineProps<{
status: ClientTicketStatus
}>()
const STATUS_CONFIG: Record<ClientTicketStatus, string> = {
new: 'bg-sky-100 text-sky-700',
in_progress: 'bg-amber-100 text-amber-700',
done: 'bg-green-100 text-green-700',
rejected: 'bg-neutral-200 text-neutral-600',
}
const classes = computed(() => STATUS_CONFIG[props.status] ?? STATUS_CONFIG.new)
</script>
@@ -1,25 +0,0 @@
<template>
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
:class="config.classes"
>
<Icon :name="config.icon" class="h-3 w-3" />
{{ $t(`clientTicket.type.${type}`) }}
</span>
</template>
<script setup lang="ts">
import type { ClientTicketType } from '~/modules/client-portal/services/dto/client-ticket'
const props = defineProps<{
type: ClientTicketType
}>()
const TYPE_CONFIG: Record<ClientTicketType, { icon: string, classes: string }> = {
bug: { icon: 'mdi:bug-outline', classes: 'bg-red-100 text-red-700' },
improvement: { icon: 'mdi:lightbulb-on-outline', classes: 'bg-blue-100 text-blue-700' },
other: { icon: 'mdi:dots-horizontal-circle-outline', classes: 'bg-neutral-100 text-neutral-700' },
}
const config = computed(() => TYPE_CONFIG[props.type] ?? TYPE_CONFIG.other)
</script>
@@ -1 +0,0 @@
export default defineNuxtConfig({})
@@ -1,93 +0,0 @@
<template>
<div class="flex flex-col gap-6">
<div>
<h1 class="text-2xl font-bold text-neutral-900">{{ $t('portal.title') }}</h1>
<p class="mt-1 text-sm text-neutral-500">{{ $t('portal.projects') }}</p>
</div>
<div v-if="isLoading" class="flex justify-center py-16">
<Icon name="heroicons:arrow-path" class="h-7 w-7 animate-spin text-neutral-400" />
</div>
<div v-else-if="!projects.length" class="rounded-xl border border-dashed border-neutral-300 py-16 text-center text-neutral-400">
{{ $t('portal.noProjects') }}
</div>
<div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="project in projects"
:key="project.id"
:to="`/portal/projects/${project.id}`"
class="group flex flex-col gap-3 rounded-xl border border-neutral-200 bg-white p-5 shadow-sm transition hover:border-primary-300 hover:shadow-md"
>
<div class="flex items-start justify-between gap-2">
<h2 class="text-lg font-bold text-neutral-900 group-hover:text-primary-500">
{{ project.name }}
</h2>
<Icon name="mdi:folder-outline" class="h-6 w-6 text-neutral-300" />
</div>
<div class="mt-auto flex items-center gap-1.5 text-sm text-neutral-500">
<span class="text-base font-bold text-primary-500">{{ openCount(project.id) }}</span>
{{ $t('portal.openTickets') }}
</div>
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
import type { AllowedProject } from '~/services/dto/user-data'
import { useClientTicketService } from '~/modules/client-portal/services/client-tickets'
import { useProjectService } from '~/modules/project-management/services/projects'
import { iriToId } from '~/modules/client-portal/utils/ticket'
definePageMeta({ middleware: ['portal'] })
useHead({ title: 'Portail client' })
const auth = useAuthStore()
const { getAll: getTickets } = useClientTicketService()
const { getAll: getProjects } = useProjectService()
const isLoading = ref(true)
const projects = ref<AllowedProject[]>([])
const openCounts = ref<Record<number, number>>({})
function openCount(projectId: number): number {
return openCounts.value[projectId] ?? 0
}
async function load() {
isLoading.value = true
try {
// Client users get their projects from `/me` (allowedProjects, embedded id + name).
// Internal users (admin/user) previewing the portal fall back to the full project list.
const allowed = auth.user?.allowedProjects ?? []
if (allowed.length) {
projects.value = allowed
} else if (auth.user?.roles?.some((r) => r === 'ROLE_ADMIN' || r === 'ROLE_USER')) {
const all = await getProjects({ archived: false })
projects.value = all.map((p) => ({ id: p.id, name: p.name, '@id': p['@id'] }))
} else {
projects.value = []
}
// Count open tickets (status new / in_progress) per project.
const tickets = await getTickets()
const counts: Record<number, number> = {}
for (const ticket of tickets) {
if (ticket.status !== 'new' && ticket.status !== 'in_progress') {
continue
}
const pid = iriToId(ticket.project)
if (pid !== null) {
counts[pid] = (counts[pid] ?? 0) + 1
}
}
openCounts.value = counts
} finally {
isLoading.value = false
}
}
onMounted(load)
</script>
@@ -1,133 +0,0 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<NuxtLink
to="/portal"
class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 transition hover:bg-neutral-100 hover:text-neutral-600"
>
<Icon name="mdi:arrow-left" class="h-5 w-5" />
</NuxtLink>
<h1 class="text-2xl font-bold text-neutral-900">{{ projectName }}</h1>
</div>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('portal.newTicket')"
@click="formOpen = true"
/>
</div>
<div v-if="isLoading" class="flex justify-center py-16">
<Icon name="heroicons:arrow-path" class="h-7 w-7 animate-spin text-neutral-400" />
</div>
<div v-else-if="!tickets.length" class="rounded-xl border border-dashed border-neutral-300 py-16 text-center text-neutral-400">
{{ $t('portal.noTickets') }}
</div>
<div v-else class="flex flex-col gap-2">
<button
v-for="ticket in tickets"
:key="ticket.id"
type="button"
class="flex flex-wrap items-center gap-3 rounded-xl border border-neutral-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-primary-300 hover:shadow-md"
@click="openDetail(ticket)"
>
<span class="font-mono text-sm font-semibold text-primary-500">
{{ formatTicketNumber(ticket.number) }}
</span>
<ClientTicketTypeBadge :type="ticket.type" />
<span class="min-w-0 flex-1 truncate font-semibold text-neutral-900">{{ ticket.title }}</span>
<ClientTicketStatusBadge :status="ticket.status" />
<span class="text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</span>
</button>
</div>
<ClientTicketFormModal
v-model="formOpen"
:project-iri="projectIri"
@created="load"
/>
<ClientTicketDetailModal
v-model="detailOpen"
:ticket="selectedTicket"
/>
</div>
</template>
<script setup lang="ts">
import type { ClientTicket } from '~/modules/client-portal/services/dto/client-ticket'
import { useClientTicketService } from '~/modules/client-portal/services/client-tickets'
import { useProjectService } from '~/modules/project-management/services/projects'
import { formatTicketNumber } from '~/modules/client-portal/utils/ticket'
definePageMeta({ middleware: ['portal'] })
const route = useRoute()
const auth = useAuthStore()
const { getAll, getById } = useClientTicketService()
const { getById: getProject } = useProjectService()
const projectId = computed(() => Number(route.params.id))
const projectIri = computed(() => `/api/projects/${projectId.value}`)
const isLoading = ref(true)
const tickets = ref<ClientTicket[]>([])
const projectName = ref('')
const formOpen = ref(false)
const detailOpen = ref(false)
const selectedTicket = ref<ClientTicket | null>(null)
useHead(() => ({ title: projectName.value || 'Portail client' }))
function formatDate(value: string): string {
return new Date(value).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
async function openDetail(ticket: ClientTicket) {
// Re-fetch to get embedded documents (collection items may omit them).
try {
selectedTicket.value = await getById(ticket.id)
} catch {
selectedTicket.value = ticket
}
detailOpen.value = true
}
function resolveProjectName() {
const allowed = auth.user?.allowedProjects ?? []
const match = allowed.find((p) => p.id === projectId.value)
if (match) {
projectName.value = match.name
}
}
async function load() {
isLoading.value = true
try {
resolveProjectName()
tickets.value = await getAll({ project: projectId.value })
// Internal users (admin/user) have no allowedProjects → fetch the name directly.
if (!projectName.value && auth.user?.roles?.some((r) => r === 'ROLE_ADMIN' || r === 'ROLE_USER')) {
try {
const project = await getProject(projectId.value)
projectName.value = project.name
} catch {
projectName.value = ''
}
}
} finally {
isLoading.value = false
}
}
onMounted(load)
</script>
@@ -1,60 +0,0 @@
import type {
ClientTicket,
ClientTicketCreate,
ClientTicketStatusUpdate,
} from './dto/client-ticket'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type ClientTicketFilters = {
project?: number | string
status?: string
submittedBy?: number | string
}
export function useClientTicketService() {
const api = useApi()
async function getAll(params?: ClientTicketFilters): Promise<ClientTicket[]> {
const query: Record<string, unknown> = {}
if (params?.project !== undefined && params.project !== '') {
query.project = typeof params.project === 'number'
? `/api/projects/${params.project}`
: params.project
}
if (params?.status) {
query.status = params.status
}
if (params?.submittedBy !== undefined && params.submittedBy !== '') {
query.submittedBy = typeof params.submittedBy === 'number'
? `/api/users/${params.submittedBy}`
: params.submittedBy
}
const data = await api.get<HydraCollection<ClientTicket>>('/client_tickets', query)
return extractHydraMembers(data)
}
async function getById(id: number): Promise<ClientTicket> {
return api.get<ClientTicket>(`/client_tickets/${id}`)
}
async function create(payload: ClientTicketCreate): Promise<ClientTicket> {
return api.post<ClientTicket>('/client_tickets', payload as Record<string, unknown>, {
toastSuccessKey: 'clientTicket.created',
})
}
async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise<ClientTicket> {
return api.patch<ClientTicket>(`/client_tickets/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'clientTicket.statusChanged',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/client_tickets/${id}`, {}, {
toastSuccessKey: 'clientTicket.deleted',
})
}
return { getAll, getById, create, updateStatus, remove }
}
@@ -1,34 +0,0 @@
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
export type ClientTicketType = 'bug' | 'improvement' | 'other'
export type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected'
export type ClientTicket = {
'@id'?: string
id: number
number: number
type: ClientTicketType
title: string
description: string
url: string | null
status: ClientTicketStatus
statusComment: string | null
project: string // IRI
submittedBy: string | null // IRI, nullable (ON DELETE SET NULL)
createdAt: string
updatedAt: string
documents?: TaskDocument[]
}
export type ClientTicketCreate = {
type: ClientTicketType
title: string
description: string
url?: string | null
project: string // IRI
}
export type ClientTicketStatusUpdate = {
status: ClientTicketStatus
statusComment?: string | null
}
@@ -1,18 +0,0 @@
/**
* Format a client-ticket number for display, e.g. `CT-001`.
*/
export function formatTicketNumber(value: number): string {
return `CT-${String(value).padStart(3, '0')}`
}
/**
* Extract the numeric id from an API Platform IRI (e.g. `/api/projects/5` → 5).
* Returns null when the IRI cannot be parsed.
*/
export function iriToId(iri: string | null | undefined): number | null {
if (!iri) {
return null
}
const match = iri.match(/(\d+)$/)
return match ? Number(match[1]) : null
}
@@ -20,12 +20,6 @@
name="mdi:flag-variant"
class="h-3.5 w-3.5 text-red-600"
/>
<Icon
v-if="task.clientTicket"
name="heroicons:user-circle"
class="h-3.5 w-3.5 text-primary-500"
:title="$t('clientTicket.linkedTooltip', { number: formatTicketNumber(task.clientTicket.number) })"
/>
</div>
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
</div>
@@ -109,7 +103,6 @@
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import { formatTicketNumber } from '~/modules/client-portal/utils/ticket'
const props = withDefaults(defineProps<{
task: Task
@@ -50,14 +50,13 @@ import { useTaskDocumentService } from '~/modules/project-management/services/ta
const props = defineProps<{
taskId?: number
clientTicketId?: number
}>()
const emit = defineEmits<{
uploaded: []
}>()
const { upload: uploadFile, uploadForClientTicket } = useTaskDocumentService()
const { upload: uploadFile } = useTaskDocumentService()
const toast = useToast()
const { t } = useI18n()
@@ -112,8 +111,6 @@ async function processFiles(files: File[]) {
try {
if (props.taskId) {
await uploadFile(props.taskId, file)
} else if (props.clientTicketId) {
await uploadForClientTicket(props.clientTicketId, file)
}
state.uploading = false
state.progress = 100
@@ -28,12 +28,6 @@
name="mdi:flag-variant"
class="h-3.5 w-3.5 text-red-600"
/>
<Icon
v-if="task.clientTicket"
name="heroicons:user-circle"
class="h-3.5 w-3.5 text-primary-500"
:title="$t('clientTicket.linkedTooltip', { number: formatTicketNumber(task.clientTicket.number) })"
/>
</div>
<!-- Row 2: title -->
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
@@ -117,7 +111,6 @@
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import { formatTicketNumber } from '~/modules/client-portal/utils/ticket'
const props = withDefaults(defineProps<{
task: Task
@@ -28,14 +28,6 @@ export type Task = {
deadline: string | null
syncToCalendar: boolean
calendarSyncError: string | null
clientTicket: {
id: number
'@id'?: string
number: number
type: 'bug' | 'improvement' | 'other'
status: 'new' | 'in_progress' | 'done' | 'rejected'
title: string
} | null
recurrence: {
id: number
'@id'?: string
@@ -15,13 +15,6 @@ export function useTaskDocumentService() {
return extractHydraMembers(data)
}
async function getByClientTicket(clientTicketId: number): Promise<TaskDocument[]> {
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
clientTicket: `/api/client_tickets/${clientTicketId}`,
})
return extractHydraMembers(data)
}
async function uploadWithRelation(relationField: string, relationIri: string, file: File): Promise<TaskDocument> {
const formData = new FormData()
formData.append('file', file)
@@ -38,10 +31,6 @@ export function useTaskDocumentService() {
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
}
async function uploadForClientTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
return uploadWithRelation('clientTicket', `/api/client_tickets/${clientTicketId}`, file)
}
async function linkShare(taskId: number, sharePath: string): Promise<TaskDocument> {
return $fetch<TaskDocument>(`${baseURL}/task_documents`, {
method: 'POST',
@@ -69,5 +58,5 @@ export function useTaskDocumentService() {
return response.text()
}
return { getByTask, getByClientTicket, upload, uploadForClientTicket, linkShare, remove, getDownloadUrl, getContent }
return { getByTask, upload, linkShare, remove, getDownloadUrl, getContent }
}
-2
View File
@@ -27,7 +27,6 @@
<AdminPriorityTab v-if="activeTab === 'priorities'" />
<AdminTagTab v-if="activeTab === 'tags'" />
<AdminUserTab v-if="activeTab === 'users'" />
<AdminClientTicketTab v-if="activeTab === 'clientTickets'" />
<AdminRoleTab v-if="activeTab === 'roles' && canViewRoles" />
<AdminAuditTab v-if="activeTab === 'audit' && canViewAudit" />
<AdminGiteaTab v-if="activeTab === 'gitea'" />
@@ -57,7 +56,6 @@ const tabs = [
{ key: 'priorities', label: 'Priorités' },
{ key: 'tags', label: 'Tags' },
{ key: 'users', label: 'Utilisateurs' },
{ key: 'clientTickets', label: t('clientTicket.adminTab') },
{ key: 'roles', label: t('admin.roles.title'), permission: 'core.roles.view' },
{ key: 'audit', label: t('admin.audit.title'), permission: 'core.audit_log.view' },
{ key: 'gitea', label: 'Gitea' },
+1 -2
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
@@ -7,7 +7,6 @@ export type Notification = {
type: NotificationType
title: string
message: string
relatedTicket: string | null
isRead: boolean
createdAt: string
}
-16
View File
@@ -1,15 +1,5 @@
export type ContractType = 'CDI' | 'CDD' | 'STAGE' | 'ALTERNANCE' | 'AUTRE'
/**
* Project as embedded in the `me:read` serialization group (id + name).
* On `/me`, `allowedProjects` is returned as embedded objects, not bare IRIs.
*/
export type AllowedProject = {
'@id'?: string
id: number
name: string
}
export type UserData = {
id: number
'@id'?: string
@@ -20,9 +10,6 @@ export type UserData = {
effectivePermissions?: string[]
avatarUrl?: string | null
apiToken?: string | null
// Client portal
client?: string | null // IRI of the linked Client (null = internal user)
allowedProjects?: AllowedProject[] // Projects a client user can access (embedded id + name)
// HR / absence management
isEmployee?: boolean
hireDate?: string | null
@@ -40,9 +27,6 @@ export type UserWrite = {
lastName?: string | null
plainPassword?: string
roles: string[]
// Client portal
client?: string | null
allowedProjects?: string[]
// HR / absence management
isEmployee?: boolean
hireDate?: string | null
+3
View File
@@ -27,6 +27,9 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Syncing RBAC permissions catalog..."
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
+6 -1
View File
@@ -38,7 +38,7 @@ restart: env-init
$(DOCKER_COMPOSE) down
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate sync-permissions
# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
reset: delete_built_dir remove_orphans build-without-cache start wait install
@@ -77,6 +77,10 @@ build-without-cache:
migration-migrate:
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
# Synchronise le catalogue des permissions RBAC depuis les modules actifs
sync-permissions:
$(SYMFONY_CONSOLE) app:sync-permissions
fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
@@ -87,6 +91,7 @@ db-reset:
$(MAKE) wait
$(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists
$(MAKE) migration-migrate
$(MAKE) sync-permissions
$(MAKE) fixtures
# Restart la bdd
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Remove the client portal entirely (reverses phases 1 & 3).
*
* - Drops notification.related_ticket_id (FK/index/column).
* - Restores task_document to a task-only model: drops the CHECK constraint and
* client_ticket_id, removes orphan client-ticket-only documents, makes task_id
* NOT NULL again.
* - Drops task.client_ticket_id.
* - Drops the user_allowed_projects join table and user.client_id.
* - Drops the client_ticket table.
* - Deletes leftover client accounts (roles contains ROLE_CLIENT): with the portal
* gone every user now resolves to ROLE_USER, so external client accounts MUST be
* removed to avoid silently granting them internal access.
*
* Lowercase SQL columns; "user" table is quoted. down() recreates the schema
* (structure only — deleted client accounts/tickets are not restored).
*/
final class Version20260622090000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remove client portal: drop client_ticket, related portal columns/links and external client accounts';
}
public function up(Schema $schema): void
{
// --- notification.related_ticket_id (phase 3) ---
$this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA98F144DB');
$this->addSql('DROP INDEX idx_notification_related_ticket');
$this->addSql('ALTER TABLE notification DROP related_ticket_id');
// --- task_document: back to task-only ---
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT chk_task_document_target');
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT FK_98A9603A9B2097DD');
$this->addSql('DROP INDEX IDX_98A9603A9B2097DD');
// Remove documents attached only to a client ticket before re-enforcing NOT NULL.
$this->addSql('DELETE FROM task_document WHERE task_id IS NULL');
$this->addSql('ALTER TABLE task_document DROP client_ticket_id');
$this->addSql('ALTER TABLE task_document ALTER task_id SET NOT NULL');
// --- task.client_ticket_id ---
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB259B2097DD');
$this->addSql('DROP INDEX IDX_527EDB259B2097DD');
$this->addSql('ALTER TABLE task DROP client_ticket_id');
// --- user_allowed_projects ---
$this->addSql('ALTER TABLE user_allowed_projects DROP CONSTRAINT FK_B3E0FC97A76ED395');
$this->addSql('ALTER TABLE user_allowed_projects DROP CONSTRAINT FK_B3E0FC97166D1F9C');
$this->addSql('DROP TABLE user_allowed_projects');
// --- user.client_id ---
$this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_8D93D64919EB6921');
$this->addSql('DROP INDEX IDX_8D93D64919EB6921');
$this->addSql('ALTER TABLE "user" DROP client_id');
// --- client_ticket table ---
$this->addSql('ALTER TABLE client_ticket DROP CONSTRAINT FK_C206E610166D1F9C');
$this->addSql('ALTER TABLE client_ticket DROP CONSTRAINT FK_C206E61079F7D87D');
$this->addSql('ALTER TABLE client_ticket DROP CONSTRAINT FK_C206E610DE12AB56');
$this->addSql('ALTER TABLE client_ticket DROP CONSTRAINT FK_C206E61016FE72E1');
$this->addSql('DROP TABLE client_ticket');
// --- external client accounts ---
$this->addSql('DELETE FROM "user" WHERE roles::text LIKE \'%ROLE_CLIENT%\'');
}
public function down(Schema $schema): void
{
// --- client_ticket table ---
$this->addSql('CREATE TABLE client_ticket (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, number INT NOT NULL, type VARCHAR(16) NOT NULL, title VARCHAR(255) NOT NULL, description TEXT NOT NULL, url VARCHAR(1024) DEFAULT NULL, status VARCHAR(16) NOT NULL, status_comment TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, project_id INT NOT NULL, submitted_by_id INT DEFAULT NULL, created_by INT DEFAULT NULL, updated_by INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX uniq_client_ticket_project_number ON client_ticket (project_id, number)');
$this->addSql('CREATE INDEX idx_client_ticket_project ON client_ticket (project_id)');
$this->addSql('CREATE INDEX idx_client_ticket_submitted_by ON client_ticket (submitted_by_id)');
$this->addSql('CREATE INDEX idx_client_ticket_status_project ON client_ticket (status, project_id)');
$this->addSql('CREATE INDEX IDX_C206E610DE12AB56 ON client_ticket (created_by)');
$this->addSql('CREATE INDEX IDX_C206E61016FE72E1 ON client_ticket (updated_by)');
$this->addSql('ALTER TABLE client_ticket ADD CONSTRAINT FK_C206E610166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE client_ticket ADD CONSTRAINT FK_C206E61079F7D87D FOREIGN KEY (submitted_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE client_ticket ADD CONSTRAINT FK_C206E610DE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE client_ticket ADD CONSTRAINT FK_C206E61016FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
// --- user.client_id ---
$this->addSql('ALTER TABLE "user" ADD client_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD CONSTRAINT FK_8D93D64919EB6921 FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_8D93D64919EB6921 ON "user" (client_id)');
// --- user_allowed_projects ---
$this->addSql('CREATE TABLE user_allowed_projects (user_id INT NOT NULL, project_id INT NOT NULL, PRIMARY KEY (user_id, project_id))');
$this->addSql('CREATE INDEX IDX_B3E0FC97A76ED395 ON user_allowed_projects (user_id)');
$this->addSql('CREATE INDEX IDX_B3E0FC97166D1F9C ON user_allowed_projects (project_id)');
$this->addSql('ALTER TABLE user_allowed_projects ADD CONSTRAINT FK_B3E0FC97A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE user_allowed_projects ADD CONSTRAINT FK_B3E0FC97166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE NOT DEFERRABLE');
// --- task.client_ticket_id ---
$this->addSql('ALTER TABLE task ADD client_ticket_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB259B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_527EDB259B2097DD ON task (client_ticket_id)');
// --- task_document generalisation ---
$this->addSql('ALTER TABLE task_document ALTER task_id DROP NOT NULL');
$this->addSql('ALTER TABLE task_document ADD client_ticket_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT FK_98A9603A9B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_98A9603A9B2097DD ON task_document (client_ticket_id)');
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT chk_task_document_target CHECK (task_id IS NOT NULL OR client_ticket_id IS NOT NULL)');
// --- notification.related_ticket_id ---
$this->addSql('ALTER TABLE notification ADD related_ticket_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA98F144DB FOREIGN KEY (related_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX idx_notification_related_ticket ON notification (related_ticket_id)');
}
}
-50
View File
@@ -9,9 +9,6 @@ use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use App\Module\ClientPortal\Domain\Enum\ClientTicketType;
use App\Module\Core\Application\Rbac\RbacSeeder;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Enum\ContractType;
@@ -218,53 +215,6 @@ class AppFixtures extends Fixture
$projectInterne->setWorkflow($standardWorkflow);
$manager->persist($projectInterne);
// Client portal users (ROLE_CLIENT) — linked to a client + allowed projects.
$clientUserLiot = new User();
$clientUserLiot->setUsername('client-liot');
$clientUserLiot->setFirstName('Camille');
$clientUserLiot->setLastName('LIOT');
$clientUserLiot->setRoles(['ROLE_CLIENT']);
$clientUserLiot->setPassword($this->passwordHasher->hashPassword($clientUserLiot, 'client-liot'));
$clientUserLiot->setClient($clientLiot);
$clientUserLiot->addAllowedProject($projectSirh);
$manager->persist($clientUserLiot);
$clientUserAcme = new User();
$clientUserAcme->setUsername('client-acme');
$clientUserAcme->setFirstName('Sophie');
$clientUserAcme->setLastName('ACME');
$clientUserAcme->setRoles(['ROLE_CLIENT']);
$clientUserAcme->setPassword($this->passwordHasher->hashPassword($clientUserAcme, 'client-acme'));
$clientUserAcme->setClient($clientAcme);
$clientUserAcme->addAllowedProject($projectCrm);
$manager->persist($clientUserAcme);
// Demo client tickets.
$ticketLiot = new ClientTicket();
$ticketLiot->setNumber(1);
$ticketLiot->setType(ClientTicketType::Bug);
$ticketLiot->setTitle('Erreur lors de l\'export des congés');
$ticketLiot->setDescription('L\'export PDF des congés échoue avec une erreur 500.');
$ticketLiot->setUrl('https://app.example.com/sirh/conges');
$ticketLiot->setStatus(ClientTicketStatus::New);
$ticketLiot->setProject($projectSirh);
$ticketLiot->setSubmittedBy($clientUserLiot);
$ticketLiot->setCreatedAt(new DateTimeImmutable());
$ticketLiot->setUpdatedAt(new DateTimeImmutable());
$manager->persist($ticketLiot);
$ticketAcme = new ClientTicket();
$ticketAcme->setNumber(1);
$ticketAcme->setType(ClientTicketType::Improvement);
$ticketAcme->setTitle('Ajouter un filtre par commercial');
$ticketAcme->setDescription('Pouvoir filtrer la liste des opportunités par commercial assigné.');
$ticketAcme->setStatus(ClientTicketStatus::InProgress);
$ticketAcme->setProject($projectCrm);
$ticketAcme->setSubmittedBy($clientUserAcme);
$ticketAcme->setCreatedAt(new DateTimeImmutable());
$ticketAcme->setUpdatedAt(new DateTimeImmutable());
$manager->persist($ticketAcme);
// Task Efforts
$effortS = new TaskEffort();
$effortS->setLabel('S');
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal;
use App\Shared\Domain\Module\ModuleInterface;
final class ClientPortalModule implements ModuleInterface
{
public static function id(): string
{
return 'client-portal';
}
public static function label(): string
{
return 'Portail client';
}
public static function isRequired(): bool
{
return false;
}
/**
* Permissions RBAC fin du Module ClientPortal.
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste pilotée par ROLE_CLIENT/ROLE_ADMIN sur les opérations.
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'client-portal.tickets.view', 'label' => 'Voir les tickets client'],
['code' => 'client-portal.tickets.manage', 'label' => 'Gérer les tickets client'],
];
}
}
@@ -1,245 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use App\Module\ClientPortal\Domain\Enum\ClientTicketType;
use App\Module\ClientPortal\Infrastructure\ApiPlatform\State\ClientTicketNumberProcessor;
use App\Module\ClientPortal\Infrastructure\ApiPlatform\State\ClientTicketProvider;
use App\Module\ClientPortal\Infrastructure\ApiPlatform\State\ClientTicketStatusProcessor;
use App\Module\ClientPortal\Infrastructure\Doctrine\DoctrineClientTicketRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
paginationEnabled: false,
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
provider: ClientTicketProvider::class,
),
new Get(
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
provider: ClientTicketProvider::class,
),
new Post(
security: "is_granted('ROLE_CLIENT')",
processor: ClientTicketNumberProcessor::class,
),
new Patch(
security: "is_granted('ROLE_ADMIN')",
processor: ClientTicketStatusProcessor::class,
),
new Delete(
security: "is_granted('ROLE_ADMIN')",
),
],
normalizationContext: ['groups' => ['client_ticket:read']],
denormalizationContext: ['groups' => ['client_ticket:write']],
order: ['createdAt' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'status' => 'exact', 'submittedBy' => 'exact'])]
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineClientTicketRepository::class)]
#[ORM\Table(name: 'client_ticket')]
#[ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number'])]
#[ORM\Index(name: 'idx_client_ticket_project', columns: ['project_id'])]
#[ORM\Index(name: 'idx_client_ticket_submitted_by', columns: ['submitted_by_id'])]
#[ORM\Index(name: 'idx_client_ticket_status_project', columns: ['status', 'project_id'])]
class ClientTicket implements ClientTicketInterface, TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_ticket:read', 'task:read'])]
private ?int $id = null;
/** Incremental number, unique per project (formatted CT-XXX in the UI). */
#[ORM\Column(type: 'integer')]
#[Groups(['client_ticket:read', 'task:read'])]
private ?int $number = null;
#[ORM\Column(type: 'string', length: 16, enumType: ClientTicketType::class)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\NotNull]
private ?ClientTicketType $type = null;
#[ORM\Column(length: 255)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\NotBlank]
private ?string $title = null;
#[ORM\Column(type: 'text')]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
#[Assert\NotBlank]
private ?string $description = null;
/** Displayed only when type = bug (page concerned by the bug). */
#[ORM\Column(length: 1024, nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $url = null;
#[ORM\Column(type: 'string', length: 16, enumType: ClientTicketStatus::class)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
private ClientTicketStatus $status = ClientTicketStatus::New;
/** Manager comment set when the status changes (mandatory when rejecting). */
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $statusComment = null;
#[ORM\ManyToOne(targetEntity: ProjectInterface::class)]
#[ORM\JoinColumn(name: 'project_id', nullable: false, onDelete: 'CASCADE')]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
#[Assert\NotNull]
private ?ProjectInterface $project = null;
/** Client user who submitted the ticket. ON DELETE SET NULL — keep history. */
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'submitted_by_id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['client_ticket:read'])]
private ?UserInterface $submittedBy = null;
public function getId(): ?int
{
return $this->id;
}
public function getNumber(): ?int
{
return $this->number;
}
public function setNumber(int $number): static
{
$this->number = $number;
return $this;
}
public function getTypeEnum(): ?ClientTicketType
{
return $this->type;
}
public function setType(?ClientTicketType $type): static
{
$this->type = $type;
return $this;
}
public function getType(): string
{
return $this->type?->value ?? '';
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(?string $url): static
{
$this->url = $url;
return $this;
}
public function getStatusEnum(): ClientTicketStatus
{
return $this->status;
}
public function setStatus(ClientTicketStatus $status): static
{
$this->status = $status;
return $this;
}
public function getStatus(): string
{
return $this->status->value;
}
public function getStatusComment(): ?string
{
return $this->statusComment;
}
public function setStatusComment(?string $statusComment): static
{
$this->statusComment = $statusComment;
return $this;
}
public function getProject(): ?ProjectInterface
{
return $this->project;
}
public function setProject(?ProjectInterface $project): static
{
$this->project = $project;
return $this;
}
public function getSubmittedBy(): ?UserInterface
{
return $this->submittedBy;
}
public function setSubmittedBy(?UserInterface $submittedBy): static
{
$this->submittedBy = $submittedBy;
return $this;
}
}
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Domain\Enum;
enum ClientTicketStatus: string
{
case New = 'new';
case InProgress = 'in_progress';
case Done = 'done';
case Rejected = 'rejected';
public function label(): string
{
return match ($this) {
self::New => 'Nouveau',
self::InProgress => 'En cours',
self::Done => 'Terminé',
self::Rejected => 'Rejeté',
};
}
/**
* Whether a transition from this status to $target is allowed.
*
* All transitions are allowed except `done` -> `new` and `rejected` -> `new`.
*/
public function canTransitionTo(self $target): bool
{
if (self::New === $target && (self::Done === $this || self::Rejected === $this)) {
return false;
}
return true;
}
}
@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Domain\Enum;
enum ClientTicketType: string
{
case Bug = 'bug';
case Improvement = 'improvement';
case Other = 'other';
public function label(): string
{
return match ($this) {
self::Bug => 'Bug',
self::Improvement => 'Amélioration',
self::Other => 'Autre',
};
}
}
@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Domain\Repository;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
interface ClientTicketRepositoryInterface
{
public function findById(int $id): ?ClientTicket;
/**
* Highest ticket number currently used on a given project, behind a
* PostgreSQL advisory transaction lock so concurrent inserts serialize.
* Returns 0 if the project has no ticket yet. Must run inside a transaction.
*/
public function findMaxNumberByProjectForUpdate(int $projectId): int;
}
@@ -1,126 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use App\Module\ClientPortal\Domain\Repository\ClientTicketRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Shared\Domain\Contract\NotifierInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Throwable;
/**
* Handles creation of a client ticket (POST).
*
* - Rejects users whose `client` is null (an admin inheriting ROLE_CLIENT via
* the role hierarchy cannot create a ticket).
* - Enforces that the target project belongs to the user's allowed projects.
* - Sets submittedBy, status = new, timestamps.
* - Generates the per-project incremental number behind an advisory lock so the
* unique constraint `(project_id, number)` is never violated by concurrency.
*
* @implements ProcessorInterface<ClientTicket, ClientTicket>
*/
final readonly class ClientTicketNumberProcessor implements ProcessorInterface
{
/**
* @param ProcessorInterface<ClientTicket, ClientTicket> $persistProcessor
*/
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private ClientTicketRepositoryInterface $repository,
private EntityManagerInterface $entityManager,
private Security $security,
private NotifierInterface $notifier,
private UserRepositoryInterface $userRepository,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
{
assert($data instanceof ClientTicket);
$user = $this->security->getUser();
assert($user instanceof UserInterface);
// An admin must not be able to create a ticket even though ROLE_ADMIN
// inherits ROLE_CLIENT in the role hierarchy.
if (null === $user->getClient()) {
throw new AccessDeniedHttpException('Only client users can submit tickets.');
}
$project = $data->getProject();
if (!$project instanceof ProjectInterface) {
throw new UnprocessableEntityHttpException('A project is required.');
}
if (!$this->userMayAccessProject($user, $project)) {
throw new AccessDeniedHttpException('You are not allowed to submit tickets on this project.');
}
$data->setSubmittedBy($user);
$data->setStatus(ClientTicketStatus::New);
$data->setStatusComment(null);
$now = new DateTimeImmutable();
$data->setCreatedAt($now);
$data->setUpdatedAt($now);
$ticket = $this->entityManager->wrapInTransaction(function () use ($data, $project, $operation, $uriVariables, $context): ClientTicket {
$maxNumber = $this->repository->findMaxNumberByProjectForUpdate((int) $project->getId());
$data->setNumber($maxNumber + 1);
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
assert($result instanceof ClientTicket);
return $result;
});
// Notify admins after the ticket is committed; never let a notification
// failure roll back or break the creation.
$this->notifyAdmins($ticket, $project);
return $ticket;
}
private function notifyAdmins(ClientTicket $ticket, ProjectInterface $project): void
{
try {
$number = sprintf('CT-%03d', (int) $ticket->getNumber());
$projectName = $project->getName() ?? '';
$title = sprintf('Nouveau ticket client %s', $number);
$message = '' !== $projectName
? sprintf('« %s » — %s', (string) $ticket->getTitle(), $projectName)
: sprintf('« %s »', (string) $ticket->getTitle());
foreach ($this->userRepository->findByRole('ROLE_ADMIN') as $admin) {
$this->notifier->notify($admin, 'ticket_created', $title, $message, $ticket);
}
} catch (Throwable) {
// Tolerant: ticket creation must succeed even if notifications fail.
}
}
private function userMayAccessProject(UserInterface $user, ProjectInterface $project): bool
{
foreach ($user->getAllowedProjects() as $allowed) {
if ($allowed->getId() === $project->getId()) {
return true;
}
}
return false;
}
}
@@ -1,124 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Provider for ClientTicket read operations.
*
* - ROLE_ADMIN: no restriction.
* - ROLE_CLIENT: only tickets the user submitted, and only on projects the
* user is allowed to access (allowedProjects).
*
* @implements ProviderInterface<ClientTicket>
*/
final readonly class ClientTicketProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|ClientTicket|null
{
$user = $this->security->getUser();
assert($user instanceof UserInterface);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
$repo = $this->entityManager->getRepository(ClientTicket::class);
// Single item.
if (isset($uriVariables['id'])) {
$ticket = $repo->find($uriVariables['id']);
if (null === $ticket) {
return null;
}
if ($isAdmin) {
return $ticket;
}
if ($ticket->getSubmittedBy() !== $user) {
return null;
}
if (!$this->userMayAccessProject($user, $ticket->getProject())) {
return null;
}
return $ticket;
}
// Collection.
$qb = $repo->createQueryBuilder('t')
->orderBy('t.createdAt', 'DESC')
;
if (!$isAdmin) {
$qb->andWhere('t.submittedBy = :user')->setParameter('user', $user);
$allowedIds = $this->allowedProjectIds($user);
if ([] === $allowedIds) {
return [];
}
$qb->andWhere('IDENTITY(t.project) IN (:allowedProjects)')
->setParameter('allowedProjects', $allowedIds)
;
}
$filters = $context['filters'] ?? [];
if (isset($filters['project'])) {
$qb->andWhere('IDENTITY(t.project) = :project')
->setParameter('project', self::extractId($filters['project']))
;
}
if (isset($filters['status'])) {
$qb->andWhere('t.status = :status')->setParameter('status', $filters['status']);
}
if ($isAdmin && isset($filters['submittedBy'])) {
$qb->andWhere('t.submittedBy = :submittedBy')
->setParameter('submittedBy', self::extractId($filters['submittedBy']))
;
}
return $qb->getQuery()->getResult();
}
private function userMayAccessProject(UserInterface $user, ?ProjectInterface $project): bool
{
if (null === $project) {
return false;
}
return in_array($project->getId(), $this->allowedProjectIds($user), true);
}
/**
* @return list<int>
*/
private function allowedProjectIds(UserInterface $user): array
{
$ids = [];
foreach ($user->getAllowedProjects() as $project) {
$id = $project->getId();
if (null !== $id) {
$ids[] = $id;
}
}
return $ids;
}
private static function extractId(string $value): int
{
return is_numeric($value) ? (int) $value : (int) basename($value);
}
}
@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use App\Shared\Domain\Contract\NotifierInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Throwable;
/**
* Handles status changes on a client ticket (PATCH, ROLE_ADMIN only).
*
* - Rejects the forbidden transitions `done` -> `new` and `rejected` -> `new`.
* - Requires a statusComment when moving to the `rejected` status.
* - Refreshes updatedAt.
*
* @implements ProcessorInterface<ClientTicket, ClientTicket>
*/
final readonly class ClientTicketStatusProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private NotifierInterface $notifier,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
{
assert($data instanceof ClientTicket);
$newStatus = $data->getStatusEnum();
$previous = $context['previous_data'] ?? null;
$oldStatus = $previous instanceof ClientTicket ? $previous->getStatusEnum() : null;
if (null !== $oldStatus && !$oldStatus->canTransitionTo($newStatus)) {
throw new UnprocessableEntityHttpException(sprintf(
'Transition from "%s" to "%s" is not allowed.',
$oldStatus->value,
$newStatus->value,
));
}
if (ClientTicketStatus::Rejected === $newStatus && '' === trim((string) $data->getStatusComment())) {
throw new UnprocessableEntityHttpException('A status comment is required to reject a ticket.');
}
$data->setUpdatedAt(new DateTimeImmutable());
$this->entityManager->persist($data);
$this->entityManager->flush();
// Notify the submitter when the status actually changed.
if ($oldStatus !== $newStatus) {
$this->notifySubmitter($data, $newStatus);
}
return $data;
}
private function notifySubmitter(ClientTicket $ticket, ClientTicketStatus $newStatus): void
{
$submitter = $ticket->getSubmittedBy();
if (!$submitter instanceof UserInterface) {
return;
}
try {
$number = sprintf('CT-%03d', (int) $ticket->getNumber());
$title = sprintf('Ticket %s mis à jour', $number);
$comment = trim((string) $ticket->getStatusComment());
$message = '' !== $comment
? sprintf('%s — %s', $newStatus->label(), $comment)
: $newStatus->label();
$this->notifier->notify($submitter, 'ticket_status_changed', $title, $message, $ticket);
} catch (Throwable) {
// Tolerant: the status change must succeed even if notifications fail.
}
}
}
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Infrastructure\Doctrine;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Repository\ClientTicketRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ClientTicket>
*/
final class DoctrineClientTicketRepository extends ServiceEntityRepository implements ClientTicketRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClientTicket::class);
}
public function findById(int $id): ?ClientTicket
{
return $this->find($id);
}
public function findMaxNumberByProjectForUpdate(int $projectId): int
{
$conn = $this->getEntityManager()->getConnection();
// Use a PostgreSQL advisory lock (project ID as lock key) instead of
// FOR UPDATE because FOR UPDATE is not allowed with aggregate functions
// in PostgreSQL. The lock is held until the surrounding transaction ends.
$conn->executeStatement(
'SELECT pg_advisory_xact_lock(:project)',
['project' => $projectId],
);
$result = $conn->fetchOne(
'SELECT COALESCE(MAX(number), 0) FROM client_ticket WHERE project_id = :project',
['project' => $projectId],
);
return (int) $result;
}
}
@@ -9,7 +9,6 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Module\Core\Infrastructure\ApiPlatform\State\NotificationProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
@@ -33,7 +32,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: DoctrineNotificationRepository::class)]
#[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')]
#[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')]
#[ORM\Index(columns: ['related_ticket_id'], name: 'idx_notification_related_ticket')]
class Notification
{
#[ORM\Id]
@@ -59,12 +57,6 @@ class Notification
#[Groups(['notification:read'])]
private ?string $message = null;
/** Optional link to the client ticket this notification relates to. */
#[ORM\ManyToOne(targetEntity: ClientTicketInterface::class)]
#[ORM\JoinColumn(name: 'related_ticket_id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['notification:read'])]
private ?ClientTicketInterface $relatedTicket = null;
#[ORM\Column]
#[Groups(['notification:read', 'notification:write'])]
private bool $isRead = false;
@@ -126,18 +118,6 @@ class Notification
return $this;
}
public function getRelatedTicket(): ?ClientTicketInterface
{
return $this->relatedTicket;
}
public function setRelatedTicket(?ClientTicketInterface $relatedTicket): static
{
$this->relatedTicket = $relatedTicket;
return $this;
}
public function isRead(): bool
{
return $this->isRead;
+2 -66
View File
@@ -18,9 +18,7 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\LeaveProfileInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -177,34 +175,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
#[Groups(['user:rbac:read', 'user:rbac:write'])]
private Collection $directPermissions;
// --- Client portal fields ---
/** Client this user belongs to. null = internal user, set = client user. */
#[ORM\ManyToOne(targetEntity: ClientInterface::class)]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:read', 'user:write'])]
private ?ClientInterface $client = null;
/**
* Projects a client user is allowed to access (a subset of the client's projects).
*
* @var Collection<int, ProjectInterface>
*/
#[ORM\ManyToMany(targetEntity: ProjectInterface::class)]
#[ORM\JoinTable(
name: 'user_allowed_projects',
joinColumns: [new ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'project_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
)]
#[Groups(['me:read', 'user:read', 'user:write'])]
private Collection $allowedProjects;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
$this->allowedProjects = new ArrayCollection();
}
public function getId(): ?int
@@ -258,11 +233,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
{
$roles = $this->roles;
// A client user must NOT inherit ROLE_USER (which would grant access to
// the internal application). Only non-client users get ROLE_USER.
if (!in_array('ROLE_CLIENT', $roles, true)) {
$roles[] = 'ROLE_USER';
}
// Every authenticated user gets ROLE_USER.
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
@@ -486,42 +458,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
$this->directPermissions->removeElement($permission);
}
public function getClient(): ?ClientInterface
{
return $this->client;
}
public function setClient(?ClientInterface $client): static
{
$this->client = $client;
return $this;
}
/**
* @return Collection<int, ProjectInterface>
*/
public function getAllowedProjects(): Collection
{
return $this->allowedProjects;
}
public function addAllowedProject(ProjectInterface $project): static
{
if (!$this->allowedProjects->contains($project)) {
$this->allowedProjects->add($project);
}
return $this;
}
public function removeAllowedProject(ProjectInterface $project): static
{
$this->allowedProjects->removeElement($project);
return $this;
}
/**
* Permissions effectives = union (rôles RBAC → permissions) (permissions directes), triée, dédupliquée.
*
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Module\Core\Infrastructure;
use App\Module\Core\Domain\Entity\Notification;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\NotifierInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
@@ -20,14 +19,12 @@ final readonly class Notifier implements NotifierInterface
string $type,
string $title,
string $message,
?ClientTicketInterface $relatedTicket = null,
): void {
$notification = new Notification();
$notification->setUser($user);
$notification->setType($type);
$notification->setTitle($title);
$notification->setMessage($message);
$notification->setRelatedTicket($relatedTicket);
$notification->setCreatedAt(new DateTimeImmutable());
$this->em->persist($notification);
@@ -19,7 +19,6 @@ use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskCalendarPr
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskNumberProcessor;
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\TaskInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
@@ -164,16 +163,6 @@ class Task implements TaskInterface, TimestampableInterface, BlamableInterface
#[Groups(['task:read', 'task:write'])]
private ?TaskRecurrence $recurrence = null;
/**
* Optional manual link to a client ticket. Exposed (number/type/status/title)
* in task:read so the kanban can show the linked-ticket icon without giving
* ROLE_USER access to the /api/client_tickets collection.
*/
#[ORM\ManyToOne(targetEntity: ClientTicketInterface::class)]
#[ORM\JoinColumn(name: 'client_ticket_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?ClientTicketInterface $clientTicket = null;
public function __construct()
{
$this->tags = new ArrayCollection();
@@ -452,18 +441,6 @@ class Task implements TaskInterface, TimestampableInterface, BlamableInterface
return $this;
}
public function getClientTicket(): ?ClientTicketInterface
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicketInterface $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
#[Assert\Callback]
public function validateScheduledDates(ExecutionContextInterface $context): void
{
@@ -14,7 +14,6 @@ use ApiPlatform\Metadata\Post;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProvider;
use App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
@@ -22,10 +21,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new Post(
security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')",
security: "is_granted('ROLE_ADMIN')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
@@ -35,11 +34,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['task_document:write']],
order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact', 'clientTicket' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])]
#[ORM\Entity]
#[ORM\EntityListeners([TaskDocumentListener::class])]
// A document must be attached to either a task or a client ticket.
#[ORM\Table(name: 'task_document')]
class TaskDocument
{
#[ORM\Id]
@@ -49,16 +46,10 @@ class TaskDocument
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write'])]
private ?Task $task = null;
/** Client ticket this document is attached to (alternative to task). */
#[ORM\ManyToOne(targetEntity: ClientTicketInterface::class)]
#[ORM\JoinColumn(name: 'client_ticket_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write', 'client_ticket:read'])]
private ?ClientTicketInterface $clientTicket = null;
#[ORM\Column(length: 255)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $originalName = null;
@@ -109,18 +100,6 @@ class TaskDocument
return $this;
}
public function getClientTicket(): ?ClientTicketInterface
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicketInterface $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
public function getOriginalName(): ?string
{
return $this->originalName;
@@ -14,8 +14,6 @@ use App\Module\Integration\Domain\Service\FileSource;
use App\Module\Integration\Domain\Service\SharePathResolver;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -77,12 +75,11 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument
{
// Défense en profondeur : l'opération Post est déjà protégée par
// ROLE_ADMIN ou ROLE_CLIENT, mais on re-vérifie ici pour que les deux
// chemins (upload ET lien partage) restent sûrs si la configuration de
// sécurité de l'opération venait à changer.
if (!$this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_CLIENT')) {
throw new AccessDeniedHttpException('Creating documents requires admin or client privileges.');
// Défense en profondeur : l'opération Post est déjà protégée par ROLE_ADMIN, mais on
// re-vérifie ici pour que les deux chemins (upload ET lien partage) restent sûrs si la
// configuration de sécurité de l'opération venait à changer.
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Creating task documents requires admin privileges.');
}
$request = $this->requestStack->getCurrentRequest();
@@ -94,14 +91,6 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
// Deux modes de création : upload d'un fichier (multipart) ou lien vers un fichier du partage SMB (JSON).
$sharePath = $this->extractSharePath($request);
// Sécurité : un utilisateur client ne peut PAS créer de lien vers le
// partage SMB interne (référence de fichier arbitraire hors de son
// périmètre) — seul le téléversement lui est permis. Le lien partage
// reste réservé aux administrateurs.
if (null !== $sharePath && !$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Les utilisateurs clients ne peuvent pas créer de lien vers le partage ; un téléversement est requis.');
}
$document = null !== $sharePath
? $this->createShareLink($request, $sharePath)
: $this->createUpload($request);
@@ -147,6 +136,8 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
}
$task = $this->resolveTask($request->request->get('task', ''));
// Use server-detected MIME type (finfo), not the client-supplied one
$originalName = $file->getClientOriginalName();
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
@@ -166,7 +157,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
$file->move($this->uploadDir, $fileName);
$document = new TaskDocument();
$this->attachTarget($document, $request);
$document->setTask($task);
$document->setOriginalName($originalName);
$document->setFileName($fileName);
$document->setMimeType($mimeType);
@@ -177,6 +168,15 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
private function createShareLink(Request $request, string $rawSharePath): TaskDocument
{
$taskIri = $request->request->get('task');
if (!is_string($taskIri) || '' === $taskIri) {
$payload = json_decode($request->getContent() ?: '{}', true);
$taskIri = is_array($payload) ? ($payload['task'] ?? '') : '';
}
$task = $this->resolveTask((string) $taskIri);
try {
$path = $this->pathResolver->normalizeRelative($rawSharePath);
} catch (InvalidPathException) {
@@ -198,7 +198,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
}
$document = new TaskDocument();
$this->attachTarget($document, $request);
$document->setTask($task);
$document->setOriginalName($entry->name);
$document->setSharePath($path);
$document->setMimeType($entry->mimeType);
@@ -233,61 +233,12 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
return null;
}
/**
* Attaches the document to a task OR a client ticket, enforcing per-role
* access. Exactly one of the two targets must be provided.
*
* - ROLE_ADMIN may attach to any task or any client ticket.
* - ROLE_CLIENT may only attach to a client ticket they submitted, and may
* never attach to a task.
*/
private function attachTarget(TaskDocument $document, Request $request): void
{
$taskIri = $this->readField($request, 'task');
$clientTicketIri = $this->readField($request, 'clientTicket');
if ('' === $taskIri && '' === $clientTicketIri) {
throw new BadRequestHttpException('A task or a clientTicket IRI is required.');
}
if ('' !== $taskIri && '' !== $clientTicketIri) {
throw new BadRequestHttpException('Provide either a task or a clientTicket, not both.');
}
$isClient = $this->security->isGranted('ROLE_CLIENT') && !$this->security->isGranted('ROLE_ADMIN');
if ('' !== $clientTicketIri) {
$document->setClientTicket($this->resolveClientTicket($clientTicketIri, $isClient));
return;
}
if ($isClient) {
throw new AccessDeniedHttpException('Client users can only attach documents to a client ticket.');
}
$document->setTask($this->resolveTask($taskIri));
}
private function readField(Request $request, string $field): string
{
$value = $request->request->get($field);
if (is_string($value) && '' !== $value) {
return $value;
}
if (str_contains((string) $request->headers->get('Content-Type'), 'application/json')) {
$payload = json_decode($request->getContent() ?: '{}', true);
if (is_array($payload) && isset($payload[$field]) && is_string($payload[$field])) {
return $payload[$field];
}
}
return '';
}
private function resolveTask(string $taskIri): Task
{
if ('' === $taskIri) {
throw new BadRequestHttpException('A task IRI is required.');
}
$task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri));
if (null === $task) {
@@ -296,24 +247,4 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
return $task;
}
private function resolveClientTicket(string $ticketIri, bool $isClient): ClientTicketInterface
{
$ticket = $this->entityManager->getRepository(ClientTicketInterface::class)->find((int) basename($ticketIri));
if (null === $ticket) {
throw new BadRequestHttpException('Client ticket not found.');
}
if ($isClient) {
$user = $this->security->getUser();
assert($user instanceof UserInterface);
if ($ticket->getSubmittedBy() !== $user) {
throw new AccessDeniedHttpException('You can only attach documents to your own tickets.');
}
}
return $ticket;
}
}
@@ -12,12 +12,6 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Provider for TaskDocument read operations.
*
* - ROLE_ADMIN: every document.
* - ROLE_USER: documents attached to a task (task IS NOT NULL).
* - ROLE_CLIENT: documents attached to a client ticket the user submitted.
*
* @implements ProviderInterface<TaskDocument>
*/
final readonly class TaskDocumentProvider implements ProviderInterface
@@ -32,56 +26,25 @@ final readonly class TaskDocumentProvider implements ProviderInterface
$user = $this->security->getUser();
assert($user instanceof UserInterface);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
$isClient = $this->security->isGranted('ROLE_CLIENT');
$repo = $this->entityManager->getRepository(TaskDocument::class);
// Single item.
// Single item
if (isset($uriVariables['id'])) {
$document = $repo->find($uriVariables['id']);
if (null === $document) {
return null;
}
if ($isAdmin) {
return $document;
}
if ($isClient) {
$ticket = $document->getClientTicket();
return null !== $ticket && $ticket->getSubmittedBy() === $user ? $document : null;
}
// ROLE_USER: task-linked documents only.
return null !== $document->getTask() ? $document : null;
return $repo->find($uriVariables['id']);
}
// Collection.
// Collection
$qb = $repo->createQueryBuilder('d')
->orderBy('d.id', 'DESC')
;
if ($isClient && !$isAdmin) {
$qb->innerJoin('d.clientTicket', 'ct')
->andWhere('ct.submittedBy = :user')
->setParameter('user', $user)
;
} elseif (!$isAdmin) {
// ROLE_USER: only documents attached to a task.
$qb->andWhere('d.task IS NOT NULL');
}
// Apply filters from query parameters
$filters = $context['filters'] ?? [];
if (isset($filters['task'])) {
$qb->andWhere('d.task = :task')
->setParameter('task', self::extractId($filters['task']))
;
}
if (isset($filters['clientTicket'])) {
$qb->andWhere('d.clientTicket = :clientTicket')
->setParameter('clientTicket', self::extractId($filters['clientTicket']))
;
}
return $qb->getQuery()->getResult();
}
@@ -10,13 +10,11 @@ use App\Module\Integration\Domain\Service\FileSource;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -28,7 +26,6 @@ class TaskDocumentDownloadController extends AbstractController
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly FileSource $fileSource,
private readonly Security $security,
private readonly string $uploadDir,
) {}
@@ -42,19 +39,6 @@ class TaskDocumentDownloadController extends AbstractController
throw new NotFoundHttpException('Document not found.');
}
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
$isClient = $this->security->isGranted('ROLE_CLIENT') && !$isAdmin;
if (!$isAdmin) {
if ($isClient) {
$ticket = $document->getClientTicket();
if (null === $ticket || $ticket->getSubmittedBy() !== $this->security->getUser()) {
throw new AccessDeniedHttpException();
}
} elseif (null === $document->getTask()) {
throw new AccessDeniedHttpException();
}
}
$mimeType = $document->getMimeType() ?? 'application/octet-stream';
// Inline for images (except SVG) and PDFs, attachment for everything else.
@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE d'un ticket client, consommé hors du module ClientPortal.
*
* Permet à ProjectManagement (Task, TaskDocument) de référencer un ticket
* client sans dépendre directement de l'entité concrète du module ClientPortal.
*/
interface ClientTicketInterface
{
public function getId(): ?int;
public function getNumber(): ?int;
public function getType(): string;
public function getStatus(): string;
public function getTitle(): ?string;
}
@@ -11,6 +11,5 @@ interface NotifierInterface
string $type,
string $title,
string $message,
?ClientTicketInterface $relatedTicket = null,
): void;
}
@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use Doctrine\Common\Collections\Collection;
/**
* Contrat de LECTURE de l'identité, consommé hors du module Core.
* Les écritures (setPassword, setters HR…) restent sur le concret Core\Domain\Entity\User.
@@ -31,16 +29,4 @@ interface UserInterface
/** @return list<string> */
public function getEffectivePermissions(): array;
/**
* Client this user belongs to, or null for an internal user.
*/
public function getClient(): ?ClientInterface;
/**
* Projects a client user is allowed to access.
*
* @return Collection<int, ProjectInterface>
*/
public function getAllowedProjects(): Collection;
}
@@ -1,293 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\ClientPortal;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use App\Module\ClientPortal\Domain\Enum\ClientTicketType;
use App\Module\Core\Domain\Entity\Notification;
use App\Module\Core\Domain\Entity\User;
use App\Module\ProjectManagement\Domain\Entity\Project;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use function count;
/**
* Phase 1 security boundary: a pure ROLE_CLIENT user is walled off from the
* internal API but can reach its own client portal collection.
*
* @internal
*/
final class ClientTicketApiTest extends WebTestCase
{
public function testClientUserCannotListTasks(): void
{
$client = self::createClient();
$this->loginClient($client, 'client-liot');
$client->request('GET', '/api/tasks');
self::assertResponseStatusCodeSame(403);
}
public function testClientUserCannotListProjects(): void
{
$client = self::createClient();
$this->loginClient($client, 'client-liot');
$client->request('GET', '/api/projects');
self::assertResponseStatusCodeSame(403);
}
/**
* Regression guard for the post-migration security review: internal
* endpoints that were only behind IS_AUTHENTICATED_FULLY (or had no
* security) must reject a pure ROLE_CLIENT.
*/
#[DataProvider('internalEndpointsForbiddenToClients')]
public function testClientUserIsWalledOffFromInternalEndpoints(string $uri): void
{
$client = self::createClient();
$this->loginClient($client, 'client-liot');
$client->request('GET', $uri);
self::assertResponseStatusCodeSame(403);
}
/** @return iterable<string, array{string}> */
public static function internalEndpointsForbiddenToClients(): iterable
{
yield 'users directory' => ['/api/users'];
yield 'smb share browse' => ['/api/share/browse'];
yield 'smb share status' => ['/api/share/status'];
yield 'bookstack links' => ['/api/tasks/1/bookstack/links'];
}
public function testClientUserCanListOwnClientTickets(): void
{
$client = self::createClient();
$this->loginClient($client, 'client-liot');
$client->request('GET', '/api/client_tickets');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('member', $data);
self::assertNotEmpty($data['member']);
// Tenancy invariant (robust to POST tests accumulating tickets in the
// shared test DB): client-liot sees its own SIRH ticket but NEVER the
// ticket submitted by another client (ACME, on the CRM project).
$titles = array_column($data['member'], 'title');
self::assertContains('Erreur lors de l\'export des congés', $titles);
self::assertNotContains('Ajouter un filtre par commercial', $titles);
}
public function testClientUserSeesOnlyOwnTickets(): void
{
$client = self::createClient();
$this->loginClient($client, 'client-acme');
$client->request('GET', '/api/client_tickets');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertNotEmpty($data['member']);
// Tenancy invariant: client-acme sees its own CRM ticket but NEVER the
// ticket submitted by client-liot (on the SIRH project).
$titles = array_column($data['member'], 'title');
self::assertContains('Ajouter un filtre par commercial', $titles);
self::assertNotContains('Erreur lors de l\'export des congés', $titles);
}
public function testInternalUserCannotListClientTickets(): void
{
$client = self::createClient();
$this->loginClient($client, 'alice');
$client->request('GET', '/api/client_tickets');
self::assertResponseStatusCodeSame(403);
}
public function testAdminCanListAllClientTickets(): void
{
$client = self::createClient();
$this->loginClient($client, 'admin');
$client->request('GET', '/api/client_tickets');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertGreaterThanOrEqual(2, count($data['member']));
}
public function testClientCanCreateTicketAndNumberIsGenerated(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$projectIri = $this->sirhProjectIri($em);
$this->loginClient($client, 'client-liot');
$client->request('POST', '/api/client_tickets', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode([
'type' => 'other',
'title' => 'Demande de fonctionnalité',
'description' => 'Une nouvelle option serait utile.',
'project' => $projectIri,
]));
self::assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertSame('new', $data['status']);
self::assertGreaterThanOrEqual(1, $data['number']);
}
public function testCreatingTicketNotifiesAdmins(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$projectIri = $this->sirhProjectIri($em);
$this->loginClient($client, 'client-liot');
$title = 'Notif test '.uniqid('', true);
$client->request('POST', '/api/client_tickets', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode([
'type' => 'other',
'title' => $title,
'description' => 'Trigger an admin notification.',
'project' => $projectIri,
]));
self::assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$ticketId = $data['id'];
$admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
self::assertNotNull($admin);
$notification = $em->getRepository(Notification::class)->findOneBy([
'user' => $admin,
'type' => 'ticket_created',
], ['createdAt' => 'DESC']);
self::assertInstanceOf(Notification::class, $notification);
self::assertNotNull($notification->getRelatedTicket());
self::assertSame($ticketId, $notification->getRelatedTicket()->getId());
}
public function testChangingStatusNotifiesSubmitter(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
// Set up a fresh `new` ticket whose submitter is a known client user,
// so we can assert the submitter is the notification recipient.
$submitter = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']);
self::assertNotNull($submitter);
$project = $em->getRepository(Project::class)->findOneBy(['code' => 'SIRH']);
self::assertNotNull($project);
$ticket = new ClientTicket();
$ticket->setType(ClientTicketType::Other);
$ticket->setTitle('Status notif '.uniqid('', true));
$ticket->setDescription('Will be moved to in_progress.');
$ticket->setProject($project);
$ticket->setSubmittedBy($submitter);
$ticket->setStatus(ClientTicketStatus::New);
$ticket->setNumber(9000 + random_int(1, 999));
$ticket->setCreatedAt(new DateTimeImmutable());
$ticket->setUpdatedAt(new DateTimeImmutable());
$em->persist($ticket);
$em->flush();
$ticketId = $ticket->getId();
// Admin moves it to in_progress.
$this->loginClient($client, 'admin');
$client->request('PATCH', '/api/client_tickets/'.$ticketId, server: [
'CONTENT_TYPE' => 'application/merge-patch+json',
], content: json_encode(['status' => 'in_progress']));
self::assertResponseIsSuccessful();
$notification = $em->getRepository(Notification::class)->findOneBy([
'user' => $submitter,
'type' => 'ticket_status_changed',
], ['createdAt' => 'DESC']);
self::assertInstanceOf(Notification::class, $notification);
self::assertNotNull($notification->getRelatedTicket());
self::assertSame($ticketId, $notification->getRelatedTicket()->getId());
}
public function testAdminCannotCreateTicket(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$projectIri = $this->sirhProjectIri($em);
$this->loginClient($client, 'admin');
$client->request('POST', '/api/client_tickets', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode([
'type' => 'other',
'title' => 'Admin attempt',
'description' => 'Should be forbidden.',
'project' => $projectIri,
]));
// Admin has no client, so ticket creation is denied.
self::assertResponseStatusCodeSame(403);
}
public function testRejectingTicketRequiresStatusComment(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$ticket = $em->getRepository(ClientTicket::class)
->findOneBy(['status' => ClientTicketStatus::New])
;
self::assertNotNull($ticket);
$id = $ticket->getId();
$this->loginClient($client, 'admin');
$client->request('PATCH', '/api/client_tickets/'.$id, server: [
'CONTENT_TYPE' => 'application/merge-patch+json',
], content: json_encode(['status' => 'rejected']));
self::assertResponseStatusCodeSame(422);
}
private function sirhProjectIri(EntityManagerInterface $em): string
{
$project = $em->getRepository(Project::class)
->findOneBy(['code' => 'SIRH'])
;
self::assertNotNull($project);
return '/api/projects/'.$project->getId();
}
private function loginClient(KernelBrowser $client, string $username): void
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
self::assertInstanceOf(User::class, $user);
$client->loginUser($user);
}
}
@@ -6,14 +6,11 @@ namespace App\Tests\Unit\Shared\Doctrine;
use App\Shared\Application\CurrentUserProviderInterface;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Infrastructure\Doctrine\TimestampableBlamableSubscriber;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase;
use stdClass;
@@ -123,16 +120,6 @@ final class TimestampableBlamableSubscriberTest extends TestCase
{
return [];
}
public function getClient(): ?ClientInterface
{
return null;
}
public function getAllowedProjects(): Collection
{
return new ArrayCollection();
}
};
}