Jul6art Jul6art · Docs

Jul6art SaaS Admin

Comment démarrer

Ce guide s'adresse aux développeurs qui vont étendre ou personnaliser Jul6art SaaS Admin pour leur organisation ou leurs clients. Il complète la présentation commerciale en montrant comment utiliser concrètement les mécaniques du socle.


Sommaire

  1. Pré-requis & bootstrap
  2. Le modèle de permissions (module → rôle → user)
  3. Ajouter un module métier
  4. Le système d'événements (hooks)
  5. Paramètres par organisation (Settings)
  6. Temps réel Mercure
  7. Notifications
  8. Audit & purge
  9. Assistant LLM
  10. CMS — thèmes, shortcodes, menus
  11. API JSON-LD & autocomplete
  12. Tests
  13. Référence rapide des modules

1. Pré-requis & bootstrap

La stack s'exécute en Docker. Le dépôt contient un docker-compose.yml fonctionnel.

# Démarrer l'environnement
docker compose up -d

# Installer les dépendances PHP
docker compose exec php composer install

# Créer la base + appliquer les migrations + charger les fixtures
docker compose exec php bin/console doctrine:database:create
docker compose exec php bin/console doctrine:migrations:migrate --no-interaction
docker compose exec php bin/console doctrine:fixtures:load --no-interaction

# Build des assets
docker compose exec node yarn install
docker compose exec node yarn build

Applications accessibles :

  • Backoffice super-admin : http://jul6art.localhost:8080/admin
  • Backoffice organisation : http://{org_slug}.jul6art.localhost:8080/organization
  • Site public d'une org : http://{org_slug}.jul6art.localhost:8080/
  • API Platform : http://jul6art.localhost:8080/api
  • Mailpit (mails de test) : http://localhost:8025
  • Mercure : http://localhost:3000

Convention forte : toutes les commandes Symfony passent par Docker. Jamais php bin/console en local sans préfixe.


2. Le modèle de permissions

Trois couches évaluées dans cet ordre pour chaque route applicative :

2.1 Feature flag (kill switch module)

Un module est activé par organisation via OrganizationFeature. Une route protégée porte un attribut #[RequiresFeature] au niveau classe :

#[Route('/organization/cms')]
#[RequiresFeature('cms.manage')]
#[IsGranted('ROLE_USER')]
class CmsPageController extends AbstractController
{
    // ...
}

FeatureAccessSubscriber redirige automatiquement vers /access-denied si l'org n'a pas la feature. Côté Twig :

{% if is_feature_enabled('cms.manage') %}
    <a href="{{ path('app_organization_cms_page_index') }}">CMS</a>
{% endif %}

Activation via FeatureService::assign($org, 'cms.manage', $actor) — cascade les permissions et les settings par défaut automatiquement.

2.2 Permission code (action fine)

Chaque méthode mutante porte #[IsGranted(PermissionCodes::*)] :

#[Route('/{id}/publish', methods: ['POST'])]
#[IsGranted(PermissionCodes::CMS_PAGE_PUBLISH)]
public function publish(Page $page): Response
{
    $this->denyAccessUnlessGranted(PageVoter::PUBLISH, $page);
    $this->pageService->publish($page, $this->getUser()->getId());

    return $this->redirectWithSuccess('...', 'cms.page.flash.published');
}

Règle dure : chaque endpoint bulk-* porte aussi son #[IsGranted] — la boucle voter par entité est la 2ᵉ couche, pas la 1ʳᵉ.

2.3 Voter (ownership + scope)

Un Voter vérifie que l'utilisateur peut accéder à cette entité (appartenance à son org, règles métier). Tous les voters étendent AbstractVoter et réutilisent PermissionDecisionService :

final class PageVoter extends AbstractVoter
{
    public const VIEW = 'cms_page.view';
    public const PUBLISH = 'cms_page.publish';

    public function __construct(
        private readonly PermissionDecisionService $permissionDecisionService,
    ) {}

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user instanceof User or !$subject instanceof Page) {
            return false;
        }

        // Cross-org refusé
        if ($subject->getOrganization()->getId() !== $user->getOrganization()?->getId()) {
            return false;
        }

        return match ($attribute) {
            self::VIEW => $this->permissionDecisionService->isGranted($user, PermissionCodes::CMS_PAGE_READ),
            self::PUBLISH => $this->permissionDecisionService->isGranted($user, PermissionCodes::CMS_PAGE_PUBLISH),
            default => false,
        };
    }
}

