Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s

## Migration modular monolith DDD — Lesstime (0.1 → 3.3)

Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici.

**Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle.

### Périmètre — 9 modules sous `src/Module/`
| Phase | Module | Contenu |
|------|--------|---------|
| 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module |
| 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` |
| 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier |
| 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) |
| 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) |
| 2.1 | **TimeTracking** | TimeEntry + MCP + export |
| 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools |
| 2.3 | **Absence** | demandes, soldes, policies, justificatifs |
| 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) |
| 2.5 | **Mail** | intégration IMAP OVH + liens tâches |
| 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share |
| 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) |
| 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) |
| 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire |

### Architecture
- Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy).
- Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées.
- Reporting en DBAL read-only pur (aucun import d'entité d'un autre module).
- Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif).

### Sécurité
- ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne.
- Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement).

### QA non-régression (branche reconstruite from scratch)
- Migrations from scratch + fixtures : OK.
- Compilation dev + prod : OK.
- **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`.
- Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche.
- Build Nuxt OK, 9 layers, 0 import legacy résiduel.

### Points à arbitrer (hors périmètre de cette migration)
- Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé.
- Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque).
- **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO.

---

## ⚠️ Déploiement / migration des données — à ne pas oublier

### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump
Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…).

À lancer **juste après chaque restore/import** :

```sql
DO $$
DECLARE r RECORD; maxid BIGINT; seq TEXT;
BEGIN
  FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public'
  LOOP
    seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name);
    IF seq IS NOT NULL THEN
      EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid;
      PERFORM setval(seq, GREATEST(maxid,1), maxid > 0);
    END IF;
  END LOOP;
END $$;
```

> Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque.

### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche)
Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-06-23 13:50:42 +00:00
parent d0a49322e1
commit 8313c759c6
622 changed files with 24802 additions and 2864 deletions
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AbsenceBalance;
use App\Entity\User;
use App\Enum\AbsenceType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsenceBalance>
*/
class AbsenceBalanceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsenceBalance::class);
}
public function findOneForPeriod(User $user, AbsenceType $type, string $period): ?AbsenceBalance
{
return $this->findOneBy([
'user' => $user,
'type' => $type,
'period' => $period,
]);
}
}
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AbsencePolicy;
use App\Enum\AbsenceType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsencePolicy>
*/
class AbsencePolicyRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsencePolicy::class);
}
public function findOneByType(AbsenceType $type): ?AbsencePolicy
{
return $this->findOneBy(['type' => $type]);
}
}
-106
View File
@@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsenceRequest>
*/
class AbsenceRequestRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsenceRequest::class);
}
/**
* Whether the user already has a PENDING or APPROVED absence that overlaps
* the given date range. Two ranges overlap when start_a <= end_b and
* end_a >= start_b.
*/
public function hasOverlap(
User $user,
DateTimeInterface $startDate,
DateTimeInterface $endDate,
?int $excludeId = null,
): bool {
$qb = $this->createQueryBuilder('a')
->select('COUNT(a.id)')
->andWhere('a.user = :user')
->andWhere('a.status IN (:statuses)')
->andWhere('a.startDate <= :endDate')
->andWhere('a.endDate >= :startDate')
->setParameter('user', $user)
->setParameter('statuses', [AbsenceStatus::Pending, AbsenceStatus::Approved])
->setParameter('startDate', $startDate->format('Y-m-d'))
->setParameter('endDate', $endDate->format('Y-m-d'))
;
if (null !== $excludeId) {
$qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId);
}
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
}
/**
* Absences (approved or pending) overlapping a date range, all employees —
* used by the admin calendar view.
*
* @return AbsenceRequest[]
*/
public function findInRange(DateTimeInterface $from, DateTimeInterface $to): array
{
return $this->createQueryBuilder('a')
->andWhere('a.status IN (:statuses)')
->andWhere('a.startDate <= :to')
->andWhere('a.endDate >= :from')
->setParameter('statuses', [AbsenceStatus::Pending, AbsenceStatus::Approved])
->setParameter('from', $from->format('Y-m-d'))
->setParameter('to', $to->format('Y-m-d'))
->orderBy('a.startDate', 'ASC')
->getQuery()
->getResult()
;
}
/**
* @return AbsenceRequest[]
*/
public function findFiltered(
?User $user = null,
?AbsenceStatus $status = null,
?AbsenceType $type = null,
?DateTimeInterface $from = null,
?DateTimeInterface $to = null,
): array {
$qb = $this->createQueryBuilder('a')->orderBy('a.startDate', 'DESC');
if (null !== $user) {
$qb->andWhere('a.user = :user')->setParameter('user', $user);
}
if (null !== $status) {
$qb->andWhere('a.status = :status')->setParameter('status', $status);
}
if (null !== $type) {
$qb->andWhere('a.type = :type')->setParameter('type', $type);
}
if (null !== $from) {
$qb->andWhere('a.endDate >= :from')->setParameter('from', $from->format('Y-m-d'));
}
if (null !== $to) {
$qb->andWhere('a.startDate <= :to')->setParameter('to', $to->format('Y-m-d'));
}
return $qb->getQuery()->getResult();
}
}
@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\BookStackConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class BookStackConfigurationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BookStackConfiguration::class);
}
public function findSingleton(): ?BookStackConfiguration
{
return $this->findOneBy([]);
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Client;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ClientRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Client::class);
}
}
@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\GiteaConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class GiteaConfigurationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, GiteaConfiguration::class);
}
public function findSingleton(): ?GiteaConfiguration
{
return $this->findOneBy([]);
}
}
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\MailConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class MailConfigurationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MailConfiguration::class);
}
public function findSingleton(): ?MailConfiguration
{
return $this->createQueryBuilder('m')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}
-34
View File
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\MailFolder;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class MailFolderRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MailFolder::class);
}
/**
* @return list<MailFolder>
*/
public function findAllOrderedByPath(): array
{
return $this->createQueryBuilder('f')
->orderBy('f.path', 'ASC')
->getQuery()
->getResult()
;
}
public function findByPath(string $path): ?MailFolder
{
return $this->findOneBy(['path' => $path]);
}
}
-152
View File
@@ -1,152 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\MailFolder;
use App\Entity\MailMessage;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class MailMessageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MailMessage::class);
}
public function findByMessageId(string $messageId): ?MailMessage
{
return $this->findOneBy(['messageId' => $messageId]);
}
public function findByFolderAndUid(MailFolder $folder, int $uid): ?MailMessage
{
return $this->findOneBy(['folder' => $folder, 'uid' => $uid]);
}
/**
* @return list<MailMessage>
*/
public function findByFolderPaginated(MailFolder $folder, int $limit, int $offset): array
{
return $this->createQueryBuilder('m')
->andWhere('m.folder = :folder')
->setParameter('folder', $folder)
->orderBy('m.sentAt', 'DESC')
->addOrderBy('m.id', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult()
;
}
public function countUnreadByFolder(MailFolder $folder): int
{
return (int) $this->createQueryBuilder('m')
->select('COUNT(m.id)')
->andWhere('m.folder = :folder')
->andWhere('m.isRead = false')
->setParameter('folder', $folder)
->getQuery()
->getSingleScalarResult()
;
}
public function findMaxUidInFolder(MailFolder $folder): int
{
$result = $this->createQueryBuilder('m')
->select('MAX(m.uid)')
->andWhere('m.folder = :folder')
->setParameter('folder', $folder)
->getQuery()
->getSingleScalarResult()
;
return (int) ($result ?? 0);
}
/**
* @return list<MailMessage>
*/
public function findLastNByFolder(MailFolder $folder, int $limit): array
{
return $this->createQueryBuilder('m')
->andWhere('m.folder = :folder')
->setParameter('folder', $folder)
->orderBy('m.sentAt', 'DESC')
->addOrderBy('m.id', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult()
;
}
/**
* @return list<int>
*/
public function findAllUidsByFolder(MailFolder $folder): array
{
$rows = $this->createQueryBuilder('m')
->select('m.uid')
->andWhere('m.folder = :folder')
->setParameter('folder', $folder)
->getQuery()
->getArrayResult()
;
return array_column($rows, 'uid');
}
/**
* Pagination cursor : retourne $limit messages apres le cursor (sentAt DESC, id DESC).
* Cursor format : base64url(sentAt_iso8601:id) - null pour la premiere page.
*
* @return array{messages: list<MailMessage>, nextCursor: ?string}
*/
public function findByFolderCursor(MailFolder $folder, int $limit, ?string $cursor): array
{
$qb = $this->createQueryBuilder('m')
->andWhere('m.folder = :folder')
->setParameter('folder', $folder)
->orderBy('m.sentAt', 'DESC')
->addOrderBy('m.id', 'DESC')
->setMaxResults($limit + 1)
;
if (null !== $cursor) {
$decoded = base64_decode(strtr($cursor, '-_', '+/'), true);
if (false !== $decoded && str_contains($decoded, ':')) {
[$sentAtStr, $idStr] = explode(':', $decoded, 2);
$cursorSentAt = DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $sentAtStr);
$cursorId = (int) $idStr;
if ($cursorSentAt instanceof DateTimeImmutable) {
$qb
->andWhere('m.sentAt < :cursorSentAt OR (m.sentAt = :cursorSentAt AND m.id < :cursorId)')
->setParameter('cursorSentAt', $cursorSentAt)
->setParameter('cursorId', $cursorId)
;
}
}
}
/** @var list<MailMessage> $results */
$results = $qb->getQuery()->getResult();
$hasMore = count($results) > $limit;
$messages = $hasMore ? array_slice($results, 0, $limit) : $results;
$nextCursor = null;
if ($hasMore && [] !== $messages) {
$last = end($messages);
$raw = $last->getSentAt()->format(DateTimeInterface::ATOM).':'.$last->getId();
$nextCursor = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
return ['messages' => $messages, 'nextCursor' => $nextCursor];
}
}
-57
View File
@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @extends ServiceEntityRepository<Notification>
*/
class NotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Notification::class);
}
public function createUserNotificationsQueryBuilder(UserInterface $user): QueryBuilder
{
return $this->createQueryBuilder('n')
->where('n.user = :user')
->setParameter('user', $user)
->orderBy('n.createdAt', 'DESC')
;
}
public function countUnreadByUser(User $user): int
{
return (int) $this->createQueryBuilder('n')
->select('COUNT(n.id)')
->where('n.user = :user')
->andWhere('n.isRead = false')
->setParameter('user', $user)
->getQuery()
->getSingleScalarResult()
;
}
public function markAllReadByUser(User $user): int
{
return $this->createQueryBuilder('n')
->update()
->set('n.isRead', 'true')
->where('n.user = :user')
->andWhere('n.isRead = false')
->setParameter('user', $user)
->getQuery()
->execute()
;
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Project;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ProjectRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Project::class);
}
}
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ShareConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ShareConfigurationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ShareConfiguration::class);
}
public function findSingleton(): ?ShareConfiguration
{
return $this->createQueryBuilder('s')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}
@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TaskBookStackLink;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TaskBookStackLinkRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskBookStackLink::class);
}
/** @return TaskBookStackLink[] */
public function findByTaskId(int $taskId): array
{
return $this->findBy(['task' => $taskId], ['createdAt' => 'DESC']);
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TaskEffort;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TaskEffortRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskEffort::class);
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TaskGroup;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TaskGroupRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskGroup::class);
}
}
-46
View File
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\MailMessage;
use App\Entity\Task;
use App\Entity\TaskMailLink;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TaskMailLinkRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskMailLink::class);
}
/**
* @return list<TaskMailLink>
*/
public function findByTask(Task $task): array
{
return $this->createQueryBuilder('l')
->andWhere('l.task = :task')
->setParameter('task', $task)
->orderBy('l.linkedAt', 'DESC')
->getQuery()
->getResult()
;
}
public function findByTaskAndMessage(Task $task, MailMessage $message): ?TaskMailLink
{
return $this->findOneBy(['task' => $task, 'mailMessage' => $message]);
}
/**
* @return list<TaskMailLink>
*/
public function findByMessage(MailMessage $message): array
{
return $this->findBy(['mailMessage' => $message]);
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TaskPriority;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TaskPriorityRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskPriority::class);
}
}
@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TaskRecurrence;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TaskRecurrenceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskRecurrence::class);
}
}
-44
View File
@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Project;
use App\Entity\Task;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Task>
*/
class TaskRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Task::class);
}
/**
* Returns the max task number for a project, using an advisory lock
* to prevent race conditions when creating tasks concurrently.
*/
public function findMaxNumberByProjectForUpdate(Project $project): int
{
$conn = $this->getEntityManager()->getConnection();
// Use PostgreSQL advisory lock (project ID as lock key) instead of FOR UPDATE
// because FOR UPDATE is not allowed with aggregate functions in PostgreSQL.
$conn->executeStatement(
'SELECT pg_advisory_xact_lock(:project)',
['project' => $project->getId()],
);
$result = $conn->fetchOne(
'SELECT COALESCE(MAX(number), 0) FROM task WHERE project_id = :project',
['project' => $project->getId()],
);
return (int) $result;
}
}
-29
View File
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TaskStatus;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TaskStatusRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskStatus::class);
}
public function findFirstNonFinal(): ?TaskStatus
{
return $this->createQueryBuilder('s')
->where('s.isFinal = :final')
->setParameter('final', false)
->orderBy('s.position', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TaskTag;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TaskTagRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskTag::class);
}
}
-75
View File
@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Project;
use App\Entity\TimeEntry;
use App\Entity\User;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TimeEntry>
*/
class TimeEntryRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TimeEntry::class);
}
public function findActiveByUser(User $user): ?TimeEntry
{
return $this->findOneBy([
'user' => $user,
'stoppedAt' => null,
]);
}
/**
* @param null|User[] $users
* @param null|Project[] $projects
* @param null|int[] $tagIds
*
* @return TimeEntry[]
*/
public function findForExport(
DateTimeImmutable $after,
DateTimeImmutable $before,
?array $users = null,
?array $projects = null,
?array $tagIds = null,
): array {
$qb = $this->createQueryBuilder('te')
->andWhere('te.startedAt >= :after')
->andWhere('te.startedAt < :before')
->setParameter('after', $after)
->setParameter('before', $before)
->orderBy('te.startedAt', 'ASC')
;
if (null !== $users && [] !== $users) {
$qb->andWhere('te.user IN (:users)')
->setParameter('users', $users)
;
}
if (null !== $projects && [] !== $projects) {
$qb->andWhere('te.project IN (:projects)')
->setParameter('projects', $projects)
;
}
if (null !== $tagIds && [] !== $tagIds) {
$qb->join('te.tags', 'tag')
->andWhere('tag.id IN (:tagIds)')
->setParameter('tagIds', $tagIds)
;
}
return $qb->getQuery()->getResult();
}
}
-62
View File
@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
/**
* @return User[]
*/
public function findByRole(string $role): array
{
$conn = $this->getEntityManager()->getConnection();
$sql = 'SELECT id FROM "user" WHERE roles::text LIKE :role';
$ids = $conn->executeQuery($sql, ['role' => '%"'.$role.'"%'])->fetchFirstColumn();
if ([] === $ids) {
return [];
}
return $this->createQueryBuilder('u')
->where('u.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult()
;
}
/**
* Employees active on the given date (hired on/before it, not yet left).
*
* @return User[]
*/
public function findActiveEmployees(DateTimeInterface $date): array
{
$dateStr = $date->format('Y-m-d');
return $this->createQueryBuilder('u')
->where('u.isEmployee = true')
->andWhere('u.hireDate IS NULL OR u.hireDate <= :date')
->andWhere('u.endDate IS NULL OR u.endDate >= :date')
->setParameter('date', $dateStr)
->orderBy('u.username', 'ASC')
->getQuery()
->getResult()
;
}
}
-25
View File
@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Workflow;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Workflow>
*/
class WorkflowRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Workflow::class);
}
public function findDefault(): ?Workflow
{
return $this->findOneBy(['isDefault' => true]);
}
}
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ZimbraConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ZimbraConfigurationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ZimbraConfiguration::class);
}
public function findSingleton(): ?ZimbraConfiguration
{
return $this->createQueryBuilder('z')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}