refactor(client-portal) : remove client portal feature entirely
- 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:
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
-126
@@ -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);
|
||||
}
|
||||
}
|
||||
-88
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+22
-91
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-41
@@ -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();
|
||||
}
|
||||
|
||||
-16
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user