2.4 Délégation user-override

Un admin d'org peut accorder ou retirer une permission à un utilisateur spécifique, au-delà de son rôle, via l'UI /organization/users/{id} ou programmatiquement :

$permissionDelegationService->grantPermissionToUser(
    $actor,              // qui fait l'override (doit lui-même avoir la permission)
    $targetUser,         // bénéficiaire
    PermissionCodes::CMS_PAGE_PUBLISH,
);

Le service refuse les tentatives cross-org et la délégation d'une permission que l'acteur n'a pas lui-même.

2.5 Rôles par défaut par organisation

À la création d'une org, RolePermissionSeederService::seedDefaultsFor() provisionne les matrices :

  • ROLE_ORGANIZATION_ADMIN reçoit les permissions complètes de chaque feature enabled.
  • ROLE_ORGANIZATION_MANAGER reçoit un sous-ensemble (read + create/update, pas delete/publish).
  • ROLE_USER reçoit cms:page:read, cms:blog:read, cms:media:read (selon les features).

Les matrices sont dans App\Core\Security\DefaultRolePermissions.


3. Ajouter un module métier

3.1 Squelette

Créez un dossier src/Modules/MonModule/ avec :

src/Modules/MonModule/
├── Controller/
│   ├── Admin/         # Super-admin cross-org
│   └── Organization/  # Scope tenant
├── Entity/
├── Form/
├── Repository/
├── Security/          # Voters
├── Service/           # Services métier + DataTableConfigProvider
├── Event/             # Events métier
├── EntityListener/    # Doctrine CRUD events
└── Setting/           # Defaults provider (si applicable)

3.2 Déclarer une feature

Ajoutez la feature à App\Modules\Feature\Service\PlanFeatureCatalog et à App\Core\Security\DefaultRolePermissions::getFeaturePermissions(). Créez une fixture dans FeatureFixtures si vous voulez qu'elle apparaisse automatiquement.

3.3 Déclarer les permissions

Ajoutez les constantes dans App\Core\Security\PermissionCodes (convention MODULE_RESOURCE_ACTIONcms:page:publish).

3.4 Créer l'entité

Pattern obligatoire :

#[ORM\Entity]
#[ORM\Table(name: 'mon_module_resource')]
#[ORM\UniqueConstraint(columns: ['organization_id', 'slug'])]
#[ORM\HasLifecycleCallbacks]
#[Auditable(ignoredFields: ['updatedAt', 'viewCount'])]
#[Notifiable(event: 'created', type: 'mon_module.resource.created', recipients: 'org_admins')]
#[ApiResource(
    operations: [/* ... */],
    normalizationContext: ['groups' => ['mon_module:read']],
    cacheHeaders: ['max_age' => 30, 'shared_max_age' => 120, 'public' => false],
)]
class Resource
{
    use TimestampableTrait;
    use SoftDeletableTrait;

    #[ORM\ManyToOne(targetEntity: Organization::class)]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private Organization $organization;

    // ...
}

HasOrganizationTrait + OrganizationFilter (Doctrine SQLFilter globale) garantissent l'isolation multi-tenant automatique.

3.5 Service métier

class ResourceService extends AbstractService
{
    public function __construct(
        AuditLogger $auditLogger,
        LoggerInterface $logger,
        EventDispatcherInterface $eventDispatcher,
        private readonly EntityManagerInterface $entityManager,
        private readonly ResourceRepository $repository,
    ) {
        parent::__construct($auditLogger, $logger, $eventDispatcher);
    }

    public function create(Organization $org, string $name, ?int $actorId = null): Resource
    {
        $resource = (new Resource())->setOrganization($org)->setName($name);

        $this->eventDispatcher->dispatch(new ResourceEvent($resource, $actorId), ResourceEvent::BEFORE_CREATED);

        $this->entityManager->persist($resource);
        $this->entityManager->flush();

        $this->auditLogger->log('resource.created', $org->getId(), $actorId, 'Resource', $resource->getId());
        $this->eventDispatcher->dispatch(new ResourceEvent($resource, $actorId), ResourceEvent::AFTER_CREATED);

        return $resource;
    }
}

