Browse Source

Introduce workflows, make saving to db more resilient

imwald
Nuša Pukšič 11 months ago
parent
commit
495f07ee9d
  1. 6
      assets/styles/app.css
  2. 30
      config/packages/workflow.yaml
  3. 33
      migrations/Version20250105181626.php
  4. 31
      migrations/Version20250202160116.php
  5. 147
      src/Controller/NzineController.php
  6. 2
      src/Entity/Article.php
  7. 13
      src/Entity/Nzine.php
  8. 2
      src/Enum/KindsEnum.php
  9. 10
      src/Enum/RolesEnum.php
  10. 10
      src/Form/EditorType.php
  11. 2
      src/Form/MainCategoryType.php
  12. 5
      src/Repository/UserEntityRepository.php
  13. 9
      src/Service/ArticleWorkflowService.php
  14. 2
      src/Service/NostrClient.php
  15. 142
      src/Service/NzineWorkflowService.php
  16. 2
      src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php

6
assets/styles/app.css

@ -364,3 +364,9 @@ footer p { @@ -364,3 +364,9 @@ footer p {
padding: 15px; /* Spacing inside the content */
border-top: none; /* Remove border overlap with active tab */
}
/* Quill editor */
#editor {
height: 400px;
margin-bottom: 20px;
}

30
config/packages/workflow.yaml

@ -35,6 +35,30 @@ framework: @@ -35,6 +35,30 @@ framework:
edit:
from: published
to: edited
re-edit:
from: edited
to: edited
nzine_workflow:
type: state_machine
marking_store:
type: method
property: state
supports:
- App\Entity\Nzine
initial_marking: draft
places:
- draft
- profile_created
- main_index_created
- nested_indices_created
- published
transitions:
create_profile:
from: draft
to: profile_created
create_main_index:
from: profile_created
to: main_index_created
create_nested_indices:
from: main_index_created
to: nested_indices_created
publish:
from: nested_indices_created
to: published

33
migrations/Version20250105181626.php

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250105181626 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE nzine ADD state VARCHAR(255) DEFAULT NULL');
$this->addSql("UPDATE nzine SET state = 'draft'");
$this->addSql('ALTER TABLE nzine ALTER COLUMN state SET NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE nzine DROP state');
}
}

31
migrations/Version20250202160116.php

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250202160116 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE article ALTER title TYPE TEXT');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE article ALTER title TYPE VARCHAR(225)');
}
}

147
src/Controller/NzineController.php

