Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ New reusable templates for building back-office pages:
- 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

Expand All @@ -223,6 +225,66 @@ New reusable templates for building back-office pages:
- 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.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 1 addition & 6 deletions config/node_types/article.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ displayName: Article
description: Article
visible: true
publishable: true
unpublishable: true
attributable: true
sortingAttributesByWeight: false
reachable: true
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions docs/developer/nodes-system/node_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions docs/developer/nodes-system/nodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion lib/DocGenerator/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.*"
},
Expand Down
2 changes: 1 addition & 1 deletion lib/DtsGenerator/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion lib/EntityGenerator/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.*",
Expand Down
8 changes: 8 additions & 0 deletions lib/EntityGenerator/src/EntityGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions lib/EntityGenerator/tests/Mocks/GeneratedNodesSources/NSMock.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
6 changes: 6 additions & 0 deletions lib/EntityGenerator/tests/NodeTypeAwareTestTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,9 @@ classname: Themes\MyTheme\Entities\PositionedCity
$mockNodeType
->method('isPublishable')
->willReturn(true);
$mockNodeType
->method('isUnpublishable')
->willReturn(true);

return $mockNodeType;
}
Expand Down Expand Up @@ -329,6 +332,9 @@ protected function getMockDocumentNodeType(): NodeTypeInterface
$mockNodeType
->method('isPublishable')
->willReturn(true);
$mockNodeType
->method('isUnpublishable')
->willReturn(true);

return $mockNodeType;
}
Expand Down
2 changes: 1 addition & 1 deletion lib/Models/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"doctrine/orm": "^3.6",
"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.*",
Expand Down
2 changes: 1 addition & 1 deletion lib/RoadizCoreBundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"roadiz/jwt": "2.8.x-dev",
"roadiz/markdown": "2.8.x-dev",
"roadiz/models": "2.8.x-dev",
"roadiz/nodetype-contracts": "^3.1.1",
"roadiz/nodetype-contracts": "^4.0.0",
"roadiz/random": "2.8.x-dev",
"scienta/doctrine-json-functions": "^6.0",
"symfony-cmf/routing-bundle": "^3.1.0",
Expand Down
65 changes: 65 additions & 0 deletions lib/RoadizCoreBundle/migrations/Version20260713120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace RZ\Roadiz\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Add built-in unpublished_at datetime field on nodes_sources to schedule content expiration.
*
* Symmetrical to published_at: content is publicly visible only when
* published_at <= now AND (unpublished_at > 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');
}
}
5 changes: 5 additions & 0 deletions lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
5 changes: 5 additions & 0 deletions lib/RoadizCoreBundle/src/Api/Filter/NodesTagsFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
1 change: 0 additions & 1 deletion lib/RoadizCoreBundle/src/Doctrine/DBAL/Types/ArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
*/
final class ArrayType extends JsonType
{
#[\Override]
public function getName(): string
{
return 'array';
Expand Down
Loading
Loading