Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s
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:
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user