3.6 Voter + controller

Copiez le pattern de PageVoter / CmsPageController. Le controller applique les 3 couches (feature + permission code + voter).

3.7 DataTable

Étendez AbstractDataTableConfigProvider pour définir colonnes, filtres, actions. Côté Twig, une seule ligne <table data-controller="core--datatable"> suffit — le contrôleur Stimulus gère recherche, tri, pagination, filtres Select2 AJAX, confirmations modales.


4. Le système d'événements (hooks)

Deux catégories :

4.1 Events CRUD (automatiques via Doctrine)

Créez un EntityListener dans le module qui étend AbstractEntityListener :

#[AsEntityListener(event: Events::prePersist, entity: Resource::class)]
#[AsEntityListener(event: Events::postPersist, entity: Resource::class)]
// ... updated, deleted
class ResourceEntityListener extends AbstractEntityListener
{
    protected function createEvent(object $entity, ?int $actorId): AbstractEntityEvent
    {
        return new ResourceEvent($entity, $actorId);
    }

    protected function getBeforeCreatedEventName(): string { return ResourceEvent::BEFORE_CREATED; }
    protected function getAfterCreatedEventName(): string { return ResourceEvent::AFTER_CREATED; }
    // ...
}

Les events se déclenchent automatiquement, peu importe l'origine (service, fixture, import CSV, API Platform).

4.2 Events métier (explicites)

Dans le service :

$event = new ResourceEvent($resource, $actorId);
$this->eventDispatcher->dispatch($event, ResourceEvent::BEFORE_PUBLISHED);

if ($event->isAborted()) {
    throw new \DomainException($event->getAbortReason() ?? 'resource.publish.aborted');
}

$resource->setStatus(Status::Published);
$this->entityManager->flush();

$this->eventDispatcher->dispatch($event, ResourceEvent::AFTER_PUBLISHED);

4.3 Écrire un listener pour personnaliser

Dans un src/Modules/MonClient/EventListener/ :

#[AsEventListener(event: PageEvent::AFTER_PUBLISHED)]
final class SyncPageToExternalCrmListener
{
    public function __invoke(PageEvent $event): void
    {
        $page = $event->getPage();
        // push vers un CRM externe, un Data Warehouse, un Slack...
        $this->externalClient->publish($page);
    }
}

Pas de fork du cœur. Pas de conflit à chaque upgrade.

4.4 #[Notifiable] — notifier sans écrire de code

#[Notifiable(
    event: 'published',
    type: 'cms.page.published',
    titleKey: 'notification.cms.page.published.title',
    messageKey: 'notification.cms.page.published.message',
    recipients: 'org_admins',  // ou 'super_admins', 'org_members', ou une stratégie custom
)]
class Page { /* ... */ }

Dispatchez NotificationService::notify('published', $page) dans votre service métier → le système crée la notif + push Mercure sur le topic du destinataire.


5. Paramètres par organisation (Settings)

5.1 Déclarer les defaults d'un module

// src/Modules/MonModule/Setting/MonModuleSettingsDefaults.php
final class MonModuleSettingsDefaults implements ModuleSettingsDefaultsInterface
{
    public static function featureCode(): string { return 'mon_module.manage'; }
    public static function moduleKey(): string   { return 'mon_module'; }

    public static function defaults(): array
    {
        return [
            new SettingDefault('mon_module.quota', '100', SettingScope::Organization),
            new SettingDefault('mon_module.allow_export', '1', SettingScope::Organization),
        ];
    }
}

Classe taguée automatiquement (_instanceof dans services.yaml). Activation de la feature → rows Setting créées idempotent. Désactivation → suppression DQL.

5.2 Lire

Service dédié par module (pattern CmsSettingsReader, LlmSettingsReader) :

class MonModuleSettingsReader
{
    public function __construct(private readonly SettingResolver $settingResolver) {}

    public function getQuota(Organization $org): int
    {
        $raw = $this->settingResolver->resolve('mon_module.quota', $org, 'mon_module');
        return \is_numeric($raw) ? (int) $raw : 100;
    }
}

En Twig :

{{ setting('mon_module.quota', 'mon_module') }}

5.3 Cascade

module → organization → global → default de code. La valeur renvoyée est toujours un string — chaque reader cast selon son type.