@ -7,21 +7,17 @@ namespace App\Controller; @@ -7,21 +7,17 @@ namespace App\Controller;
use App\Entity\Article;
use App\Entity\Event as EventEntity;
use App\Entity\Nzine;
use App\Entity\NzineBot;
use App\Entity\User;
use App\Enum\KindsEnum;
use App\Enum\RolesEnum;
use App\Form\NzineBotType;
use App\Form\NzineType;
use App\Service\EncryptionService;
use App\Service\NostrClient;
use App\Service\NzineWorkflowService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Exception;
use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key;
use swentel\nostr\Message\EventMessage;
use swentel\nostr\Relay\Relay;
use swentel\nostr\Sign\Sign;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@ -38,92 +34,23 @@ class NzineController extends AbstractController @@ -38,92 +34,23 @@ class NzineController extends AbstractController
* @throws \JsonException
*/
#[Route('/nzine', name: 'nzine_index')]
public function index(Request $request, EncryptionService $encryptionService, EntityManagerInterface $entityManager): Response
public function index(Request $request, NzineWorkflowService $nzineWorkflowService): Response
{
$form = $this->createForm(NzineBotType::class);
$form->handleRequest($request);
$user = $this->getUser();
// TODO change into a workflow
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
// create NZine bot
$key = new Key();
$private_key = '8c55771e896581fffea62c6440e306d502630e9dbd067e484bf6fc9c83ede28c';
// $private_key = $key->generatePrivateKey();
// $bot = new NzineBot($encryptionService);
// $bot->setNsec($private_key);
$bot = $entityManager->getRepository(NzineBot::class)->find(1);
//$entityManager->persist($bot);
//$entityManager->flush();
$profileContent = [
'name' => $data['name'],
'about' => $data['about'],
'bot' => true
];
// publish bot profile
$profileEvent = new Event();
$profileEvent->setKind(0);
$profileEvent->setContent(json_encode($profileContent));
$signer = new Sign();
$signer->signEvent($profileEvent, $private_key);
$eventMessage = new EventMessage($profileEvent);
$relayUrl = 'wss://purplepag.es';
$relay = new Relay($relayUrl);
$relay->setMessage($eventMessage);
// $result = $relay->send();
// create NZine entity
$nzine = new Nzine();
$public_key = $key->getPublicKey($private_key);
$nzine->setNpub($public_key);
$nzine->setNzineBot($bot);
$nzine->setEditor($user->getUserIdentifier());
// $entityManager->persist($nzine);
// $entityManager->flush();
// TODO add EDITOR role to the user
$role = RolesEnum::EDITOR->value;
$user = $entityManager->getRepository(User::class)->findOneBy(['npub' => $user->getUserIdentifier()]);
$user->addRole($role);
// $entityManager->persist($user);
// $entityManager->flush();
$slugger = new AsciiSlugger();
$title = $profileContent['name'];
$slug = 'nzine-'.$slugger->slug($title)->lower().'-'.rand(10000,99999);
// create NZine main index
$index = new Event();
$index->setKind(KindsEnum::PUBLICATION_INDEX->value);
$index->addTag(['d' => $slug]);
$index->addTag(['title' => $title]);
$index->addTag(['summary' => $profileContent['about']]);
$index->addTag(['auto-update' => 'yes']);
$index->addTag(['type' => 'magazine']);
$signer = new Sign();
$signer->signEvent($index, $private_key);
// save to persistence, first map to EventEntity
$serializer = new Serializer([new ObjectNormalizer()],[new JsonEncoder()]);
$i = $serializer->deserialize($index->toJson(), EventEntity::class, 'json');
// don't save any more for now
$entityManager->persist($i);
// $entityManager->flush();
// TODO publish index to relays
// TODO remove this, this is temporary, to not create a host of nzines
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $nzine->getNpub()]);
$nzine->setSlug($slug);
$entityManager->persist($nzine);
$entityManager->flush();
// init object
$nzine = $nzineWorkflowService->init();
// create bot and nzine, save to persistence
$nzine = $nzineWorkflowService->createProfile($nzine, $data['name'], $data['about'], $user);
// create main index
$nzineWorkflowService->createMainIndex($nzine, $data['name'], $data['about']);
return $this->redirectToRoute('nzine_edit', ['npub' => $public_key ]);
return $this->redirectToRoute('nzine_edit', ['npub' => $nzine->getNpub() ]);
}
// on submit, create a key pair and save it securely
// create a new NZine entity and link it to the key pair
// then redirect to edit
return $this->render('pages/nzine-editor.html.twig', [
'form' => $form
@ -132,6 +59,7 @@ class NzineController extends AbstractController @@ -132,6 +59,7 @@ class NzineController extends AbstractController
#[Route('/nzine/{npub}', name: 'nzine_edit')]
public function edit(Request $request, $npub, EntityManagerInterface $entityManager,
EncryptionService $encryptionService,
ManagerRegistry $managerRegistry, NostrClient $nostrClient): Response
{
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $npub]);
@ -140,11 +68,22 @@ class NzineController extends AbstractController @@ -140,11 +68,22 @@ class NzineController extends AbstractController
}
try {
$bot = $entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]);
} catch (\Exception $e) {
} catch (Exception $e) {
// sth went wrong, but whatever
$managerRegistry->resetManager();
}
// existing index
$indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]);
$mainIndexCandidates = array_filter($indices, function ($index) use ($nzine) {
return $index->getSlug() == $nzine->getSlug();
});
$mainIndex = array_pop($mainIndexCandidates);
if (empty($mainIndex)) {
throw $this->createNotFoundException('Main index not found');
}
$catForm = $this->createForm(NzineType::class, ['categories' => $nzine->getMainCategories()]);
$catForm->handleRequest($request);
if ($catForm->isSubmitted() && $catForm->isValid()) {
@ -153,20 +92,16 @@ class NzineController extends AbstractController @@ -153,20 +92,16 @@ class NzineController extends AbstractController
$nzine->setMainCategories($data);
// try {
// $entityManager->beginTransaction();
// $entityManager->persist($nzine);
// $entityManager->flush();
// $entityManager->commit();
// } catch (Exception $e) {
// $entityManager->rollback();
// $managerRegistry->resetManager();
// }
try {
$entityManager->beginTransaction();
$entityManager->persist($nzine);
$entityManager->flush();
$entityManager->commit();
} catch (Exception $e) {
$entityManager->rollback();
$managerRegistry->resetManager();
}
// existing indices
$indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]);
// get oldest, treat it as root
$mainIndex = $indices[0];
// TODO create and update indices
foreach ($data as $cat) {
// find or create new index
@ -185,7 +120,9 @@ class NzineController extends AbstractController @@ -185,7 +120,9 @@ class NzineController extends AbstractController
$signer = new Sign();
// TODO get key
// $signer->signEvent($index, $private_key);
$private_key = $encryptionService->decrypt($nzine->getNsec());
$signer->signEvent($index, $private_key);
// save to persistence, first map to EventEntity
$serializer = new Serializer([new ObjectNormalizer()],[new JsonEncoder()]);
$i = $serializer->deserialize($index->toJson(), EventEntity::class, 'json');
@ -197,15 +134,16 @@ class NzineController extends AbstractController @@ -197,15 +134,16 @@ class NzineController extends AbstractController
// TODO add the new and updated indices to the main index
// redirect to route nzine_view
return $this->redirectToRoute('nzine_view', [
'npub' => $nzine->getNpub(),
]);
}
return $this->render('pages/nzine-editor.html.twig', [
'nzine' => $nzine,
'indices' => $indices,
'bot' => $bot,
'catForm' => $catForm
]);
@ -220,25 +158,21 @@ class NzineController extends AbstractController @@ -220,25 +158,21 @@ class NzineController extends AbstractController
#[Route('/nzine/{npub}', name: 'nzine_update')]
public function nzineUpdate()
{
// TODO make this a separate step and create all the indices and populate with articles all at once
// TODO make this a separate step and publish all the indices and populate with articles all at once
}
#[Route('/nzine/v/{npub}', name: 'nzine_view')]
public function nzineView($npub, EntityManagerInterface $entityManager) {
public function nzineView($npub, EntityManagerInterface $entityManager): Response
{
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $npub]);
if (!$nzine) {
throw $this->createNotFoundException('N-Zine not found');
}
// Find all index events for this nzine
$indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]);
// TODO Filter out the main index by the d-tag saved to entity or something
$main = $indices[0];
// let's pretend we have some nested indices in this zine
$main->setTags(['a', '30040:'.$npub.':1'.$nzine->getSlug()]);
$main->setTags(['a', '30040:'.$npub.':2'.$nzine->getSlug()]);
return $this->render('pages/nzine.html.twig', [
'nzine' => $nzine,
@ -247,7 +181,8 @@ class NzineController extends AbstractController @@ -247,7 +181,8 @@ class NzineController extends AbstractController
}
#[Route('/nzine/v/{npub}/{cat}', name: 'nzine_category')]
public function nzineCategory($npub, $cat, EntityManagerInterface $entityManager) {
public function nzineCategory($npub, $cat, EntityManagerInterface $entityManager): Response
{
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $npub]);
if (!$nzine) {
throw $this->createNotFoundException('N-Zine not found');

2
src/Entity/Article.php

@ -39,7 +39,7 @@ class Article @@ -39,7 +39,7 @@ class Article
#[ORM\Column(nullable: true, enumType: KindsEnum::class)]
private ?KindsEnum $kind = null;
#[ORM\Column(length: 225, nullable: true)]
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]

13
src/Entity/Nzine.php

@ -38,6 +38,9 @@ class Nzine @@ -38,6 +38,9 @@ class Nzine
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $slug = null;
#[ORM\Column(type: 'string')]
private string $state = 'draft';
public function __construct()
{
$this->mainCategories = new ArrayCollection();
@ -125,4 +128,14 @@ class Nzine @@ -125,4 +128,14 @@ class Nzine
{
$this->slug = $slug;
}
public function getState(): string
{
return $this->state;
}
public function setState(string $state): void
{
$this->state = $state;
}
}

2
src/Enum/KindsEnum.php

@ -14,10 +14,12 @@ enum KindsEnum: int @@ -14,10 +14,12 @@ enum KindsEnum: int
case CURATION_SET = 30004; // NIP-51
case LONGFORM = 30023; // NIP-23
case LONGFORM_DRAFT = 30024; // NIP-23
case PUBLICATION_INDEX = 30040;
case CONTENT_SEARCH = 5302;
case CONTENT_INDEX = 5312;
case CONTENT_SEARCH_RESULT = 6302;
case CONTENT_INDEX_RESULT = 6312;
case HIGHLIGHTS = 9802;
case RELAY_LIST = 10002; // NIP-65, Relay list metadata
case APP_DATA = 30078; // NIP-78, Arbitrary custom app data
}

