Compare commits

...

28 Commits

Author SHA1 Message Date
Alexey Skobkin 49d4097a47 DTO\Api\Post phpDoc cleaning. 2019-02-28 03:46:32 +03:00
Alexey Skobkin 3f31705536 WS processing IdentityMap cleaning. 2019-02-28 03:44:59 +03:00
Alexey Skobkin 6b5a60b2a5 WS 'comment' processing. WIP. 2019-02-28 03:43:40 +03:00
Alexey Skobkin b0a6fbfb7f Comment DB unique constraint (post_id, number). New migration. 2019-02-28 02:44:51 +03:00
Alexey Skobkin a12bf9d9a2 WS 'post' processing draft. 2019-02-28 02:26:01 +03:00
Alexey Skobkin dbc1c060f8 Fixing WS message processing conditions in ProcessWebsocketUpdatesCommand. 2019-02-25 22:10:17 +03:00
Alexey Skobkin b1d047941c New WS\Message types added: post_edited, comment_edited. Validation slightly refactored. 2019-02-25 20:02:26 +03:00
Alexey Skobkin 4d7549bddd Fixing crash on unexpected message type or deserialization error. Adding sentry logging to WS processing. 2019-02-25 20:01:47 +03:00
Alexey Skobkin ac0d905ce4 Implementing 'rec' type validation in WebSocket\Message. 2019-02-25 19:12:52 +03:00
Alexey Skobkin 16489b509e Message::$postAuthorId added. 2019-02-25 19:00:36 +03:00
Alexey Skobkin 728464005e Small style changes in ProcessWebsocketUpdatesCommand. 2019-02-25 17:56:46 +03:00
Alexey Skobkin 4ce5fe0ccb WebSocketMessageProcessor todo comments. 2019-02-24 02:08:15 +03:00
Alexey Skobkin 300dbcb466 PostFactory::findOrCreateFromWebsocketDto() draft. UserFactory::DATE_FORMAT moved to AbstractFactory. 2019-02-24 02:07:17 +03:00
Alexey Skobkin d528b45436 DTO\WebSocket\Message::$authorId for upcoming point update (https://github.com/artss/point/pull/13). Serialization config changed. Setter methods removed. 2019-02-24 01:40:49 +03:00
Alexey Skobkin f2b423ad8c Fixing missing Message::$rcid mapping for recommendations without comment. 2019-02-24 01:38:47 +03:00
Alexey Skobkin 4707b41f27 --keep-jobs option added for 'point:update:websocket-messages' command. 2019-02-24 01:37:30 +03:00
Alexey Skobkin 05aaa1d4e1 Fixing circular dependency in UserFactory. 2019-02-23 21:11:11 +03:00
Alexey Skobkin 26ee4522fc Unfinished work on WS comment retrieval, 2019-02-23 20:51:42 +03:00
Alexey Skobkin 63b27dc312 Post::addComment() bug fix. 2019-02-23 20:49:48 +03:00
Alexey Skobkin f5a4e5b896 #44 composer require leezy/pheanstalk-bundle (bundle configuration as well). 2019-02-23 20:49:48 +03:00
Alexey Skobkin 4eb7b418db #44 Database migration for new schema. 2019-02-23 20:46:15 +03:00
Alexey Skobkin 5897555302 #44 Comment mapping fix. 2019-02-23 20:46:15 +03:00
Alexey Skobkin 7902f9b3ea #44 Post mapping fix. 2019-02-23 20:46:15 +03:00
Alexey Skobkin f8dfe4e103 Fix #45. Doctrine partial index recreation on new migration generation fixed. 2019-02-23 20:46:15 +03:00
Alexey Skobkin 46b327b455 WS updates handling draft. Updates processing implementation needed. 2019-02-23 20:46:15 +03:00
Alexey Skobkin 31d49eb270 Comment::$toNumber type fixed. LoadCommentsData fixed according to new Comment constructor. 2019-02-23 20:46:15 +03:00
Alexey Skobkin 5d2ce0fe42 'point:update:websocket-messages' command draft. 2019-02-23 20:46:15 +03:00
Alexey Skobkin e82ff0d2a1 Comment schema changes, entity refactored. Post::$subscribed added. Migration needed. 2019-02-23 20:46:15 +03:00
31 changed files with 1087 additions and 244 deletions

View File

@ -19,6 +19,7 @@ class AppKernel extends Kernel
new Symfony\Bundle\WebServerBundle\WebServerBundle(),
new JMS\SerializerBundle\JMSSerializerBundle(),
new Csa\Bundle\GuzzleBundle\CsaGuzzleBundle(),
new Leezy\PheanstalkBundle\LeezyPheanstalkBundle(),
new Ob\HighchartsBundle\ObHighchartsBundle(),
new Knp\Bundle\MarkdownBundle\KnpMarkdownBundle(),
new Knp\Bundle\PaginatorBundle\KnpPaginatorBundle(),

View File

@ -0,0 +1,51 @@
<?php
namespace Application\Migrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Issue #44 - Post and Comment schema refactoring.
* - Post subscription status added.
* - Comments parent-child relations removed.
* - User login index
* - Other adjustments
*/
class Version20180427143940 extends AbstractMigration
{
public function up(Schema $schema)
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE posts.posts ADD is_subscribed BOOLEAN DEFAULT FALSE NOT NULL');
// Removing parent_id constraint and index
$this->addSql('ALTER TABLE posts.comments DROP CONSTRAINT fk_62899975727aca70');
$this->addSql('DROP INDEX posts.idx_62899975727aca70');
$this->addSql('ALTER TABLE posts.comments DROP parent_id');
$this->addSql('ALTER TABLE posts.comments ADD to_number INT');
$this->addSql('ALTER TABLE posts.comments ALTER number TYPE INT');
$this->addSql('ALTER TABLE posts.comments ALTER number DROP DEFAULT');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6289997596901F54 ON posts.comments (number)');
$this->addSql('CREATE INDEX idx_user_login ON users.users (login)');
}
public function down(Schema $schema)
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE posts.posts DROP is_subscribed');
$this->addSql('DROP INDEX posts.UNIQ_6289997596901F54');
$this->addSql('ALTER TABLE posts.comments ADD parent_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE posts.comments DROP to_number');
$this->addSql('ALTER TABLE posts.comments ALTER number TYPE SMALLINT');
$this->addSql('ALTER TABLE posts.comments ALTER number DROP DEFAULT');
$this->addSql('ALTER TABLE posts.comments ADD CONSTRAINT fk_62899975727aca70 FOREIGN KEY (parent_id) REFERENCES posts.comments (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_62899975727aca70 ON posts.comments (parent_id)');
$this->addSql('DROP INDEX users.idx_user_login');
}
}

