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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user