From 3e9d441514f97fb6795f25ee3ea5ea92238a6393 Mon Sep 17 00:00:00 2001 From: eliot lauger Date: Tue, 28 Jul 2026 12:13:17 +0200 Subject: [PATCH 1/3] feat(nodes-sources): add scheduled unpublishedAt expiration field Add a built-in, nullable unpublishedAt datetime on NodesSources, symmetrical to publishedAt, so editors can schedule content expiration. Content is public only when publishedAt <= now AND (unpublishedAt IS NULL OR unpublishedAt > now). - NodeType: new opt-in `unpublishable` flag (entity, config, tree DTO) - Entity: unpublishedAt column + indexes, isPublished()/isUnpublishable() - Repository + API Platform (nodes-sources, node, tags) visibility filtering - Solr: index unpublished_at_dt and exclude expired content from default search - Rozier: form field, node-tree badge (threaded through tree DTOs), fr/en labels - EntityGenerator: isUnpublishable() proxy; regenerated NS entities - Bump roadiz/nodetype-contracts to ^4.0.0 (isUnpublishable interface method) - Core migration adds the column/indexes (guarded); docs + UPGRADE guidance --- UPGRADE.md | 62 ++++++++++++++++++ composer.json | 2 +- config/node_types/article.yaml | 7 +- docs/developer/nodes-system/node_types.md | 5 ++ docs/developer/nodes-system/nodes.md | 17 +++++ lib/DocGenerator/composer.json | 2 +- lib/DtsGenerator/composer.json | 2 +- lib/EntityGenerator/composer.json | 2 +- lib/EntityGenerator/src/EntityGenerator.php | 8 +++ .../Mocks/GeneratedNodesSources/NSMock.php | 10 +++ .../NSMock.php | 10 +++ .../NSMock.php | 10 +++ .../tests/NodeTypeAwareTestTrait.php | 6 ++ lib/Models/composer.json | 2 +- lib/RoadizCoreBundle/composer.json | 2 +- .../migrations/Version20260713120000.php | 65 +++++++++++++++++++ .../src/Api/Extension/NodeQueryExtension.php | 5 ++ .../Extension/NodesSourcesQueryExtension.php | 5 ++ .../src/Api/Filter/NodesTagsFilter.php | 5 ++ .../NodesSourcesInheritanceSubscriber.php | 5 ++ lib/RoadizCoreBundle/src/Entity/NodeType.php | 14 ++++ .../src/Entity/NodesSources.php | 40 +++++++++++- .../Constraint/NodeSourceReservedName.php | 3 + .../src/Model/NodeTreeDto.php | 2 + .../src/Model/NodeTypeTreeDto.php | 7 ++ .../src/Model/NodesSourcesTreeDto.php | 6 ++ .../Configuration/NodeTypeConfiguration.php | 1 + .../src/Repository/NodeRepository.php | 4 +- .../src/Repository/NodesSourcesRepository.php | 6 ++ .../Form/NodeSource/NodeSourceBaseType.php | 18 +++++ .../src/Form/NodeSource/NodeSourceType.php | 1 + .../widgets/nodeTree/singleNode.html.twig | 11 ++++ .../translations/rozier/messages.en.xlf | 10 +++ .../translations/rozier/messages.fr.xlf | 10 +++ .../DefaultNodesSourcesIndexingSubscriber.php | 4 ++ .../src/NodeSourceSearchHandler.php | 46 +++++++++++++ .../app/assets/less/widgets/nestable.less | 38 +++++++++++ src/GeneratedEntity/NSAliasBlock.php | 10 +++ src/GeneratedEntity/NSArticle.php | 39 +++-------- src/GeneratedEntity/NSArticleContainer.php | 10 +++ src/GeneratedEntity/NSArticleFeedBlock.php | 10 +++ src/GeneratedEntity/NSBasicBlock.php | 10 +++ src/GeneratedEntity/NSGroupBlock.php | 10 +++ src/GeneratedEntity/NSMenu.php | 10 +++ src/GeneratedEntity/NSMenuLink.php | 10 +++ src/GeneratedEntity/NSNeutral.php | 10 +++ src/GeneratedEntity/NSOffer.php | 10 +++ src/GeneratedEntity/NSPage.php | 10 +++ 48 files changed, 549 insertions(+), 43 deletions(-) create mode 100644 lib/RoadizCoreBundle/migrations/Version20260713120000.php diff --git a/UPGRADE.md b/UPGRADE.md index da89cfefd..706991446 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -19,6 +19,8 @@ - RealmsAwareWebResponseInterface - Removed obsolete `roadiz/fonts-bundle` - Removed `getFontsFilesPath` and `getFontsFilesBasePath` methods from `RZ\Roadiz\Documents\Models\FileAwareInterface` +- `NodesSources` now ships a **built-in `unpublishedAt`** date-time field and `unpublishedAt` becomes a **reserved node-type field name**. Projects that already declared an `unpublished_at` (as a custom node-type field, or as a project-level column) must migrate — see [Built-in `unpublishedAt` scheduled expiration field](#built-in-unpublishedat-scheduled-expiration-field). +- `NodeTypeInterface` gained an `isUnpublishable(): bool` method. Any custom implementation must add it. ## New custom-form webhook system @@ -28,6 +30,66 @@ - Field mapping and provider settings are configured per form in the admin UI; this controls how form fields map to provider-specific fields. - The system is idempotent per CustomFormAnswer ID and uses Messenger retry policies on failure +## Built-in `unpublishedAt` scheduled expiration field + +`NodesSources` now provides a built-in, nullable `unpublishedAt` date-time column (`nodes_sources.unpublished_at`), +symmetrical to `publishedAt`. It lets editors schedule content **expiration**: a node-source is publicly +visible only when + +``` +node.status = PUBLISHED +AND publishedAt <= now +AND (unpublishedAt IS NULL OR unpublishedAt > now) +``` + +Enable it per node-type with the new `unpublishable: true` option (mirroring `publishable`). `unpublishedAt` +defaults to `null` (*never expires*), so enabling it is backward-compatible for existing content. + +A core bundle migration adds the column and its indexes. It is **guarded**: if `nodes_sources.unpublished_at` +already exists it is a no-op, so it will not clash with a column you added yourself. + +### If your project already has an `unpublished_at` + +`unpublishedAt` is now a reserved name and a built-in property, so an existing project-level `unpublished_at` +**will collide** (a custom node-type field named `unpublished_at` would generate a duplicate `$unpublishedAt` +property on the generated entity). You must remove the legacy definition and reconcile the schema: + +1. **Remove the custom field from your node-type(s).** Delete the `unpublished_at` field from every + `config/node_types/*.yaml` (or from the node-type definition in database), then regenerate entities: + + ```bash + bin/console app:node-types:regenerate # or your project's node-type sync/update command + ``` + +2. **Add a project migration** (`bin/console make:migration`, then adjust it) to preserve legacy data and + drop the legacy schema. Adapt the table name(s) to your node-type(s): + + ```php + public function up(Schema $schema): void + { + // Copy legacy per-node-type values up into the built-in column, then drop the custom column. + if ($schema->hasTable('ns_article') && $schema->getTable('ns_article')->hasColumn('unpublished_at')) { + $this->addSql('UPDATE nodes_sources ns INNER JOIN ns_article a ON a.id = ns.id SET ns.unpublished_at = a.unpublished_at WHERE a.unpublished_at IS NOT NULL'); + $this->addSql('ALTER TABLE ns_article DROP unpublished_at'); + } + + // If you previously added a project-level column/index on nodes_sources, drop the redundant index + // so it does not conflict with the built-in one created by the core bundle migration. + if ($schema->getTable('nodes_sources')->hasIndex('nsapp_unpublished_at')) { + $this->addSql('DROP INDEX nsapp_unpublished_at ON nodes_sources'); + } + } + ``` + +3. **Run the migrations** and verify the schema is in sync: + + ```bash + bin/console doctrine:migrations:migrate + bin/console doctrine:schema:validate + ``` + +If you implement `NodeTypeInterface` yourself, also add the new `isUnpublishable(): bool` method. + ## Other changes - Roadiz can integrate with external translation services to automatically translate Markdown fields. diff --git a/composer.json b/composer.json index 542d97b48..7b2277259 100644 --- a/composer.json +++ b/composer.json @@ -80,7 +80,7 @@ "rezozero/intervention-request-bundle": "^5.1", "rezozero/liform-bundle": "^0.20.1", "rezozero/tree-walker": "^1.7.0", - "roadiz/nodetype-contracts": "^3.1.1", + "roadiz/nodetype-contracts": "^4.0.0", "scheb/2fa-backup-code": "^7.5", "scheb/2fa-bundle": "^7.5", "scheb/2fa-google-authenticator": "^7.5", diff --git a/config/node_types/article.yaml b/config/node_types/article.yaml index 92737843e..c46b18568 100644 --- a/config/node_types/article.yaml +++ b/config/node_types/article.yaml @@ -4,6 +4,7 @@ displayName: Article description: Article visible: true publishable: true +unpublishable: true attributable: true sortingAttributesByWeight: false reachable: true @@ -40,12 +41,6 @@ fields: - GroupBlock - AliasBlock type: children-nodes - - - name: unpublished_at - universal: true - indexed: true - label: 'Date de dépublication' - type: date-time - name: only_on_webresponse serializationGroups: diff --git a/docs/developer/nodes-system/node_types.md b/docs/developer/nodes-system/node_types.md index 9a55f53e2..fde025433 100644 --- a/docs/developer/nodes-system/node_types.md +++ b/docs/developer/nodes-system/node_types.md @@ -31,8 +31,13 @@ To add a new node type, follow these steps: visible: true # 'publishable' is an optional boolean. + # When true, node-sources get a "publishedAt" date-time field (scheduled publication). publishable: false + # 'unpublishable' is an optional boolean. + # When true, node-sources get an "unpublishedAt" date-time field (scheduled expiration). + unpublishable: false + # 'attributable' is an optional boolean. attributable: true diff --git a/docs/developer/nodes-system/nodes.md b/docs/developer/nodes-system/nodes.md index 6c9069dcf..28671fef1 100644 --- a/docs/developer/nodes-system/nodes.md +++ b/docs/developer/nodes-system/nodes.md @@ -99,6 +99,23 @@ There are two parameters that you must take care of in your themes and your cont For example, *publication date and time* won’t be necessary in plain text pages and non-timestampable contents. But we decided to add it directly to the `NodesSources` entity to be able to filter and order with this field in the Roadiz back office. This would not be possible if you manually created your own `publishedAt` as a node-type field. +### Scheduled publication window (`publishedAt` / `unpublishedAt`) + +Node-sources expose two optional, symmetric date-time fields that define a **publication window**: + +- `publishedAt` — enabled when the node-type is `publishable`. Content becomes visible once `publishedAt <= now`. +- `unpublishedAt` — enabled when the node-type is `unpublishable`. Content becomes hidden again once `unpublishedAt` is reached. + +A node-source is considered publicly visible (outside preview mode) when **all** of the following hold: + +``` +node.status = PUBLISHED +AND publishedAt <= now +AND (unpublishedAt IS NULL OR unpublishedAt > now) +``` + +`unpublishedAt` is nullable and defaults to `null`, meaning *never expires* — so enabling `unpublishable` is fully backward-compatible with existing content. Both fields are applied consistently by the node-source repository, the API Platform query extensions and `NodesSources::isPublished()`. As with `publishedAt`, preview mode bypasses the date-time gate so editors can review expired or not-yet-published content. + ::: warning Pay attention that *publication date and time* (`publishedAt`) and visibility (`node.visible`) **do not prevent** your node-source from being viewed if you do not explicitly forbid access to its controller. This field is not deeply set into Roadiz security mechanics. diff --git a/lib/DocGenerator/composer.json b/lib/DocGenerator/composer.json index 9cc86fbeb..d8e069f37 100644 --- a/lib/DocGenerator/composer.json +++ b/lib/DocGenerator/composer.json @@ -4,7 +4,7 @@ "type": "library", "require": { "php": ">=8.3", - "roadiz/nodetype-contracts": "^3.1.1", + "roadiz/nodetype-contracts": "^4.0.0", "symfony/translation": "7.4.*", "symfony/http-foundation": "7.4.*" }, diff --git a/lib/DtsGenerator/composer.json b/lib/DtsGenerator/composer.json index d25b38711..a2b594ffa 100644 --- a/lib/DtsGenerator/composer.json +++ b/lib/DtsGenerator/composer.json @@ -3,7 +3,7 @@ "description": "Roadiz sub-package which generates Typescript interfaces skeleton based on your schema", "type": "library", "require": { - "roadiz/nodetype-contracts": "^3.1.1", + "roadiz/nodetype-contracts": "^4.0.0", "symfony/http-foundation": "7.4.*" }, "require-dev": { diff --git a/lib/EntityGenerator/composer.json b/lib/EntityGenerator/composer.json index 2df32fa3a..c029ef2b7 100644 --- a/lib/EntityGenerator/composer.json +++ b/lib/EntityGenerator/composer.json @@ -15,7 +15,7 @@ "php": ">=8.3", "ext-json": "*", "nette/php-generator": "^4.1", - "roadiz/nodetype-contracts": "^3.1.1", + "roadiz/nodetype-contracts": "^4.0.0", "symfony/string": "7.4.*", "symfony/yaml": "7.4.*", "symfony/serializer": "7.4.*", diff --git a/lib/EntityGenerator/src/EntityGenerator.php b/lib/EntityGenerator/src/EntityGenerator.php index 57d343075..c7859a2ee 100644 --- a/lib/EntityGenerator/src/EntityGenerator.php +++ b/lib/EntityGenerator/src/EntityGenerator.php @@ -323,6 +323,14 @@ private function addClassMethods(ClassType $classType): self ->setBody('return '.($this->nodeType->isPublishable() ? 'true' : 'false').';') ; + $classType->addMethod('isUnpublishable') + ->addComment('$this->nodeType->isUnpublishable() proxy.') + ->addComment('@return bool Does this nodeSource is unpublishable with date and time?') + ->addAttribute(\Override::class) + ->setReturnType('bool') + ->setBody('return '.($this->nodeType->isUnpublishable() ? 'true' : 'false').';') + ; + $classType->addMethod('__toString') ->setReturnType('string') ->addAttribute(\Override::class) diff --git a/lib/EntityGenerator/tests/Mocks/GeneratedNodesSources/NSMock.php b/lib/EntityGenerator/tests/Mocks/GeneratedNodesSources/NSMock.php index 43aa2626f..83833ff25 100644 --- a/lib/EntityGenerator/tests/Mocks/GeneratedNodesSources/NSMock.php +++ b/lib/EntityGenerator/tests/Mocks/GeneratedNodesSources/NSMock.php @@ -1260,6 +1260,16 @@ public function isPublishable(): bool return true; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return true; + } + #[\Override] public function __toString(): string { diff --git a/lib/EntityGenerator/tests/Mocks/GeneratedNodesSourcesWithDocumentDto/NSMock.php b/lib/EntityGenerator/tests/Mocks/GeneratedNodesSourcesWithDocumentDto/NSMock.php index 39befbf4d..7cf05b80d 100644 --- a/lib/EntityGenerator/tests/Mocks/GeneratedNodesSourcesWithDocumentDto/NSMock.php +++ b/lib/EntityGenerator/tests/Mocks/GeneratedNodesSourcesWithDocumentDto/NSMock.php @@ -121,6 +121,16 @@ public function isPublishable(): bool return true; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return true; + } + #[\Override] public function __toString(): string { diff --git a/lib/EntityGenerator/tests/Mocks/GeneratedNodesSourcesWithRepository/NSMock.php b/lib/EntityGenerator/tests/Mocks/GeneratedNodesSourcesWithRepository/NSMock.php index 9e7cb5097..66815627b 100644 --- a/lib/EntityGenerator/tests/Mocks/GeneratedNodesSourcesWithRepository/NSMock.php +++ b/lib/EntityGenerator/tests/Mocks/GeneratedNodesSourcesWithRepository/NSMock.php @@ -1260,6 +1260,16 @@ public function isPublishable(): bool return true; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return true; + } + #[\Override] public function __toString(): string { diff --git a/lib/EntityGenerator/tests/NodeTypeAwareTestTrait.php b/lib/EntityGenerator/tests/NodeTypeAwareTestTrait.php index a574024cd..76fb948ae 100644 --- a/lib/EntityGenerator/tests/NodeTypeAwareTestTrait.php +++ b/lib/EntityGenerator/tests/NodeTypeAwareTestTrait.php @@ -292,6 +292,9 @@ classname: Themes\MyTheme\Entities\PositionedCity $mockNodeType ->method('isPublishable') ->willReturn(true); + $mockNodeType + ->method('isUnpublishable') + ->willReturn(true); return $mockNodeType; } @@ -329,6 +332,9 @@ protected function getMockDocumentNodeType(): NodeTypeInterface $mockNodeType ->method('isPublishable') ->willReturn(true); + $mockNodeType + ->method('isUnpublishable') + ->willReturn(true); return $mockNodeType; } diff --git a/lib/Models/composer.json b/lib/Models/composer.json index b063a894a..63939fe68 100644 --- a/lib/Models/composer.json +++ b/lib/Models/composer.json @@ -22,7 +22,7 @@ "doctrine/orm": "~2.20.0", "api-platform/doctrine-orm": "^4.1.18", "api-platform/metadata": "^4.1.18", - "roadiz/nodetype-contracts": "^3.1.1", + "roadiz/nodetype-contracts": "^4.0.0", "symfony/string": "7.4.*", "symfony/translation-contracts": "^3.0", "symfony/http-foundation": "7.4.*", diff --git a/lib/RoadizCoreBundle/composer.json b/lib/RoadizCoreBundle/composer.json index 92a376fdf..782656556 100644 --- a/lib/RoadizCoreBundle/composer.json +++ b/lib/RoadizCoreBundle/composer.json @@ -47,7 +47,7 @@ "roadiz/jwt": "2.7.*", "roadiz/markdown": "2.7.*", "roadiz/models": "2.7.*", - "roadiz/nodetype-contracts": "^3.1.1", + "roadiz/nodetype-contracts": "^4.0.0", "roadiz/random": "2.7.*", "scienta/doctrine-json-functions": "^4.2", "symfony-cmf/routing-bundle": "^3.1.0", diff --git a/lib/RoadizCoreBundle/migrations/Version20260713120000.php b/lib/RoadizCoreBundle/migrations/Version20260713120000.php new file mode 100644 index 000000000..a722268bd --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20260713120000.php @@ -0,0 +1,65 @@ + now OR unpublished_at IS NULL). + * + * BC NOTE: this migration is guarded so it is a no-op on projects that already own an + * unpublished_at column on nodes_sources (e.g. added by a project-level migration or by a + * custom node-type field). Projects that previously declared a custom "unpublished_at" + * node-type field must remove that field from their node-type definitions, regenerate their + * entities and add a project migration copying the legacy per-type values into + * nodes_sources.unpublished_at before dropping the old per-type column. + */ +final class Version20260713120000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add built-in unpublished_at field and indexes on nodes_sources for scheduled content expiration.'; + } + + public function up(Schema $schema): void + { + $table = $schema->getTable('nodes_sources'); + + if (!$table->hasColumn('unpublished_at')) { + $this->addSql('ALTER TABLE nodes_sources ADD unpublished_at DATETIME DEFAULT NULL'); + } + if ($table->hasIndex('nsapp_unpublished_at')) { + $this->addSql('DROP INDEX nsapp_unpublished_at ON nodes_sources'); + } + $this->addSql('CREATE INDEX ns_unpublished_at ON nodes_sources (unpublished_at)'); + + if (!$table->hasIndex('ns_node_translation_unpublished')) { + $this->addSql('CREATE INDEX ns_node_translation_unpublished ON nodes_sources (node_id, translation_id, unpublished_at)'); + } + if (!$table->hasIndex('ns_node_discr_translation_unpublished')) { + $this->addSql('CREATE INDEX ns_node_discr_translation_unpublished ON nodes_sources (node_id, discr, translation_id, unpublished_at)'); + } + if (!$table->hasIndex('ns_discr_translation_unpublished')) { + $this->addSql('CREATE INDEX ns_discr_translation_unpublished ON nodes_sources (discr, translation_id, unpublished_at)'); + } + if (!$table->hasIndex('ns_title_translation_unpublished')) { + $this->addSql('CREATE INDEX ns_title_translation_unpublished ON nodes_sources (title, translation_id, unpublished_at)'); + } + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX ns_unpublished_at ON nodes_sources'); + $this->addSql('DROP INDEX ns_node_translation_unpublished ON nodes_sources'); + $this->addSql('DROP INDEX ns_node_discr_translation_unpublished ON nodes_sources'); + $this->addSql('DROP INDEX ns_discr_translation_unpublished ON nodes_sources'); + $this->addSql('DROP INDEX ns_title_translation_unpublished ON nodes_sources'); + $this->addSql('ALTER TABLE nodes_sources DROP unpublished_at'); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php b/lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php index f6d7d0dba..05bd420cc 100644 --- a/lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php +++ b/lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php @@ -65,8 +65,13 @@ private function apply( ); $queryBuilder ->andWhere($queryBuilder->expr()->lte($alias.'.publishedAt', ':lte_published_at')) + ->andWhere($queryBuilder->expr()->orX( + $queryBuilder->expr()->gt($alias.'.unpublishedAt', ':gt_unpublished_at'), + $queryBuilder->expr()->isNull($alias.'.unpublishedAt') + )) ->andWhere($queryBuilder->expr()->eq('o.status', ':status')) ->setParameter(':lte_published_at', new \DateTime()) + ->setParameter(':gt_unpublished_at', new \DateTime()) ->setParameter(':status', NodeStatus::PUBLISHED); } diff --git a/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesQueryExtension.php b/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesQueryExtension.php index d7bf69287..6e63def55 100644 --- a/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesQueryExtension.php +++ b/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesQueryExtension.php @@ -88,8 +88,13 @@ private function apply( $queryBuilder ->andWhere($queryBuilder->expr()->lte('o.publishedAt', ':lte_published_at')) + ->andWhere($queryBuilder->expr()->orX( + $queryBuilder->expr()->gt('o.unpublishedAt', ':gt_unpublished_at'), + $queryBuilder->expr()->isNull('o.unpublishedAt') + )) ->andWhere($queryBuilder->expr()->eq($alias.'.status', ':status')) ->setParameter(':lte_published_at', new \DateTime()) + ->setParameter(':gt_unpublished_at', new \DateTime()) ->setParameter(':status', NodeStatus::PUBLISHED); } } diff --git a/lib/RoadizCoreBundle/src/Api/Filter/NodesTagsFilter.php b/lib/RoadizCoreBundle/src/Api/Filter/NodesTagsFilter.php index 78365e121..c6237464d 100644 --- a/lib/RoadizCoreBundle/src/Api/Filter/NodesTagsFilter.php +++ b/lib/RoadizCoreBundle/src/Api/Filter/NodesTagsFilter.php @@ -134,9 +134,14 @@ private function alterQueryBuilder(QueryBuilder $queryBuilder, array $parameters $ntgQb ->innerJoin('n.nodeSources', 'ns') ->andWhere($ntgQb->expr()->lte('ns.publishedAt', ':lte_published_at')) + ->andWhere($ntgQb->expr()->orX( + $ntgQb->expr()->gt('ns.unpublishedAt', ':gt_unpublished_at'), + $ntgQb->expr()->isNull('ns.unpublishedAt') + )) ->andWhere($ntgQb->expr()->eq('n.status', ':status')); $queryBuilder ->setParameter(':lte_published_at', new \DateTime()) + ->setParameter(':gt_unpublished_at', new \DateTime()) ->setParameter(':status', NodeStatus::PUBLISHED); } diff --git a/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/NodesSourcesInheritanceSubscriber.php b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/NodesSourcesInheritanceSubscriber.php index 8363a2a49..ae5c528a6 100644 --- a/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/NodesSourcesInheritanceSubscriber.php +++ b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/NodesSourcesInheritanceSubscriber.php @@ -74,15 +74,20 @@ public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void ['columns' => ['discr']], ['columns' => ['title']], ['columns' => ['published_at']], + 'ns_unpublished_at' => ['columns' => ['unpublished_at']], 'ns_no_index' => ['columns' => ['no_index']], 'ns_node_translation_published' => ['columns' => ['node_id', 'translation_id', 'published_at']], + 'ns_node_translation_unpublished' => ['columns' => ['node_id', 'translation_id', 'unpublished_at']], 'ns_node_discr_translation' => ['columns' => ['node_id', 'discr', 'translation_id']], 'ns_node_discr_translation_published' => ['columns' => ['node_id', 'discr', 'translation_id', 'published_at']], + 'ns_node_discr_translation_unpublished' => ['columns' => ['node_id', 'discr', 'translation_id', 'unpublished_at']], 'ns_translation_published' => ['columns' => ['translation_id', 'published_at']], 'ns_discr_translation' => ['columns' => ['discr', 'translation_id']], 'ns_discr_translation_published' => ['columns' => ['discr', 'translation_id', 'published_at']], + 'ns_discr_translation_unpublished' => ['columns' => ['discr', 'translation_id', 'unpublished_at']], 'ns_title_published' => ['columns' => ['title', 'published_at']], 'ns_title_translation_published' => ['columns' => ['title', 'translation_id', 'published_at']], + 'ns_title_translation_unpublished' => ['columns' => ['title', 'translation_id', 'unpublished_at']], ], 'uniqueConstraints' => [ ['columns' => ['node_id', 'translation_id']], diff --git a/lib/RoadizCoreBundle/src/Entity/NodeType.php b/lib/RoadizCoreBundle/src/Entity/NodeType.php index 39e08e1ba..9910d1b11 100644 --- a/lib/RoadizCoreBundle/src/Entity/NodeType.php +++ b/lib/RoadizCoreBundle/src/Entity/NodeType.php @@ -40,6 +40,8 @@ final class NodeType implements NodeTypeInterface, \Stringable private bool $visible = true; #[SymfonySerializer\Groups(['node_type', 'node_type:import']),] private bool $publishable = false; + #[SymfonySerializer\Groups(['node_type', 'node_type:import']),] + private bool $unpublishable = false; /** * @var bool define if this node-type produces nodes that will have attributes @@ -154,6 +156,18 @@ public function setPublishable(bool $publishable): NodeType return $this; } + #[\Override] + public function isUnpublishable(): bool + { + return $this->unpublishable; + } + + public function setUnpublishable(bool $unpublishable): NodeType + { + $this->unpublishable = $unpublishable; + return $this; + } + public function getReachable(): bool { return $this->reachable; diff --git a/lib/RoadizCoreBundle/src/Entity/NodesSources.php b/lib/RoadizCoreBundle/src/Entity/NodesSources.php index 4a9702cd5..9940727be 100644 --- a/lib/RoadizCoreBundle/src/Entity/NodesSources.php +++ b/lib/RoadizCoreBundle/src/Entity/NodesSources.php @@ -37,15 +37,20 @@ ORM\Index(columns: ['discr']), ORM\Index(columns: ['title']), ORM\Index(columns: ['published_at']), + ORM\Index(columns: ['unpublished_at'], name: 'ns_unpublished_at'), ORM\Index(columns: ['no_index'], name: 'ns_no_index'), ORM\Index(columns: ['node_id', 'translation_id', 'published_at'], name: 'ns_node_translation_published'), + ORM\Index(columns: ['node_id', 'translation_id', 'unpublished_at'], name: 'ns_node_translation_unpublished'), ORM\Index(columns: ['node_id', 'discr', 'translation_id'], name: 'ns_node_discr_translation'), ORM\Index(columns: ['node_id', 'discr', 'translation_id', 'published_at'], name: 'ns_node_discr_translation_published'), + ORM\Index(columns: ['node_id', 'discr', 'translation_id', 'unpublished_at'], name: 'ns_node_discr_translation_unpublished'), ORM\Index(columns: ['translation_id', 'published_at'], name: 'ns_translation_published'), ORM\Index(columns: ['discr', 'translation_id'], name: 'ns_discr_translation'), ORM\Index(columns: ['discr', 'translation_id', 'published_at'], name: 'ns_discr_translation_published'), + ORM\Index(columns: ['discr', 'translation_id', 'unpublished_at'], name: 'ns_discr_translation_unpublished'), ORM\Index(columns: ['title', 'published_at'], name: 'ns_title_published'), ORM\Index(columns: ['title', 'translation_id', 'published_at'], name: 'ns_title_translation_published'), + ORM\Index(columns: ['title', 'translation_id', 'unpublished_at'], name: 'ns_title_translation_unpublished'), ORM\UniqueConstraint(columns: ['node_id', 'translation_id']), ORM\InheritanceType('JOINED'), // Limit discriminator column to 30 characters for indexing optimization @@ -94,6 +99,16 @@ class NodesSources implements PersistableInterface, Loggable, \Stringable )] protected ?\DateTime $publishedAt = null; + #[ApiFilter(BaseFilter\DateFilter::class)] + #[ApiFilter(BaseFilter\OrderFilter::class)] + #[ORM\Column(name: 'unpublished_at', type: 'datetime', unique: false, nullable: true)] + #[SymfonySerializer\Groups(['nodes_sources', 'nodes_sources_base'])] + #[Gedmo\Versioned] + #[ApiProperty( + description: 'Content unpublication date and time', + )] + protected ?\DateTime $unpublishedAt = null; + #[ApiFilter(BaseFilter\SearchFilter::class, strategy: 'partial')] #[ORM\Column(name: 'meta_title', type: 'string', length: 150, unique: false)] #[SymfonySerializer\Groups(['nodes_sources'])] @@ -386,6 +401,18 @@ public function setPublishedAt(?\DateTime $publishedAt = null): NodesSources return $this; } + public function getUnpublishedAt(): ?\DateTime + { + return $this->unpublishedAt; + } + + public function setUnpublishedAt(?\DateTime $unpublishedAt = null): NodesSources + { + $this->unpublishedAt = $unpublishedAt; + + return $this; + } + /** * Final on purpose: like getMetaDescription(), this raw getter is bound to * the admin SEO form and its setter round-trip, so it must never become a @@ -559,9 +586,12 @@ public function getNodeTypeColor(): string #[SymfonySerializer\Groups(['nodes_sources_published'])] public function isPublished(): bool { + $now = new \DateTime(); + return $this->getNode()->isPublished() && null !== $this->getPublishedAt() - && $this->getPublishedAt() <= new \DateTime(); + && $this->getPublishedAt() <= $now + && (null === $this->getUnpublishedAt() || $this->getUnpublishedAt() > $now); } /** @@ -572,6 +602,14 @@ public function isPublishable(): bool throw new \RuntimeException('This method should only be called from children classes'); } + /** + * Overridden in NS classes. + */ + public function isUnpublishable(): bool + { + throw new \RuntimeException('This method should only be called from children classes'); + } + /** * Overridden in NS classes. */ diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/NodeSourceReservedName.php b/lib/RoadizCoreBundle/src/Form/Constraint/NodeSourceReservedName.php index 86e9221b7..ec9dfb516 100644 --- a/lib/RoadizCoreBundle/src/Form/Constraint/NodeSourceReservedName.php +++ b/lib/RoadizCoreBundle/src/Form/Constraint/NodeSourceReservedName.php @@ -51,6 +51,9 @@ final class NodeSourceReservedName extends Constraint 'publishable', 'published', 'publishedAt', + 'unpublishable', + 'unpublished', + 'unpublishedAt', 'reachable', 'redirections', 'shareImage', diff --git a/lib/RoadizCoreBundle/src/Model/NodeTreeDto.php b/lib/RoadizCoreBundle/src/Model/NodeTreeDto.php index bda4a0f94..1f44f33f4 100644 --- a/lib/RoadizCoreBundle/src/Model/NodeTreeDto.php +++ b/lib/RoadizCoreBundle/src/Model/NodeTreeDto.php @@ -32,11 +32,13 @@ public function __construct( ?int $sourceId, ?string $title, ?\DateTime $publishedAt, + ?\DateTime $unpublishedAt = null, ) { $this->nodeSource = new NodesSourcesTreeDto( $sourceId, $title, $publishedAt, + $unpublishedAt, ); } diff --git a/lib/RoadizCoreBundle/src/Model/NodeTypeTreeDto.php b/lib/RoadizCoreBundle/src/Model/NodeTypeTreeDto.php index f2765a6b9..b67fb5969 100644 --- a/lib/RoadizCoreBundle/src/Model/NodeTypeTreeDto.php +++ b/lib/RoadizCoreBundle/src/Model/NodeTypeTreeDto.php @@ -13,6 +13,7 @@ public function __construct( private string $name, private bool $publishable, + private bool $unpublishable, private bool $reachable, private string $displayName, private string $color, @@ -33,6 +34,12 @@ public function isPublishable(): bool return $this->publishable; } + #[\Override] + public function isUnpublishable(): bool + { + return $this->unpublishable; + } + public function getDisplayName(): string { return $this->displayName; diff --git a/lib/RoadizCoreBundle/src/Model/NodesSourcesTreeDto.php b/lib/RoadizCoreBundle/src/Model/NodesSourcesTreeDto.php index 452e928a2..7a089891b 100644 --- a/lib/RoadizCoreBundle/src/Model/NodesSourcesTreeDto.php +++ b/lib/RoadizCoreBundle/src/Model/NodesSourcesTreeDto.php @@ -12,6 +12,7 @@ public function __construct( private ?int $id, private ?string $title, private ?\DateTime $publishedAt, + private ?\DateTime $unpublishedAt = null, ) { } @@ -30,4 +31,9 @@ public function getPublishedAt(): ?\DateTime { return $this->publishedAt; } + + public function getUnpublishedAt(): ?\DateTime + { + return $this->unpublishedAt; + } } diff --git a/lib/RoadizCoreBundle/src/NodeType/Configuration/NodeTypeConfiguration.php b/lib/RoadizCoreBundle/src/NodeType/Configuration/NodeTypeConfiguration.php index abac9e7f9..7cadd37d8 100644 --- a/lib/RoadizCoreBundle/src/NodeType/Configuration/NodeTypeConfiguration.php +++ b/lib/RoadizCoreBundle/src/NodeType/Configuration/NodeTypeConfiguration.php @@ -33,6 +33,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarNode('description')->end() ->booleanNode('visible')->defaultTrue()->end() ->booleanNode('publishable')->defaultFalse()->end() + ->booleanNode('unpublishable')->defaultFalse()->end() ->booleanNode('attributable')->defaultFalse()->end() ->booleanNode('searchable')->defaultTrue()->end() ->booleanNode('sortingAttributesByWeight')->defaultFalse()->end() diff --git a/lib/RoadizCoreBundle/src/Repository/NodeRepository.php b/lib/RoadizCoreBundle/src/Repository/NodeRepository.php index b475f7fef..2358f03a8 100644 --- a/lib/RoadizCoreBundle/src/Repository/NodeRepository.php +++ b/lib/RoadizCoreBundle/src/Repository/NodeRepository.php @@ -408,7 +408,8 @@ protected function alterQueryBuilderAsNodeTreeDto(QueryBuilder $qb, string $alia %s.nodeTypeName, %s.id, %s.title, - %s.publishedAt + %s.publishedAt, + %s.unpublishedAt ) EOT, NodeTreeDto::class, @@ -426,6 +427,7 @@ protected function alterQueryBuilderAsNodeTreeDto(QueryBuilder $qb, string $alia self::NODESSOURCES_ALIAS, self::NODESSOURCES_ALIAS, self::NODESSOURCES_ALIAS, + self::NODESSOURCES_ALIAS, )); return $qb; diff --git a/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php b/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php index e5f5183aa..191035b17 100644 --- a/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php +++ b/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php @@ -226,6 +226,12 @@ public function alterQueryBuilderWithAuthorizationChecker( * Forbid unpublished node for anonymous and not backend users. */ $qb->andWhere($qb->expr()->lte($prefix.'.publishedAt', ':now')); + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->gt($prefix.'.unpublishedAt', ':now'), + $qb->expr()->isNull($prefix.'.unpublishedAt') + ) + ); $qb->andWhere($qb->expr()->eq(static::NODE_ALIAS.'.status', ':node_status')); $qb->setParameter('node_status', NodeStatus::PUBLISHED); $qb->setParameter('now', new \DateTime('now')); diff --git a/lib/RoadizRozierBundle/src/Form/NodeSource/NodeSourceBaseType.php b/lib/RoadizRozierBundle/src/Form/NodeSource/NodeSourceBaseType.php index 0e2b22560..32e80bdfa 100644 --- a/lib/RoadizRozierBundle/src/Form/NodeSource/NodeSourceBaseType.php +++ b/lib/RoadizRozierBundle/src/Form/NodeSource/NodeSourceBaseType.php @@ -50,6 +50,23 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ], ]); } + + if (true === $options['unpublishable']) { + $builder->add('unpublishedAt', DateTimeType::class, [ + 'label' => 'unpublishedAt', + 'required' => false, + 'attr' => [ + 'class' => 'rz-datetime-field', + 'data-dev-name' => '{{ nodeSource.'.StringHandler::camelCase('unpublishedAt').' }}', + ], + 'date_widget' => 'single_text', + 'date_format' => 'yyyy-MM-dd', + 'placeholder' => [ + 'hour' => 'hour', + 'minute' => 'minute', + ], + ]); + } } #[\Override] @@ -65,6 +82,7 @@ public function configureOptions(OptionsResolver $resolver): void 'label' => false, 'inherit_data' => true, 'publishable' => false, + 'unpublishable' => false, ]); $resolver->setRequired('translation'); diff --git a/lib/RoadizRozierBundle/src/Form/NodeSource/NodeSourceType.php b/lib/RoadizRozierBundle/src/Form/NodeSource/NodeSourceType.php index 93dfe19d5..5d87fbc73 100644 --- a/lib/RoadizRozierBundle/src/Form/NodeSource/NodeSourceType.php +++ b/lib/RoadizRozierBundle/src/Form/NodeSource/NodeSourceType.php @@ -63,6 +63,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void if (true === $options['withTitle']) { $builder->add('base', NodeSourceBaseType::class, [ 'publishable' => $options['nodeType']->isPublishable(), + 'unpublishable' => $options['nodeType']->isUnpublishable(), 'translation' => $builder->getData()->getTranslation(), ]); } diff --git a/lib/RoadizRozierBundle/templates/widgets/nodeTree/singleNode.html.twig b/lib/RoadizRozierBundle/templates/widgets/nodeTree/singleNode.html.twig index fefd58060..4b53137ff 100644 --- a/lib/RoadizRozierBundle/templates/widgets/nodeTree/singleNode.html.twig +++ b/lib/RoadizRozierBundle/templates/widgets/nodeTree/singleNode.html.twig @@ -54,6 +54,12 @@ This template is using NodeTreeDto and TagTreeDto objects to render a node tree. {% set innerClasses = innerClasses|merge(['datetime-publishable-future']) %} {% endif %} {% endif %} +{% if nodeTree.isStackTree and type.isUnpublishable and source.unpublishedAt %} + {% set innerClasses = innerClasses|merge(['datetime-unpublishable']) %} + {% if source.unpublishedAt < date() %} + {% set innerClasses = innerClasses|merge(['datetime-unpublishable-past']) %} + {% endif %} +{% endif %}
  • {% if not mainNodeTree and not nodeTree.isStackTree %} @@ -92,6 +98,11 @@ This template is using NodeTreeDto and TagTreeDto objects to render a node tree. {{ source.publishedAt|format_date('short', locale=app.request.locale) }} {% endif %} + {% if nodeTree.isStackTree and type.isUnpublishable and source.unpublishedAt %} +
    + {{ source.unpublishedAt|format_date('short', locale=app.request.locale) }} +
    + {% endif %} {% endblock %} diff --git a/lib/RoadizRozierBundle/translations/rozier/messages.en.xlf b/lib/RoadizRozierBundle/translations/rozier/messages.en.xlf index faeb56f6b..aac68545d 100644 --- a/lib/RoadizRozierBundle/translations/rozier/messages.en.xlf +++ b/lib/RoadizRozierBundle/translations/rozier/messages.en.xlf @@ -4383,6 +4383,16 @@ Date and time publishable Field label for node-type publishable status. + + unpublishedAt + Unpublication date and time + Field label for node-source scheduled expiration date and time. + + + unpublishable + Date and time unpublishable + Field label for node-type unpublishable status. + this_node_type_will_be_available_for_creating_root_nodes diff --git a/lib/RoadizRozierBundle/translations/rozier/messages.fr.xlf b/lib/RoadizRozierBundle/translations/rozier/messages.fr.xlf index 162ec978f..6c5151847 100644 --- a/lib/RoadizRozierBundle/translations/rozier/messages.fr.xlf +++ b/lib/RoadizRozierBundle/translations/rozier/messages.fr.xlf @@ -4383,6 +4383,16 @@ Publiable par date et heure Field label for node-type publishable status. + + unpublishedAt + Date et heure de dépublication + Field label for node-source scheduled expiration date and time. + + + unpublishable + Dépubliable par date et heure + Field label for node-type unpublishable status. + this_node_type_will_be_available_for_creating_root_nodes diff --git a/lib/RoadizSolrBundle/src/EventListener/DefaultNodesSourcesIndexingSubscriber.php b/lib/RoadizSolrBundle/src/EventListener/DefaultNodesSourcesIndexingSubscriber.php index 45c9f12bb..553262805 100644 --- a/lib/RoadizSolrBundle/src/EventListener/DefaultNodesSourcesIndexingSubscriber.php +++ b/lib/RoadizSolrBundle/src/EventListener/DefaultNodesSourcesIndexingSubscriber.php @@ -74,6 +74,10 @@ public function onIndexing(NodesSourcesIndexingEvent $event): void $assoc['published_at_dt'] = $this->formatDateTimeToUTC($nodeSource->getPublishedAt()); } + if (null !== $nodeSource->getUnpublishedAt()) { + $assoc['unpublished_at_dt'] = $this->formatDateTimeToUTC($nodeSource->getUnpublishedAt()); + } + if ($this->canIndexTitleInCollection($nodeSource)) { $collection[] = $title; } diff --git a/lib/RoadizSolrBundle/src/NodeSourceSearchHandler.php b/lib/RoadizSolrBundle/src/NodeSourceSearchHandler.php index 4b3eccaca..d0bbb04df 100644 --- a/lib/RoadizSolrBundle/src/NodeSourceSearchHandler.php +++ b/lib/RoadizSolrBundle/src/NodeSourceSearchHandler.php @@ -192,6 +192,45 @@ protected function argFqProcess(array &$args): array $args['fq'][] = $tmp; } + /* + * Handle unpublication (expiration) date-time filtering + */ + $hasExplicitUnpublishedAtFilter = isset($args['unpublishedAt']); + if (isset($args['unpublishedAt'])) { + $tmp = 'unpublished_at_dt:'; + if (!is_array($args['unpublishedAt']) && $args['unpublishedAt'] instanceof \DateTimeInterface) { + $tmp .= $this->formatDateTimeToUTC($args['unpublishedAt']); + } elseif ( + isset($args['unpublishedAt'][0]) + && 'BETWEEN' === $args['unpublishedAt'][0] + && isset($args['unpublishedAt'][1]) + && $args['unpublishedAt'][1] instanceof \DateTimeInterface + && isset($args['unpublishedAt'][2]) + && $args['unpublishedAt'][2] instanceof \DateTimeInterface + ) { + $tmp .= '['. + $this->formatDateTimeToUTC($args['unpublishedAt'][1]). + ' TO '. + $this->formatDateTimeToUTC($args['unpublishedAt'][2]).']'; + } elseif ( + isset($args['unpublishedAt'][0]) + && '<=' === $args['unpublishedAt'][0] + && isset($args['unpublishedAt'][1]) + && $args['unpublishedAt'][1] instanceof \DateTimeInterface + ) { + $tmp .= '[* TO '.$this->formatDateTimeToUTC($args['unpublishedAt'][1]).']'; + } elseif ( + isset($args['unpublishedAt'][0]) + && '>=' === $args['unpublishedAt'][0] + && isset($args['unpublishedAt'][1]) + && $args['unpublishedAt'][1] instanceof \DateTimeInterface + ) { + $tmp .= '['.$this->formatDateTimeToUTC($args['unpublishedAt'][1]).' TO *]'; + } + unset($args['unpublishedAt']); + $args['fq'][] = $tmp; + } + $status = $args['status'] ?? $args['node.status'] ?? null; if (isset($status)) { $tmp = 'node_status_i:'; @@ -217,6 +256,13 @@ protected function argFqProcess(array &$args): array if (!$hasExplicitPublishedAtFilter) { $args['fq'][] = 'published_at_dt:[* TO NOW/MINUTE]'; } + if (!$hasExplicitUnpublishedAtFilter) { + /* + * Exclude expired content: keep documents whose unpublished_at_dt is in the + * future or missing (unpublishedAt IS NULL OR unpublishedAt > now). + */ + $args['fq'][] = '(*:* -unpublished_at_dt:[* TO NOW/MINUTE])'; + } } /* diff --git a/lib/Rozier/app/assets/less/widgets/nestable.less b/lib/Rozier/app/assets/less/widgets/nestable.less index 9391c4525..a5f77c71b 100644 --- a/lib/Rozier/app/assets/less/widgets/nestable.less +++ b/lib/Rozier/app/assets/less/widgets/nestable.less @@ -375,6 +375,44 @@ } } } + + &.datetime-unpublishable { + .non-nestable-handle:before, + .uk-nestable-handle:before { + z-index: 1; + } + .nodetree-unpublished-at { + margin-left: 0; + left: -20px; + top: -1px; + position: relative; + z-index: 0; + border-radius: 15px 0 0 15px; + background-color: #b8b8b8; + color: #fff; + margin-right: -15px; + line-height: 1; + border: 0 none; + padding: 4px 7px 4px 25px; + min-width: 50px; + text-align: right; + text-shadow: none; + letter-spacing: 0.02em; + + i { + display: none; + } + } + + &.datetime-unpublishable-past { + .nodetree-unpublished-at { + background-color: #ca8776; + i { + display: inline-block; + } + } + } + } } .root-tree, diff --git a/src/GeneratedEntity/NSAliasBlock.php b/src/GeneratedEntity/NSAliasBlock.php index 3cc1a32b3..061e93842 100644 --- a/src/GeneratedEntity/NSAliasBlock.php +++ b/src/GeneratedEntity/NSAliasBlock.php @@ -111,6 +111,16 @@ public function isPublishable(): bool return false; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return false; + } + #[\Override] public function __toString(): string { diff --git a/src/GeneratedEntity/NSArticle.php b/src/GeneratedEntity/NSArticle.php index f3b5889db..74b26ca25 100644 --- a/src/GeneratedEntity/NSArticle.php +++ b/src/GeneratedEntity/NSArticle.php @@ -30,7 +30,6 @@ #[Gedmo\Loggable(logEntryClass: UserLogEntry::class)] #[ORM\Entity(repositoryClass: Repository\NSArticleRepository::class)] #[ORM\Table(name: 'ns_article')] -#[ORM\Index(columns: ['unpublished_at'])] #[ApiFilter(PropertyFilter::class)] class NSArticle extends NodesSources { @@ -75,17 +74,6 @@ class NSArticle extends NodesSources #[ORM\Column(name: 'realm_a_secret', type: 'string', nullable: true, length: 250)] private ?string $realmASecret = null; - /** Date de dépublication. */ - #[Serializer\SerializedName(serializedName: 'unpublishedAt')] - #[Serializer\Groups(['nodes_sources', 'nodes_sources_default'])] - #[ApiProperty(description: 'Date de dépublication')] - #[Serializer\MaxDepth(2)] - #[ApiFilter(Filter\OrderFilter::class)] - #[ApiFilter(Filter\DateFilter::class)] - #[Gedmo\Versioned] - #[ORM\Column(name: 'unpublished_at', type: 'datetime', nullable: true)] - private ?\DateTime $unpublishedAt = null; - /** Only on web response. */ #[Serializer\SerializedName(serializedName: 'onlyOnWebresponse')] #[Serializer\Groups(['article_get_by_path'])] @@ -215,23 +203,6 @@ public function setRealmASecret(?string $realmASecret): static return $this; } - /** - * @return \DateTime|null - */ - public function getUnpublishedAt(): ?\DateTime - { - return $this->unpublishedAt; - } - - /** - * @return $this - */ - public function setUnpublishedAt(?\DateTime $unpublishedAt): static - { - $this->unpublishedAt = $unpublishedAt; - return $this; - } - /** * @return string|null */ @@ -318,6 +289,16 @@ public function isPublishable(): bool return true; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return true; + } + #[\Override] public function __toString(): string { diff --git a/src/GeneratedEntity/NSArticleContainer.php b/src/GeneratedEntity/NSArticleContainer.php index 938629e59..5bfa898dd 100644 --- a/src/GeneratedEntity/NSArticleContainer.php +++ b/src/GeneratedEntity/NSArticleContainer.php @@ -68,6 +68,16 @@ public function isPublishable(): bool return false; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return false; + } + #[\Override] public function __toString(): string { diff --git a/src/GeneratedEntity/NSArticleFeedBlock.php b/src/GeneratedEntity/NSArticleFeedBlock.php index cdfa52888..6b72eecae 100644 --- a/src/GeneratedEntity/NSArticleFeedBlock.php +++ b/src/GeneratedEntity/NSArticleFeedBlock.php @@ -96,6 +96,16 @@ public function isPublishable(): bool return false; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return false; + } + #[\Override] public function __toString(): string { diff --git a/src/GeneratedEntity/NSBasicBlock.php b/src/GeneratedEntity/NSBasicBlock.php index 16995489a..aca1be1de 100644 --- a/src/GeneratedEntity/NSBasicBlock.php +++ b/src/GeneratedEntity/NSBasicBlock.php @@ -291,6 +291,16 @@ public function isPublishable(): bool return false; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return false; + } + #[\Override] public function __toString(): string { diff --git a/src/GeneratedEntity/NSGroupBlock.php b/src/GeneratedEntity/NSGroupBlock.php index 0d0b4292e..c2ad5c1e8 100644 --- a/src/GeneratedEntity/NSGroupBlock.php +++ b/src/GeneratedEntity/NSGroupBlock.php @@ -68,6 +68,16 @@ public function isPublishable(): bool return false; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return false; + } + #[\Override] public function __toString(): string { diff --git a/src/GeneratedEntity/NSMenu.php b/src/GeneratedEntity/NSMenu.php index 083aff4a6..9dd4e294b 100644 --- a/src/GeneratedEntity/NSMenu.php +++ b/src/GeneratedEntity/NSMenu.php @@ -68,6 +68,16 @@ public function isPublishable(): bool return false; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return false; + } + #[\Override] public function __toString(): string { diff --git a/src/GeneratedEntity/NSMenuLink.php b/src/GeneratedEntity/NSMenuLink.php index ab5014e50..056090f42 100644 --- a/src/GeneratedEntity/NSMenuLink.php +++ b/src/GeneratedEntity/NSMenuLink.php @@ -202,6 +202,16 @@ public function isPublishable(): bool return false; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return false; + } + #[\Override] public function __toString(): string { diff --git a/src/GeneratedEntity/NSNeutral.php b/src/GeneratedEntity/NSNeutral.php index a40dbb108..ed03c3fa5 100644 --- a/src/GeneratedEntity/NSNeutral.php +++ b/src/GeneratedEntity/NSNeutral.php @@ -101,6 +101,16 @@ public function isPublishable(): bool return false; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return false; + } + #[\Override] public function __toString(): string { diff --git a/src/GeneratedEntity/NSOffer.php b/src/GeneratedEntity/NSOffer.php index bb1ffe1b0..e901627f1 100644 --- a/src/GeneratedEntity/NSOffer.php +++ b/src/GeneratedEntity/NSOffer.php @@ -216,6 +216,16 @@ public function isPublishable(): bool return false; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return false; + } + #[\Override] public function __toString(): string { diff --git a/src/GeneratedEntity/NSPage.php b/src/GeneratedEntity/NSPage.php index c11dd54a2..337956db4 100644 --- a/src/GeneratedEntity/NSPage.php +++ b/src/GeneratedEntity/NSPage.php @@ -971,6 +971,16 @@ public function isPublishable(): bool return false; } + /** + * $this->nodeType->isUnpublishable() proxy. + * @return bool Does this nodeSource is unpublishable with date and time? + */ + #[\Override] + public function isUnpublishable(): bool + { + return false; + } + #[\Override] public function __toString(): string { From 088529bf3959e40eb1b1dbc39cf553c0490f4aee Mon Sep 17 00:00:00 2001 From: eliot lauger Date: Tue, 28 Jul 2026 16:51:55 +0200 Subject: [PATCH 2/3] chore: fix phpcs --- lib/RoadizCoreBundle/src/Entity/NodeType.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/RoadizCoreBundle/src/Entity/NodeType.php b/lib/RoadizCoreBundle/src/Entity/NodeType.php index 9910d1b11..0f79b73e6 100644 --- a/lib/RoadizCoreBundle/src/Entity/NodeType.php +++ b/lib/RoadizCoreBundle/src/Entity/NodeType.php @@ -165,6 +165,7 @@ public function isUnpublishable(): bool public function setUnpublishable(bool $unpublishable): NodeType { $this->unpublishable = $unpublishable; + return $this; } From 2e510b64b8cf97ebfb3bcfc47d695b544bdaed7e Mon Sep 17 00:00:00 2001 From: eliot lauger Date: Tue, 4 Aug 2026 16:55:33 +0200 Subject: [PATCH 3/3] fix: phpstan --- lib/RoadizCoreBundle/src/Doctrine/DBAL/Types/ArrayType.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/RoadizCoreBundle/src/Doctrine/DBAL/Types/ArrayType.php b/lib/RoadizCoreBundle/src/Doctrine/DBAL/Types/ArrayType.php index c570197e0..5d1e94815 100644 --- a/lib/RoadizCoreBundle/src/Doctrine/DBAL/Types/ArrayType.php +++ b/lib/RoadizCoreBundle/src/Doctrine/DBAL/Types/ArrayType.php @@ -13,7 +13,6 @@ */ final class ArrayType extends JsonType { - #[\Override] public function getName(): string { return 'array';