View File

@ -0,0 +1,28 @@
<?php declare(strict_types=1);
namespace Application\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20190227233244 extends AbstractMigration
{
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('CREATE UNIQUE INDEX unique_post_id_comment_number ON posts.comments (post_id, number)');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('DROP INDEX posts.unique_post_id_comment_number');
}
}

View File

@ -71,6 +71,13 @@ doctrine_migrations:
table_name: migration_versions
name: Application Migrations
leezy_pheanstalk:
pheanstalks:
primary:
server: "%beanstalkd_host%"
port: "%beanstalkd_port%"
default: true
# Swiftmailer Configuration
swiftmailer:
transport: "%mailer_transport%"

View File

@ -6,6 +6,11 @@ parameters:
database_user: point
database_password: ~
# Message Queue settings
beanstalkd_host: 'localhost'
beanstalkd_port: 11300
beanstalkd_ws_updates_tube: 'point-websocket-updates'
mailer_transport: smtp
mailer_host: 127.0.0.1
mailer_user: ~

View File

@ -101,6 +101,13 @@ services:
# Send message
Skobkin\Bundle\PointToolsBundle\Command\TelegramSendMessageCommand:
tags: [{ name: console.command }]
# WebSocket MQ processing
Skobkin\Bundle\PointToolsBundle\Command\ProcessWebsocketUpdatesCommand:
arguments:
$bsClient: '@leezy.pheanstalk.primary'
$bsTubeName: '%beanstalkd_ws_updates_tube%'
tags:
- { name: console.command }
# Entity repositories as services

View File

