refactor: extract the lit/content-create draft transaction into NewDraft (M7)

NewDraft owns insert (partial write on a throwing hook), refuse and discard (via
Rollback::attempt and WriteFailed::*) and the partial logging; ContentCreateAbility
keeps checkDivi/prepare/combined (Errors::fieldsOf) and the result. Both discard
callers still pass REASON_EXTRAS. Behaviour unchanged; NewDraftTest adds the
throwing-delete branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 17:43:50 +02:00
co-authored by Claude Opus 5.5
parent 4114bdb805
commit d2cc8497c9
3 changed files with 203 additions and 94 deletions
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use RuntimeException;
use Throwable;
use WP_Error;
/**
* The transaction of lit/content-create: inserting the draft and every way back out of it.
* The three exits side by side:
* - insert(): a throwing hook leaves the draft's existence unknown – partial write without id.
* - refuse(): a check that needs the draft failed – the draft is deleted and the refusal is
* returned like any invalid input; partial write if the delete fails.
* - discard(): writing after the insert failed – created terms and the draft are deleted;
* rolled back, or partial write if that cleanup failed.
* Partial writes are logged with result "partial".
*/
final class NewDraft
{
public const REASON_INSERT = 'WordPress hat beim Anlegen des Eintrags einen Fehler gemeldet.';
public const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.';
public function __construct(
private PostWriter $writer,
private ChangeLog $log,
private string $ability,
private string $postType
) {
}
/**
* @param array<string, mixed> $data wp_insert_post() data (unslashed).
* @return int|WP_Error The new draft's ID, or WordPress' refusal as invalid input.
* @throws WriteFailed When a hook threw during the insert.
*/
public function insert(array $data): int|WP_Error
{
try {
$postId = wp_insert_post(wp_slash($data), true);
} catch (Throwable $e) {
// A hook threw – the row may or may not exist and its ID is unknown; nothing is
// searched or deleted, the client is told to check (lit_partial_write without id).
$this->logPartial('', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden');
throw WriteFailed::partial(self::REASON_INSERT, $e);
}
if ($postId instanceof WP_Error) {
return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message());
}
return (int) $postId;
}
/**
* Some checks need the draft: meta rights (add_post_meta needs its ID) and the Divi layout
* against the new entry's own dynamic content options. A refusal removes the brand-new draft
* again and is reported like any invalid input.
*
* @param array<string, string>|WP_Error $denied fields.<key> => error, or the error itself.
* @throws WriteFailed When the draft cannot be deleted.
*/
public function refuse(int $postId, array|WP_Error $denied): WP_Error
{
try {
$deleted = (bool) wp_delete_post($postId, true);
} catch (Throwable $e) {
$deleted = false;
}
if (! $deleted) {
$summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId);
$this->logPartial((string) $postId, $summary);
$fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied;
throw WriteFailed::partial(
($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ')
. implode(', ', array_keys($fields)) . '.',
detail: sprintf('New post %d refused after the insert; draft could not be deleted', $postId),
postId: $postId
);
}
return $denied instanceof WP_Error
? $denied
: Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied);
}
/**
* Rolls the brand-new draft back: terms it created, then the draft itself (force –
* it cannot have been used anywhere yet). $reason is the client text of the error.
*
* @throws WriteFailed Always.
*/
public function discard(int $postId, Throwable $cause, string $reason): never
{
$problems = Rollback::attempt(
fn () => $this->writer->deleteCreatedTerms(),
static fn () => self::deleteDraft($postId)
);
$detail = sprintf('Extras of new post %d could not be written: %s', $postId, $cause->getMessage());
if ($problems === []) {
throw WriteFailed::rolledBack($reason, $cause, $detail);
}
$summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId);
$this->logPartial((string) $postId, $summary);
throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId);
}
/** @throws RuntimeException When the draft is not deleted. */
private static function deleteDraft(int $postId): void
{
try {
$deleted = wp_delete_post($postId, true);
} catch (Throwable $e) {
throw new RuntimeException(sprintf('draft %d could not be deleted: %s', $postId, $e->getMessage()), 0, $e);
}
if (! $deleted) {
throw new RuntimeException(sprintf('draft %d could not be deleted', $postId));
}
}
private function logPartial(string $postId, string $summary): void
{
$this->log->recordPartial($this->ability, $this->postType, $postId, $summary);
}
}