10
src/Enum/RolesEnum.php

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
<?php
namespace App\Enum;
enum RolesEnum: string
{
case USER = 'ROLE_USER';
case ADMIN = 'ROLE_ADMIN';
case EDITOR = 'ROLE_EDITOR';
}

10
src/Form/EditorType.php

@ -26,22 +26,22 @@ class EditorType extends AbstractType @@ -26,22 +26,22 @@ class EditorType extends AbstractType
->add('title', TextType::class, [
'required' => false,
'sanitize_html' => true,
'attr' => ['placeholder' => 'Enter title', 'class' => 'form-control']])
'attr' => ['placeholder' => 'Awesome article', 'class' => 'form-control']])
->add('summary', TextareaType::class, [
'required' => false,
'sanitize_html' => true,
'attr' => ['placeholder' => 'Enter summary', 'class' => 'form-control']])
'attr' => ['class' => 'form-control']])
->add('content', QuillType::class, [
'required' => false,
'attr' => ['placeholder' => 'Enter content', 'class' => 'form-control']])
->add('image', UrlType::class, [
'required' => false,
'label' => 'Image URL',
'attr' => ['placeholder' => 'Enter image URL', 'class' => 'form-control']])
'label' => 'Cover image URL',
'attr' => ['class' => 'form-control']])
->add('topics', TextType::class, [
'required' => false,
'sanitize_html' => true,
'help' => 'Separate tags with commas',
'help' => 'Separate tags with commas, skip #',
'attr' => ['placeholder' => 'Enter tags', 'class' => 'form-control']])
->add(
$builder->create('actions', FormType::class,

2
src/Form/MainCategoryType.php

@ -23,7 +23,7 @@ class MainCategoryType extends AbstractType @@ -23,7 +23,7 @@ class MainCategoryType extends AbstractType
'label' => 'Title',
])
->add('tags', TextType::class, [
'label' => 'Tags (comma-separated, no #s)',
'label' => 'Tags (comma-separated)',
]);
$builder->get('tags')->addModelTransformer($this->transformer);