@ -33,7 +33,8 @@
"unreal4u/telegram-api": "^2.2",
"csa/guzzle-bundle": "^3",
"symfony/web-server-bundle": "^3.3",
"sentry/sentry-symfony": "^2.2"
"sentry/sentry-symfony": "^2.2",
"leezy/pheanstalk-bundle": "^3.3"
},
"require-dev": {
"symfony/phpunit-bridge": "^3.0",

110
composer.lock generated
View File

@ -2273,6 +2273,66 @@
],
"time": "2018-05-16T12:15:58+00:00"
},
{
"name": "leezy/pheanstalk-bundle",
"version": "3.3.0",
"source": {
"type": "git",
"url": "https://github.com/armetiz/LeezyPheanstalkBundle.git",
"reference": "76056c91c4021356b1bd4870f6bcd30d612d358d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/armetiz/LeezyPheanstalkBundle/zipball/76056c91c4021356b1bd4870f6bcd30d612d358d",
"reference": "76056c91c4021356b1bd4870f6bcd30d612d358d",
"shasum": ""
},
"require": {
"pda/pheanstalk": "~3.0",
"php": ">=5.5.9",
"psr/log": "~1.0",
"symfony/console": "~2.5|~3.0|^4.0",
"symfony/framework-bundle": "~2.5|~3.0|^4.0"
},
"require-dev": {
"phpunit/phpunit": "~4.0|~5.0",
"phpunit/phpunit-mock-objects": "2.3.0|~3.4"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Leezy\\PheanstalkBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Thomas Tourlourat",
"email": "thomas@tourlourat.com",
"homepage": "http://www.armetiz.info"
}
],
"description": "The LeezyPheanstalkBundle is a Symfony2 Bundle that provides a command line interface for manage the Beanstalkd workqueue server & a pheanstalk integration.",
"homepage": "https://github.com/armetiz/LeezyPheanstalkBundle",
"keywords": [
"asynchronous",
"beanstalkd",
"bundle",
"messaging",
"pheanstalk",
"queueing",
"symfony"
],
"time": "2018-03-02T15:28:52+00:00"
},
{
"name": "michelf/php-markdown",
"version": "1.8.0",
@ -2623,6 +2683,56 @@
],
"time": "2018-07-02T15:55:56+00:00"
},
{
"name": "pda/pheanstalk",
"version": "v3.1.0",
"source": {
"type": "git",
"url": "https://github.com/pda/pheanstalk.git",
"reference": "430e77c551479aad0c6ada0450ee844cf656a18b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/pda/pheanstalk/zipball/430e77c551479aad0c6ada0450ee844cf656a18b",
"reference": "430e77c551479aad0c6ada0450ee844cf656a18b",
"shasum": ""
},
"require": {
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "~4.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Pheanstalk\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Annesley",
"email": "paul@annesley.cc",
"homepage": "http://paul.annesley.cc/",
"role": "Developer"
}
],
"description": "PHP client for beanstalkd queue",
"homepage": "https://github.com/pda/pheanstalk",
"keywords": [
"beanstalkd"
],
"time": "2015-08-07T21:42:41+00:00"
},
{
"name": "phpcollection/phpcollection",
"version": "0.5.0",

View File

@ -0,0 +1,114 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Command;
use Doctrine\ORM\EntityManagerInterface;
use JMS\Serializer\Serializer;
use Leezy\PheanstalkBundle\Proxy\PheanstalkProxy;
use Pheanstalk\Job;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\WebSocket\Message;
use Skobkin\Bundle\PointToolsBundle\Exception\WebSocket\UnsupportedTypeException;
use Skobkin\Bundle\PointToolsBundle\Service\WebSocket\WebSocketMessageProcessor;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\{InputInterface, InputOption};
use Symfony\Component\Console\Output\OutputInterface;
/**
* This command processes WebSocket updates MQ and stores new content in the DB
*/
class ProcessWebsocketUpdatesCommand extends Command
{
/** @var EntityManagerInterface */
private $em;
/** @var PheanstalkProxy */
private $bsClient;
/** @var string */
private $bsTubeName;
/** @var Serializer */
private $serializer;
/** @var WebSocketMessageProcessor */
private $messageProcessor;
/** @var \Raven_Client */
private $sentryClient;
public function __construct(
EntityManagerInterface $em,
\Raven_Client $raven,
PheanstalkProxy $bsClient,
string $bsTubeName,
Serializer $serializer,
WebSocketMessageProcessor $processor
) {
$this->em = $em;
$this->sentryClient = $raven;
$this->serializer = $serializer;
$this->messageProcessor = $processor;
$this->bsClient = $bsClient;
$this->bsTubeName = $bsTubeName;
parent::__construct();
}
/** {@inheritdoc} */
protected function configure()
{
$this
->setName('point:update:websocket-messages')
->setDescription('Reads and processes updates from Beanstalkd queue pipe')
->addOption('keep-jobs', 'k', InputOption::VALUE_NONE, 'Don\'t delete jobs from queue after processing')
;
}
/** {@inheritdoc} */
protected function execute(InputInterface $input, OutputInterface $output)
{
$keepJobs = (bool) $input->getOption('keep-jobs');
/** @var Job $job */
while ($job = $this->bsClient->reserveFromTube($this->bsTubeName, 0)) {
try {
/** @var Message $message */
$message = $this->serializer->deserialize($job->getData(), Message::class, 'json');
} catch (\Exception $e) {
$output->writeln(sprintf(
'Error while deserializing #%d data: \'%s\'',
$job->getId(),
$job->getData()
));
$this->sentryClient->captureException($e);
continue;
}
$output->writeln('Processing job #'.$job->getId().' ('.$message->getA().')');
try {
if ($this->messageProcessor->processMessage($message)) {
$this->em->flush();
if (!$keepJobs) {
$this->bsClient->delete($job);
}
$this->em->clear();
}
} catch (UnsupportedTypeException $e) {
$output->writeln(' Unsupported message type: '.$message->getA());
$this->sentryClient->captureException($e);
continue;
} catch (\Exception $e) {
$output->writeln(' Message processing error: '.$e->getMessage());
$this->sentryClient->captureException($e);
continue;
}
}
}
}

View File

@ -4,39 +4,22 @@ namespace Skobkin\Bundle\PointToolsBundle\DTO\Api;
class Comment implements ValidableInterface
{
/**
* @var string|null
*/
private $postId;
/**
* @var int|null
*/
/** @var int|null */
private $number;
/**
* @var int|null
*/
/** @var int|null */
private $toCommentId;
/**
* @var string|null
*/
/** @var string|null */
private $created;
/**
* @var string|null
*/
/** @var string|null */
private $text;
/**
* @var User|null
*/
/** @var User|null */
private $author;
/**
* @var bool|null
*/
/** @var bool|null */
private $isRec;

View File

@ -4,27 +4,17 @@ namespace Skobkin\Bundle\PointToolsBundle\DTO\Api;
class MetaPost implements ValidableInterface
{
/**
* @var Post|null
*/
/** @var Post|null */
private $post;
/**
* @var Comment[]|null
*/
/** @var Comment[]|null */
private $comments;
public function getPost(): ?Post
{
return $this->post;
}
public function setPost(?Post $post): void
{
$this->post = $post;
}
/**
* @return Comment[]|null
*/
@ -33,20 +23,8 @@ class MetaPost implements ValidableInterface
return $this->comments;
}
/**
* @param Comment[]|null $comments
*/
public function setComments(?array $comments): void
{
$this->comments = $comments;
}
public function isValid(): bool
{
if (null !== $this->post && $this->post->isValid()) {
return true;
}
return false;
return (null !== $this->post && $this->post->isValid());
}
}

View File

@ -4,44 +4,28 @@ namespace Skobkin\Bundle\PointToolsBundle\DTO\Api;
class Post implements ValidableInterface
{
/**
* @var string|null
*/
/** @var string|null */
private $id;
/**
* @var string[]|null
*/
/** @var string[]|null */
private $tags;
/**
* @var string[]|null
*/
/** @var string[]|null */
private $files;
/**
* @var User|null
*/
/** @var User|null */
private $author;
/**
* @var string|null
*/
/** @var string|null */
private $text;
/**
* @var string|null
*/
/** @var string|null */
private $created;
/**
* @var string|null
*/
/** @var string|null */
private $type;
/**
* @var bool|null
*/
/** @var bool|null */
private $private;

View File

@ -0,0 +1,330 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\DTO\Api\WebSocket;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\ValidableInterface;
use Skobkin\Bundle\PointToolsBundle\Exception\WebSocket\UnsupportedTypeException;
/**
* WebSocket update message
*/
class Message implements ValidableInterface
{
public const TYPE_COMMENT = 'comment';
public const TYPE_COMMENT_EDITED = 'comment_edited';
public const TYPE_POST = 'post';
public const TYPE_POST_EDITED = 'post_edited';
public const TYPE_POST_COMMENT_RECOMMENDATION = 'rec';
public const TYPE_RECOMMENDATION_WITH_COMMENT = 'ok';
/**
* Event type. @see Message::TYPE_* constants
*
* @var string
*/
private $a;
/**
* Login of the user
*
* @var string
*/
private $author;
/**
* @var int
*/
private $authorId;
/** @var string|null */
private $authorName;
/**
* Number of the comment in the thread
*
* @var int|null
*/
private $commentId;
/**
* ???
*
* @var bool|null
*/
private $cut;
/**
* Array of file paths
*
* @var string[]|null
*/
private $files;
/** @var string|null */
private $html;
/**
* @deprecated Link in the Post::type=feed posts
*
* @var string|null
*/
private $link;
/**
* @var int|null
*/
private $postAuthorId;
/** @var string */
private $postId;
/** @var bool|null */
private $private;
/**
* Number of the comment in the thread for recommendation with text
*
* @var int|null
*/
private $rcid;
/**
* Array of tags
*
* @var string[]|null
*/
private $tags;
/** @var string */
private $text;
/**
* @deprecated ???
*
* @var string|null
*/
private $title;
/**
* Number of the comment to which this comment is answering
*
* @var string|null
*/
private $toCommentId;
/**
* Text quotation of the comment to which this comment is answering
*
* @var string|null
*/
private $toText;
/**
* Array of logins of users to which post is addressed
*
* @var string[]|null
*/
private $toUsers;
public function isPost(): bool
{
return self::TYPE_POST === $this->a;
}
public function isComment(): bool
{
return self::TYPE_COMMENT === $this->a;
}
public function isCommentRecommendation(): bool
{
return self::TYPE_RECOMMENDATION_WITH_COMMENT === $this->a;
}
public function isPostRecommendation(): bool
{
return self::TYPE_POST_COMMENT_RECOMMENDATION === $this->a;
}
/**
* @throws \RuntimeException
* @throws UnsupportedTypeException
*/
public function isValid(): bool
{
switch ($this->a) {
case self::TYPE_POST:
return $this->isValidPost();
break;
case self::TYPE_POST_EDITED:
return $this->isValidPostEdited();
break;
case self::TYPE_COMMENT;
return $this->isValidComment();
break;
case self::TYPE_COMMENT_EDITED;
return $this->isValidCommentEdited();
break;
case self::TYPE_RECOMMENDATION_WITH_COMMENT;
return $this->isValidRecommendationWithComment();
break;
case self::TYPE_POST_COMMENT_RECOMMENDATION;
return $this->isValidPostCommentRecommendation();
break;
case null:
throw new \RuntimeException('Message has NULL type.');
break;
default:
throw new UnsupportedTypeException(sprintf('Type \'%s\' is not supported.', $this->a));
}
}
public function getA(): string
{
return $this->a;
}
public function getAuthor(): string
{
return $this->author;
}
public function getAuthorId(): int
{
return $this->authorId;
}
public function getAuthorName(): ?string
{
return $this->authorName;
}
public function getCommentId(): ?int
{
return $this->commentId;
}
public function getCut(): ?bool
{
return $this->cut;
}
public function getFiles(): ?array
{
return $this->files;
}
public function getHtml(): ?string
{
return $this->html;
}
public function getLink(): ?string
{
return $this->link;
}
public function getPostId(): string
{
return $this->postId;
}
public function getPostAuthorId(): ?int
{
return $this->postAuthorId;
}
public function getPrivate(): ?bool
{
return $this->private;
}
public function getRcid(): ?int
{
return $this->rcid;
}
public function getTags(): ?array
{
return $this->tags;
}
public function getText(): string
{
return $this->text;
}
public function getTitle(): ?string
{
return $this->title;
}
public function getToCommentId(): ?string
{
return $this->toCommentId;
}
public function getToText(): ?string
{
return $this->toText;
}
public function getToUsers(): ?array
{
return $this->toUsers;
}
private function hasCommonMandatoryData(): bool
{
return (
null !== $this->author &&
null !== $this->authorId &&
null !== $this->postId
);
}
private function isValidPost(): bool
{
return $this->hasCommonMandatoryData() && (
// Text can be empty ("") though
null !== $this->text &&
null !== $this->tags
);
}
private function isValidPostEdited(): bool
{
return $this->isValidPost();
}
private function isValidComment(): bool
{
return $this->hasCommonMandatoryData() && (
null !== $this->commentId &&
null !== $this->html &&
null !== $this->text
);
}
private function isValidCommentEdited(): bool
{
return $this->hasCommonMandatoryData() && (null !== $this->commentId);
}
private function isValidRecommendationWithComment(): bool
{
return $this->hasCommonMandatoryData();
}
private function isValidPostCommentRecommendation(): bool
{
return $this->hasCommonMandatoryData() && (null !== $this->postAuthorId);
}
}

View File

@ -11,7 +11,7 @@ use Skobkin\Bundle\PointToolsBundle\Entity\User;
class LoadCommentsData extends AbstractFixture implements OrderedFixtureInterface
{
public function load(ObjectManager $om)
public function load(ObjectManager $om): void
{
/** @var Post $post */
$post = $this->getReference('test_post_longpost');
@ -25,29 +25,28 @@ class LoadCommentsData extends AbstractFixture implements OrderedFixtureInterfac
$this->getReference('test_user_99995'),
];
$comments = [];
$text = 'Some text with [link to @skobkin-ru site](https://skobk.in/) and `code block`'.PHP_EOL.
'and some quotation:'.PHP_EOL.
'> test test quote'.PHP_EOL.
'and some text after';
foreach (range(1, 10000) as $num) {
$comment = (new Comment())
->setNumber($num)
->setDeleted(mt_rand(0, 15) ? false : true)
->setCreatedAt(new \DateTime())
->setAuthor($users[array_rand($users)])
->setRec(false)
->setText(
'Some text with [link to @skobkin-ru site](https://skobk.in/) and `code block`'.PHP_EOL.
'and some quotation:'.PHP_EOL.
'> test test quote'.PHP_EOL.
'and some text after'
)
;
$comment = new Comment(
$text,
new \DateTime(),
false,
$post,
$num,
($num > 1 && !random_int(0, 4)) ? random_int(1, $num - 1) : null,
$users[array_rand($users)],
[]
);
if (count($comments) > 0 && mt_rand(0, 1)) {
$comment->setParent($comments[mt_rand(0, count($comments) - 1)]);
if (!random_int(0, 15)) {
$comment->delete();
}
$post->addComment($comment);
$comments[] = $comment;
$om->persist($comment);
}

View File

@ -9,6 +9,8 @@ use Skobkin\Bundle\PointToolsBundle\Entity\User;
/**
* @ORM\Table(name="comments", schema="posts", indexes={
* @ORM\Index(name="idx_comment_created_at", columns={"created_at"})
* }, uniqueConstraints={
* @ORM\UniqueConstraint(name="unique_post_id_comment_number", columns={"post_id", "number"})
* })
* @ORM\Entity(repositoryClass="Skobkin\Bundle\PointToolsBundle\Repository\Blogs\CommentRepository")
*/
@ -62,10 +64,17 @@ class Comment
/**
* @var int
*
* @ORM\Column(name="number", type="smallint")
* @ORM\Column(name="number", type="integer", unique=true)
*/
private $number;
/**
* @var int|null
*
* @ORM\Column(name="to_number", type="integer", nullable=true)
*/
private $toNumber;
/**
* @var User
*
@ -85,26 +94,36 @@ class Comment
*/
private $files;
/**
* @var Comment|null
*
* @ORM\ManyToOne(targetEntity="Skobkin\Bundle\PointToolsBundle\Entity\Blogs\Comment", inversedBy="children")
* @ORM\JoinColumn(name="parent_id", nullable=true)
*/
private $parent;
/**
* @var Comment[]|ArrayCollection
*
* @ORM\OneToMany(targetEntity="Skobkin\Bundle\PointToolsBundle\Entity\Blogs\Comment", fetch="EXTRA_LAZY", mappedBy="parent")
*/
private $children;
public function __construct(
string $text,
\DateTime $createdAt,
bool $rec,
Post $post,
int $number,
?int $toNumber,
User $author,
array $files
) {
$this->text = $text;
$this->createdAt = $createdAt;
$this->rec = $rec;
$this->post = $post;
$this->number = $number;
$this->toNumber = $toNumber;
$this->author = $author;
public function __construct()
{
$this->files = new ArrayCollection();
$this->children = new ArrayCollection();
foreach ($files as $file) {
if (!($file instanceof File)) {
throw new \RuntimeException(sprintf(
'$files array must contain only \'%s\' objects. %s given.',
\is_object($file) ? \get_class($file) : \gettype($file)
));
}
$this->files->add($file);
}
}
public function getId(): int
@ -112,95 +131,41 @@ class Comment
return $this->id;
}
public function setCreatedAt(\DateTime $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getCreatedAt(): \DateTime
{
return $this->createdAt;
}
public function setText(string $text): self
{
$this->text = $text;
return $this;
}
public function getText(): string
{
return $this->text;
}
public function setRec(bool $rec): self
{
$this->rec = $rec;
return $this;
}
public function isRec(): bool
{
return $this->rec;
}
public function getRec(): bool
{
return $this->rec;
}
public function getPost(): Post
{
return $this->post;
}
public function setPost(Post $post): self
{
$this->post = $post;
return $this;
}
public function setNumber(int $number): self
{
$this->number = $number;
return $this;
}
public function getNumber(): int
{
return $this->number;
}
public function getToNumber(): ?int
{
return $this->toNumber;
}
public function getAuthor(): User
{
return $this->author;
}
public function setAuthor(User $author): self
{
$this->author = $author;
return $this;
}
public function addFile(File $files): self
{
$this->files[] = $files;
return $this;
}
public function removeFile(File $files): void
{
$this->files->removeElement($files);
}
/**
* @return File[]|ArrayCollection
*/
@ -209,52 +174,22 @@ class Comment
return $this->files;
}
public function getParent(): ?Comment
public function delete(): self
{
return $this->parent;
}
public function setParent(Comment $parent): self
{
$this->parent = $parent;
$this->deleted = true;
return $this;
}
public function setDeleted(bool $deleted): self
public function restore(): self
{
$this->deleted = $deleted;
$this->deleted = false;
return $this;
}
public function getDeleted(): bool
{
return $this->deleted;
}
public function isDeleted(): bool
{
return $this->deleted;
}
public function addChild(Comment $children): self
{
$this->children[] = $children;
return $this;
}
public function removeChild(Comment $children): void
{
$this->children->removeElement($children);
}
/**
* @return Comment[]|ArrayCollection
*/
public function getChildren(): iterable
{
return $this->children;
}
}

View File

@ -69,6 +69,13 @@ class Post
*/
private $deleted = false;
/**
* @var bool Status of point-tools subscription to the post (to receive WS updates)
*
* @ORM\Column(name="is_subscribed", type="boolean", options={"default": false})
*/
private $subscribed = false;
/**
* @var User
*
@ -103,7 +110,7 @@ class Post
private $comments;
public function __construct(string $id, User $author, \DateTime $createdAt, string $type)
public function __construct(string $id, User $author, \DateTime $createdAt, string $type = self::TYPE_POST)
{
$this->id = $id;
$this->author = $author;
@ -216,6 +223,25 @@ class Post
{
return $this->deleted;
}
public function isSubscribed(): bool
{
return $this->subscribed;
}
public function subscribe(): self
{
$this->subscribed = true;
return $this;
}
public function unsubscribe(): self
{
$this->subscribed = false;
return $this;
}
public function setPrivate(bool $private): self
{
@ -237,7 +263,6 @@ class Post
public function addComment(Comment $comment): self
{
$this->comments[] = $comment;
$comment->setPost($this);
return $this;
}

View File

@ -7,8 +7,8 @@ use Skobkin\Bundle\PointToolsBundle\Entity\User;
/**
* @ORM\Table(name="telegram_accounts", schema="users", indexes={
* @ORM\Index(name="subscriber_notification_idx", columns={"subscriber_notification"}, options={"where": "subscriber_notification = TRUE"}),
* @ORM\Index(name="rename_notification_idx", columns={"rename_notification"}, options={"where": "rename_notification = TRUE"}),
* @ORM\Index(name="subscriber_notification_idx", columns={"subscriber_notification"}, options={"where": "(subscriber_notification = true)"}),
* @ORM\Index(name="rename_notification_idx", columns={"rename_notification"}, options={"where": "(rename_notification = true)"}),
* })
* @ORM\Entity(repositoryClass="Skobkin\Bundle\PointToolsBundle\Repository\Telegram\AccountRepository")
* @ORM\HasLifecycleCallbacks()

View File

@ -7,6 +7,7 @@ use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Table(name="users", schema="users", indexes={
* @ORM\Index(name="idx_user_login", columns={"login"}),
* @ORM\Index(name="idx_user_public", columns={"public"}),
* @ORM\Index(name="idx_user_removed", columns={"is_removed"})
* })
@ -190,15 +191,24 @@ class User
return $this->createdAt;
}
public function updateCreatedAt(\DateTime $date): self
{
$this->createdAt = $date;
return $this;
}
public function getUpdatedAt(): ?\DateTime
{
return $this->updatedAt;
}
public function updatePrivacy(?bool $public, ?bool $whitelistOnly): void
public function updatePrivacy(bool $public, bool $whitelistOnly): self
{
$this->public = $public;
$this->whitelistOnly = $whitelistOnly;
return $this;
}
public function isPublic(): ?bool

View File

@ -0,0 +1,20 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Exception\Api;
class PostNotFoundException extends NotFoundException
{
/** @var string */
private $id;
public function __construct(string $id, \Exception $previous)
{
parent::__construct('Post not found', 0, $previous);
$this->id = $id;
}
public function getId(): string
{
return $this->id;
}
}

View File

@ -4,16 +4,11 @@ namespace Skobkin\Bundle\PointToolsBundle\Exception\Api;
class UserNotFoundException extends NotFoundException
{
/**
* @var int
*/
protected $userId;
/**
* @var string
*/
protected $login;
/** @var int */
private $userId;
/** @var string */
private $login;
/**
* {@inheritdoc}

View File

@ -0,0 +1,9 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Exception\WebSocket;
use RuntimeException;
class UnsupportedTypeException extends RuntimeException
{
}

View File

@ -2,10 +2,6 @@ Skobkin\Bundle\PointToolsBundle\DTO\Api\Comment:
exclusion_policy: none
access_type: public_method
properties:
postId:
serialized_name: 'post_id'
type: 'Skobkin\Bundle\PointToolsBundle\DTO\Api\Post'
max_depth: 2
number:
serialized_name: 'id'
type: integer

View File

@ -1,10 +1,9 @@
Skobkin\Bundle\PointToolsBundle\DTO\Api\MetaPost:
exclusion_policy: none
access_type: public_method
properties:
post:
serialized_name: 'post'
type: 'Skobkin\Bundle\PointToolsBundle\DTO\Api\Post'
max_depth: 2
comments:
serialized_name: 'comments'
type: 'array<Skobkin\Bundle\PointToolsBundle\DTO\Api\Comment>'
max_depth: 2

View File

@ -0,0 +1,60 @@
Skobkin\Bundle\PointToolsBundle\DTO\Api\WebSocket\Message:
exclusion_policy: none
properties:
a:
type: string
serialized_name: 'a'
author:
type: string
serialized_name: 'author'
authorId:
type: int
serialized_name: 'author_id'
authorName:
type: string
serialized_name: 'author_name'
commentId:
type: int
serialized_name: 'comment_id'
cut:
type: bool
serialized_name: 'cut'
files:
type: array<string>
serialized_name: 'files'
html:
type: string
serialized_name: 'html'
link:
type: string
serialized_name: 'link'
postId:
type: string
serialized_name: 'post_id'
postAuthorId:
type: int
serialized_name: 'post_author_id'
private:
type: bool
serialized_name: 'private'
rcid:
type: int
serialized_name: 'rcid'
tags:
type: array<string>
serialized_name: 'tags'
text:
type: string
serialized_name: 'text'
title:
type: string
serialized_name: 'title'
toCommentId:
type: string
serialized_name: 'to_comment_id'
toText:
type: string
serialized_name: 'to_text'
toUsers:
type: array<string>
serialized_name: 'to_users'

View File

@ -5,6 +5,9 @@ namespace Skobkin\Bundle\PointToolsBundle\Service\Api;
use GuzzleHttp\ClientInterface;
use JMS\Serializer\SerializerInterface;
use Psr\Log\LoggerInterface;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\MetaPost;
use Skobkin\Bundle\PointToolsBundle\Entity\Blogs\Post;
use Skobkin\Bundle\PointToolsBundle\Exception\Api\{NotFoundException, PostNotFoundException};
use Skobkin\Bundle\PointToolsBundle\Service\Factory\Blogs\PostFactory;
/**
@ -12,6 +15,8 @@ use Skobkin\Bundle\PointToolsBundle\Service\Factory\Blogs\PostFactory;
*/
class PostApi extends AbstractApi
{
private const PREFIX = '/api/post/';
/**
* @var PostFactory
*/
@ -24,4 +29,24 @@ class PostApi extends AbstractApi
$this->postFactory = $postFactory;
}
/**
* @throws PostNotFoundException
*/
public function getById(string $id): Post
{
try {
$postData = $this->getGetJsonData(
self::PREFIX.$id,
[],
MetaPost::class
);
} catch (NotFoundException $e) {
throw new PostNotFoundException($id, $e);
}
// Not catching ForbiddenException right now
return $this->postFactory->findOrCreateFromDtoWithContent($postData);
}
}

View File

@ -3,9 +3,7 @@
namespace Skobkin\Bundle\PointToolsBundle\Service\Api;
use GuzzleHttp\ClientInterface;
use JMS\Serializer\{
DeserializationContext, SerializerInterface
};
use JMS\Serializer\{DeserializationContext, SerializerInterface};
use Psr\Log\LoggerInterface;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\{Auth, User as UserDTO};
use Skobkin\Bundle\PointToolsBundle\Entity\User;

View File

@ -6,6 +6,8 @@ use Psr\Log\LoggerInterface;
abstract class AbstractFactory
{
public const DATE_FORMAT = 'Y-m-d_H:i:s';
/** @var LoggerInterface */
protected $logger;

View File

@ -3,7 +3,10 @@
namespace Skobkin\Bundle\PointToolsBundle\Service\Factory\Blogs;
use Psr\Log\LoggerInterface;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\WebSocket\Message;
use Skobkin\Bundle\PointToolsBundle\Entity\Blogs\Comment;
use Skobkin\Bundle\PointToolsBundle\Repository\Blogs\{CommentRepository, PostRepository};
use Skobkin\Bundle\PointToolsBundle\Service\Api\PostApi;
use Skobkin\Bundle\PointToolsBundle\Service\Factory\{AbstractFactory, UserFactory};
class CommentFactory extends AbstractFactory
@ -17,12 +20,45 @@ class CommentFactory extends AbstractFactory
/** @var UserFactory */
private $userFactory;
/** @var PostApi */
private $postApi;
public function __construct(LoggerInterface $logger, CommentRepository $commentRepository, PostRepository $postRepository, UserFactory $userFactory)
public function __construct(LoggerInterface $logger, CommentRepository $commentRepository, PostRepository $postRepository, UserFactory $userFactory, PostApi $postApi)
{
parent::__construct($logger);
$this->userFactory = $userFactory;
$this->commentRepository = $commentRepository;
$this->postRepository = $postRepository;
$this->postApi = $postApi;
}
public function findOrCreateFromWebsocketMessage(Message $message): Comment
{
if ($message->isValid()) {
throw new \InvalidArgumentException('Comment is invalid');
}
if ($message->isComment()) {
throw new \InvalidArgumentException(sprintf(
'Invalid Message object provided. %s expected, %s given',
Message::TYPE_COMMENT,
$message->getA()
));
}
if (null === $comment = $this->commentRepository->findOneBy(['post' => $post, 'number' => $message->getCommentId()])) {
$author = $this->userFactory->findOrCreateFromIdLoginAndName(
$message->getAuthorId(),
$message->getAuthor(),
$message->getAuthorName()
);
if (null === $post = $this->postRepository->find($message->getPostId())) {
$post = $this->postApi->getById($message->getPostId());
}
// TODO
//$comment = new Comment()
}
}
}

View File

@ -4,10 +4,11 @@ namespace Skobkin\Bundle\PointToolsBundle\Service\Factory\Blogs;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\{MetaPost, Post as PostDTO, PostsPage};
use Skobkin\Bundle\PointToolsBundle\DTO\Api\{MetaPost, Post as ApiPost, PostsPage};
use Skobkin\Bundle\PointToolsBundle\DTO\Api\WebSocket\Message as WebsocketMessage;
use Skobkin\Bundle\PointToolsBundle\Entity\{Blogs\Post, Blogs\PostTag, User};
use Skobkin\Bundle\PointToolsBundle\Exception\{Api\InvalidResponseException, Factory\Blog\InvalidDataException};
use Skobkin\Bundle\PointToolsBundle\Repository\Blogs\PostRepository;
use Skobkin\Bundle\PointToolsBundle\Repository\{Blogs\PostRepository, UserRepository};
use Skobkin\Bundle\PointToolsBundle\Service\Factory\{AbstractFactory, UserFactory};
class PostFactory extends AbstractFactory
@ -18,6 +19,9 @@ class PostFactory extends AbstractFactory
/** @var PostRepository */
private $postRepository;
/** @var UserRepository */
private $userRepository;
/** @var UserFactory */
private $userFactory;
@ -35,6 +39,7 @@ class PostFactory extends AbstractFactory
LoggerInterface $logger,
EntityManagerInterface $em,
PostRepository $postRepository,
UserRepository $userRepository,
UserFactory $userFactory,
FileFactory $fileFactory,
CommentFactory $commentFactory,
@ -43,6 +48,7 @@ class PostFactory extends AbstractFactory
parent::__construct($logger);
$this->em = $em;
$this->postRepository = $postRepository;
$this->userRepository = $userRepository;
$this->userFactory = $userFactory;
$this->fileFactory = $fileFactory;
$this->commentFactory = $commentFactory;
@ -92,8 +98,6 @@ class PostFactory extends AbstractFactory
/**
* Create full post with tags, files and comments
*
* @todo Implement comments
*
* @throws InvalidDataException
*/
public function findOrCreateFromDtoWithContent(MetaPost $metaPost): Post
@ -111,7 +115,7 @@ class PostFactory extends AbstractFactory
throw $e;
}
$post = $this->findOrCreateFromDto($postData, $author);
$post = $this->findOrCreateFromApiDto($postData, $author);
try {
$this->updatePostTags($post, $postData->getTags() ?: []);
@ -127,10 +131,62 @@ class PostFactory extends AbstractFactory
throw $e;
}
// @TODO implement comments
return $post;
}
private function findOrCreateFromDto(PostDTO $postData, User $author): Post
public function findOrCreateFromWebsocketDto(WebsocketMessage $message): Post
{
if (!$message->isValid()) {
throw new InvalidDataException('Invalid post data');
}
if (!$message->isPost()) {
throw new \LogicException(sprintf(
'Incorrect message type received. \'post\' expected \'%s\' given',
$message->getA()
));
}
if (null === $post = $this->postRepository->find($message->getPostId())) {
$author = $this->userFactory->findOrCreateFromIdLoginAndName(
$message->getAuthorId(),
$message->getAuthor(),
$message->getAuthorName()
);
$post = new Post(
$message->getPostId(),
$author,
new \DateTime(),
Post::TYPE_POST
);
$this->postRepository->add($post);
}
$post
->setText($message->getText())
->setPrivate((bool) $message->getPrivate())
;
try {
$this->updatePostTags($post, $message->getTags() ?: []);
} catch (\Exception $e) {
$this->logger->error('Error while updating post tags');
throw $e;
}
try {
$this->updatePostFiles($post, $message->getFiles() ?: []);
} catch (\Exception $e) {
$this->logger->error('Error while updating post files');
throw $e;
}
return $post;
}
private function findOrCreateFromApiDto(ApiPost $postData, User $author): Post
{
if (null === ($post = $this->postRepository->find($postData->getId()))) {
// Creating new post
@ -145,7 +201,7 @@ class PostFactory extends AbstractFactory
$post
->setText($postData->getText())
->setPrivate($postData->getPrivate())
->setPrivate((bool) $postData->getPrivate())
;
return $post;

View File

@ -10,12 +10,9 @@ use Skobkin\Bundle\PointToolsBundle\Exception\Factory\InvalidUserDataException;
class UserFactory extends AbstractFactory
{
public const DATE_FORMAT = 'Y-m-d_H:i:s';
/** @var UserRepository */
private $userRepository;
public function __construct(LoggerInterface $logger, UserRepository $userRepository)
{
parent::__construct($logger);
@ -23,27 +20,25 @@ class UserFactory extends AbstractFactory
}
/**
* @param UserDTO $userData
*
* @return User
*
* @throws InvalidUserDataException
*/
public function findOrCreateFromDTO(UserDTO $userData): User
{
// @todo LOG
if (!$userData->isValid()) {
throw new InvalidUserDataException('Invalid user data', $userData);
}
$createdAt = \DateTime::createFromFormat(self::DATE_FORMAT, $userData->getCreated()) ?: new \DateTime();
/** @var User $user */
if (null === ($user = $this->userRepository->find($userData->getId()))) {
$user = new User(
$userData->getId(),
\DateTime::createFromFormat('Y-m-d_H:i:s', $userData->getCreated()) ?: new \DateTime()
$createdAt
);
$this->userRepository->add($user);
} else {
$user->updateCreatedAt($createdAt);
}
$user->updateLoginAndName($userData->getLogin(), $userData->getName());
@ -55,6 +50,21 @@ class UserFactory extends AbstractFactory
return $user;
}
public function findOrCreateFromIdLoginAndName(int $id, string $login, ?string $name): User
{
/** @var User $user */
if (null === $user = $this->userRepository->find($id)) {
// We're using current date now but next time when we'll be updating user from API it'll be fixed
$user = new User($id, new \DateTime(), $login, $name);
$this->userRepository->add($user);
} else {
// @todo update login?
// Probably don't because no name in the WS message (or maybe after PR to point?)
}
return $user;
}
/**
* @return User[]
*/

View File

@ -0,0 +1,69 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Service\WebSocket;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\WebSocket\Message;
use Skobkin\Bundle\PointToolsBundle\Service\Factory\Blogs\{CommentFactory, PostFactory};
class WebSocketMessageProcessor
{
/** @var PostFactory */
private $postFactory;
/** @var CommentFactory */
private $commentFactory;
public function __construct(PostFactory $postFactory, CommentFactory $commentFactory)
{
$this->postFactory = $postFactory;
$this->commentFactory = $commentFactory;
}
/**
* Returns true on success (all data saved)
*/
public function processMessage(Message $message): bool
{
if (!$message->isValid()) {
return false;
}
switch (true) {
case $message->isPost():
return $this->processPost($message);
break;
case $message->isComment():
return $this->processComment($message);
break;
case $message->isCommentRecommendation():
return $this->processRecommendation($message);
break;
}
return false;
}
private function processPost(Message $postData): bool
{
$this->postFactory->findOrCreateFromWebsocketDto($postData);
return true;
}
private function processComment(Message $commentData): bool
{
// Not done yet
return false;
$this->commentFactory->findOrCreateFromWebsocketMessage($commentData);
return true;
}
private function processRecommendation(Message $recommendData): bool
{
return false;
}
}