5.4 Prefetch

Les features et settings de l'org courante sont chargés en 2 queries au début de chaque requête HTTP (pour les users non super-admin) via TenantContextBag. Toute lecture postérieure est en mémoire.


6. Temps réel Mercure

6.1 Marquer une entité comme broadcastable

#[BroadcastableEntity(tenantScope: 'organization')]
class Resource { /* ... */ }

Publie automatiquement sur /organizations/{orgId}/feed à chaque postPersist/postUpdate/postRemove.

6.2 Écouter côté JS

Le mixin Stimulus mercurable fait tout :

import { Controller } from '@hotwired/stimulus';
import { useMercurable } from '../../mixins/mercurable';

export default class extends Controller {
    connect() {
        useMercurable(this);
        this.subscribe('/api/resources/{id}', (event) => this._onUpdate(event));
    }
}

Auto-unsubscribe au disconnect(). Un seul EventSource partagé pour tous les topics de la page.

6.3 DataTable auto-refresh

core--datatable s'abonne automatiquement aux topics du resource principal et des IRI référencées, invalide le cache, recharge avec debounce 500ms. Zéro configuration.


7. Notifications

  1. Pose #[Notifiable] sur l'entité.
  2. Appelle $this->notificationService->notify('event_name', $entity) dans le service après flush().
  3. Le dropdown cloche se met à jour via Mercure sur /notifications/{userId}.

La linkUrl est pré-calculée selon le rôle du destinataire (admin → /admin/..., org → /organization/...).


8. Audit & purge

8.1 Audit

#[Auditable(ignoredFields: ['updatedAt', 'viewCount'])]
class Resource { /* ... */ }

Les events Doctrine postPersist/postUpdate/postRemove créent automatiquement un AuditLog avec diff des champs modifiés (hors ignoredFields). Les appels manuels AuditLogger::log() dans les services couvrent en plus les actions métier spécifiques (ex. cms.page.published).

8.2 Purge

#[Purgeable(field: 'createdAt', interval: '-3 months')]
class AuditLog { /* ... */ }

#[Purgeable(
    field: 'createdAt',
    interval: '-1 month',
    condition: 'entity.isRead() == true',
)]
class Notification { /* ... */ }

Commande :

docker compose exec php bin/console app:purge --dry-run
docker compose exec php bin/console app:purge

Chaque purge crée une ligne d'audit — cycle auto-nettoyé puisque AuditLog est lui-même Purgeable.


9. Assistant LLM

9.1 Activer sur une org

Super-admin :

  1. Active la feature llm.writing sur l'org (/admin/organizations/{id} → onglet Features).
  2. Les settings llm.writing.* apparaissent dans /admin/settings/modules/llm et sont auto-provisionnés avec les valeurs par défaut (enabled=0, allowed_actions=[rewrite, correct, translate, summarize, continue, custom], default_tone=neutral, rate_limit=60/h).

Admin org :

  1. Ouvre /organization/llm/config → active enabled=1.
  2. Choisit les actions autorisées.

9.2 Brancher l'assistant sur un éditeur Trix

Le bouton ✨ est injecté automatiquement sur tout <trix-editor> porteur du Stimulus controller cms--editor. Rien à faire côté dev — l'assistant détecte la langue, le contexte fonctionnel, et propose les actions autorisées.

9.3 Créer un contexte LLM custom

// src/Modules/MonModule/Llm/MonModuleWritingContext.php
final class MonModuleWritingContext extends AbstractContext
{
    public function getCode(): string { return 'mon_module.description'; }
    public function getLabelKey(): string { return 'mon_module.llm.context.description'; }
    public function getSystemPrompt(): string
    {
        return 'Tu es assistant pour la rédaction de descriptions de resources. ...';
    }
}

Taguée llm.writing_context automatiquement. Apparaît dans le menu du bouton ✨.


10. CMS — thèmes, shortcodes, menus

10.1 Hiérarchie de templates

L'ordre de résolution :

  1. templates/public/themes/{theme}/page/{slug}.html.twig → override par page
  2. templates/public/themes/{theme}/org/{org-slug}/page.html.twig → override par org
  3. templates/public/themes/{theme}/page.html.twig → défaut du thème

Créer un fichier au chemin 1 ou 2 suffit à activer l'override. Aucun config.