5
src/Repository/UserEntityRepository.php

@ -17,10 +17,11 @@ class UserEntityRepository extends ServiceEntityRepository @@ -17,10 +17,11 @@ class UserEntityRepository extends ServiceEntityRepository
{
$entity = $this->findOneBy(['npub' => $user->getNpub()]);
if (!!$entity) {
if ($entity !== null) {
$user->setId($entity->getId());
}
} else {
$this->entityManager->persist($user);
}
return $user;
}

9
src/Service/ArticleWorkflowService.php

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
<?php
namespace App\Service;
class ArticleWorkflowService
{
}

2
src/Service/NostrClient.php

@ -121,6 +121,7 @@ class NostrClient @@ -121,6 +121,7 @@ class NostrClient
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
$this->managerRegistry->resetManager();
}
}
@ -138,6 +139,7 @@ class NostrClient @@ -138,6 +139,7 @@ class NostrClient
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
$this->managerRegistry->resetManager();
}
}
}

142
src/Service/NzineWorkflowService.php

@ -0,0 +1,142 @@ @@ -0,0 +1,142 @@
<?php
namespace App\Service;
use App\Entity\Nzine;
use App\Entity\NzineBot;
use App\Entity\Event as EventEntity;
use App\Entity\User;
use App\Enum\KindsEnum;
use App\Enum\RolesEnum;
use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key;
use swentel\nostr\Sign\Sign;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\Workflow\WorkflowInterface;
use Doctrine\ORM\EntityManagerInterface;
class NzineWorkflowService
{
private Nzine $nzine;
public function __construct(private readonly WorkflowInterface $nzineWorkflow,
private readonly NostrClient $nostrClient,
private readonly EncryptionService $encryptionService,
private readonly EntityManagerInterface $entityManager)
{
}
public function init($nzine = null): Nzine
{
if (!is_null($nzine)) {
$this->nzine = $nzine;
} else {
$this->nzine = new Nzine();
}
return $this->nzine;
}
public function createProfile($nzine, $name, $about, $user): Nzine
{
if (!$this->nzineWorkflow->can($nzine, 'create_profile')) {
throw new \LogicException('Cannot create profile in the current state.');
}
$this->nzine = $nzine;
// create NZine bot
$key = new Key();
$private_key = $key->generatePrivateKey();
$bot = new NzineBot($this->encryptionService);
$bot->setNsec($private_key);
$this->entityManager->persist($bot);
// publish bot profile
$profileContent = [
'name' => $name,
'about' => $about,
'bot' => true
];
$profileEvent = new Event();
$profileEvent->setKind(KindsEnum::METADATA->value);
$profileEvent->setContent(json_encode($profileContent));
$signer = new Sign();
$signer->signEvent($profileEvent, $private_key);
$this->nostrClient->publishEvent($profileEvent, ['wss://purplepag.es']);
// add EDITOR role to the user
$role = RolesEnum::EDITOR->value;
$user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $user->getUserIdentifier()]);
$user->addRole($role);
$this->entityManager->persist($user);
// create NZine entity
$public_key = $key->getPublicKey($private_key);
$this->nzine->setNpub($public_key);
$this->nzine->setNzineBot($bot);
$this->nzine->setEditor($user->getUserIdentifier());
$this->nzineWorkflow->apply($this->nzine, 'create_profile');
$this->entityManager->persist($this->nzine);
$this->entityManager->flush();
return $this->nzine;
}
/**
* @throws \JsonException
*/
public function createMainIndex(Nzine $nzine, string $title, string $summary): void
{
if (!$this->nzineWorkflow->can($nzine, 'create_main_index')) {
throw new \LogicException('Cannot create main index in the current state.');
}
$bot = $nzine->getNzineBot();
$private_key = $bot->getNsec();
$slugger = new AsciiSlugger();
$slug = 'nzine-'.$slugger->slug($title)->lower().'-'.rand(10000,99999);
// create NZine main index
$index = new Event();
$index->setKind(KindsEnum::PUBLICATION_INDEX->value);
$index->addTag(['d' => $slug]);
$index->addTag(['title' => $title]);
$index->addTag(['summary' => $summary]);
$index->addTag(['auto-update' => 'yes']);
$index->addTag(['type' => 'magazine']);
$signer = new Sign();
$signer->signEvent($index, $private_key);
// save to persistence, first map to EventEntity
$serializer = new Serializer([new ObjectNormalizer()],[new JsonEncoder()]);
$i = $serializer->deserialize($index->toJson(), EventEntity::class, 'json');
$this->entityManager->persist($i);
$this->nzineWorkflow->apply($nzine, 'create_main_index');
$this->entityManager->persist($nzine);
$this->entityManager->flush();
}
public function createNestedIndex(Nzine $nzine, string $categoryTitle, array $tags): void
{
if (!$this->nzineWorkflow->can($nzine, 'create_nested_indices')) {
throw new \LogicException('Cannot create nested indices in the current state.');
}
// Example logic: Create a nested index for the category
$nestedIndex = new EventEntity();
// $nestedIndex->setTitle($categoryTitle);
$nestedIndex->setTags($tags);
$nestedIndex->setKind('30040'); // Assuming 30040 is the kind for publication indices
$this->entityManager->persist($nestedIndex);
$this->nzineWorkflow->apply($nzine, 'create_nested_indices');
$this->entityManager->persist($nzine);
$this->entityManager->flush();
}
}

2
src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php

@ -20,7 +20,7 @@ class NostrEventRenderer implements NodeRendererInterface @@ -20,7 +20,7 @@ class NostrEventRenderer implements NodeRendererInterface
// Construct the local link URL from the special part
$url = '/e/' . $node->getSpecial();
} else if ($node->getType() === 'naddr') {
dump($node);
// dump($node);
// Construct the local link URL from the special part
$url = '/' . $node->getSpecial();
}

Loading…
Cancel
Save