10.2 Ajouter un thème

  1. Copie templates/public/themes/starter/templates/public/themes/mon-theme/.
  2. Ajoute la case dans App\Modules\Cms\Enum\BaseTheme.
  3. L'admin org peut sélectionner le nouveau thème dans /organization/cms/theme.

10.3 Shortcodes

Ajouter un nouveau shortcode :

// src/Modules/MonModule/Twig/MonModuleShortcodeExtension.php
final class MonModuleShortcodeExtension extends AbstractExtension
{
    public function getFilters(): array
    {
        return [new TwigFilter('mon_module_shortcodes', $this->process(...), ['is_safe' => ['html']])];
    }

    public function process(string $content, Organization $organization): string
    {
        return \preg_replace_callback(
            '/\[mon-module-widget slug="([^"]+)"\]/',
            fn(array $m) => $this->renderWidget($m[1], $organization),
            $content,
        );
    }
}

Puis chainer dans le template :

{{ page.content|cms_shortcodes(organization)|mon_module_shortcodes(organization)|raw }}

10.4 Media & sanitization

MediaService::upload applique MIME whitelist + quota + per-file size limit, tous configurables via Setting. PageService::create/update passe le contenu par symfony/html-sanitizer avec le profile cms.editorial avant persist — XSS impossible même si l'éditeur WYSIWYG fuit.


11. API JSON-LD & autocomplete

Toute entité #[ApiResource] est exposée avec JWT + JSON-LD. Le filter Doctrine multi-tenant est appliqué — impossible pour un user d'une org A de lire les ressources de l'org B via l'API.

11.1 Ajouter un <select> Select2 AJAX

Deux types prêts :

  • OrganizationFormType → autocomplete organisations
  • UserFormType → autocomplete utilisateurs (affiche fullName, cherche sur email+firstName+lastName)
$builder->add('assignee', UserFormType::class, [
    'label' => 'Assigné à',
    'required' => false,
]);

Lazy loading : zéro row au render, AJAX à la frappe. L'entité bindée apparaît comme <option selected> automatiquement.

11.2 Créer un autocomplete sur une autre entité

Copie le pattern de LazyOrganizationChoiceLoader + OrganizationFormType. Le JWT est déjà exposé globalement par JwtTokenExtension (emis dans le <head> de chaque layout authentifié).


12. Tests

12.1 Lancer

docker compose exec -e APP_ENV=test \
  -e DATABASE_URL="postgresql://backoffice:backoffice@db:5432/backoffice_test?serverVersion=16&charset=utf8" \
  -e REGISTRATION_ENABLED=true \
  php php -d memory_limit=1G vendor/bin/phpunit

12.2 Conventions

  • TDD requis sur toute feature non-triviale : test qui échoue d'abord, code qui fait passer, refactor.
  • Tests unitaires : services, voters, resolvers, subscribers.
  • Tests fonctionnels : parcours HTTP complet avec WebTestCase.
  • API : utiliser ApiTestCase::API_HEADERS (Content-Type: application/ld+json). Format réponse : member/totalItems (pas hydra:*).

12.3 Suite de qualité

# PHPStan niveau 8
docker compose exec php vendor/bin/phpstan analyze --no-progress

# Lint Twig + YAML
docker compose exec php bin/console lint:twig templates
docker compose exec php bin/console lint:yaml translations config

# Audit sécu dépendances
docker compose exec php composer audit

13. Référence rapide des modules

Module Doc plan Feature code
CMS docs/modules/cms.md cms.manage
CRM docs/modules/crm.md crm.manage
ERP docs/modules/erp.md erp.invoicing, erp.products, erp.inventory, …
SIRH docs/modules/sirh.md sirh.core, sirh.leave, sirh.payroll, …
Agenda docs/modules/agenda.md agenda.manage
Emailing docs/modules/emailing.md emailing.manage
Reporting docs/modules/reporting.md reporting.manage
LLM Writing docs/modules/llm.md llm.writing
Chat docs/modules/chat.md chat.manage
Document (GED) docs/modules/document.md document.manage

Docs transverses

Règles projet (à lire absolument)


Contact tech

Pour toute question architecturale, consulter les ADR (docs/adr/) en premier. Pour un support custom, passez par le contact technique désigné dans votre contrat.


Bon dev 🚀