Added edit calendar events and fixed timezone

This commit is contained in:
Tim Lappe 2025-04-28 07:42:42 +02:00
parent 85ea87201d
commit ac995b1b83
27 changed files with 738 additions and 98 deletions

View File

@ -3,5 +3,6 @@
"phpstan.configFile": "backend/phpstan.dist.neon", "phpstan.configFile": "backend/phpstan.dist.neon",
"phpstan.checkValidity": true, "phpstan.checkValidity": true,
"phpstan.showTypeOnHover": false, "phpstan.showTypeOnHover": false,
"phpstan.showProgress": true "phpstan.showProgress": true,
"php.version": "8.4"
} }

View File

@ -26,5 +26,5 @@ APP_SECRET=71bf50bfb778d456b3a376ff60d5dcd8
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://postgres:postgres@calendi-postgres:5432/postgres?serverVersion=16&charset=utf8" DATABASE_URL="postgresql://postgres:postgres@calendi-postgres.test:5432/postgres?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###

View File

@ -4,7 +4,7 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.1", "php": ">=8.4",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"doctrine/annotations": "^2.0", "doctrine/annotations": "^2.0",
@ -20,6 +20,7 @@
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "6.4.*", "symfony/framework-bundle": "6.4.*",
"symfony/http-client": "6.4.*", "symfony/http-client": "6.4.*",
"symfony/monolog-bundle": "^3.10",
"symfony/property-access": "6.4.*", "symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*", "symfony/property-info": "6.4.*",
"symfony/runtime": "6.4.*", "symfony/runtime": "6.4.*",
@ -83,6 +84,8 @@
"doctrine/doctrine-fixtures-bundle": "^4.1", "doctrine/doctrine-fixtures-bundle": "^4.1",
"phpstan/phpstan": "^2.1", "phpstan/phpstan": "^2.1",
"phpstan/phpstan-symfony": "^2.0", "phpstan/phpstan-symfony": "^2.0",
"symfony/maker-bundle": "^1.62" "symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "6.4.*",
"symfony/web-profiler-bundle": "6.4.*"
} }
} }

349
backend/composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "f41287711c3c1d476ebbca47f5b529b5", "content-hash": "7ec99e86c547c32beef698aea0e9e346",
"packages": [ "packages": [
{ {
"name": "doctrine/annotations", "name": "doctrine/annotations",
@ -1202,6 +1202,109 @@
}, },
"time": "2025-01-24T11:45:48+00:00" "time": "2025-01-24T11:45:48+00:00"
}, },
{
"name": "monolog/monolog",
"version": "3.9.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6",
"reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^2.0 || ^3.0"
},
"provide": {
"psr/log-implementation": "3.0.0"
},
"require-dev": {
"aws/aws-sdk-php": "^3.0",
"doctrine/couchdb": "~1.0@dev",
"elasticsearch/elasticsearch": "^7 || ^8",
"ext-json": "*",
"graylog2/gelf-php": "^1.4.2 || ^2.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.2",
"mongodb/mongodb": "^1.8",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"php-console/php-console": "^3.1.8",
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
"predis/predis": "^1.1 || ^2",
"rollbar/rollbar": "^4.0",
"ruflin/elastica": "^7 || ^8",
"symfony/mailer": "^5.4 || ^6",
"symfony/mime": "^5.4 || ^6"
},
"suggest": {
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
"ext-mbstring": "Allow to work properly with unicode symbols",
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
"ext-openssl": "Required to send log messages using SSL",
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
"rollbar/rollbar": "Allow sending log messages to Rollbar",
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Monolog\\": "src/Monolog"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "https://seld.be"
}
],
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
"homepage": "https://github.com/Seldaek/monolog",
"keywords": [
"log",
"logging",
"psr-3"
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/3.9.0"
},
"funding": [
{
"url": "https://github.com/Seldaek",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
"type": "tidelift"
}
],
"time": "2025-03-24T10:02:05+00:00"
},
{ {
"name": "nelmio/api-doc-bundle", "name": "nelmio/api-doc-bundle",
"version": "v5.0.1", "version": "v5.0.1",
@ -3485,6 +3588,166 @@
], ],
"time": "2025-03-28T13:27:10+00:00" "time": "2025-03-28T13:27:10+00:00"
}, },
{
"name": "symfony/monolog-bridge",
"version": "v6.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bridge.git",
"reference": "9d14621e59f22c2b6d030d92d37ffe5ae1e60452"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/9d14621e59f22c2b6d030d92d37ffe5ae1e60452",
"reference": "9d14621e59f22c2b6d030d92d37ffe5ae1e60452",
"shasum": ""
},
"require": {
"monolog/monolog": "^1.25.1|^2|^3",
"php": ">=8.1",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/http-kernel": "^5.4|^6.0|^7.0",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"symfony/console": "<5.4",
"symfony/http-foundation": "<5.4",
"symfony/security-core": "<5.4"
},
"require-dev": {
"symfony/console": "^5.4|^6.0|^7.0",
"symfony/http-client": "^5.4|^6.0|^7.0",
"symfony/mailer": "^5.4|^6.0|^7.0",
"symfony/messenger": "^5.4|^6.0|^7.0",
"symfony/mime": "^5.4|^6.0|^7.0",
"symfony/security-core": "^5.4|^6.0|^7.0",
"symfony/var-dumper": "^5.4|^6.0|^7.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\Monolog\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides integration for Monolog with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/monolog-bridge/tree/v6.4.13"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-10-14T08:49:08+00:00"
},
{
"name": "symfony/monolog-bundle",
"version": "v3.10.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bundle.git",
"reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181",
"reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181",
"shasum": ""
},
"require": {
"monolog/monolog": "^1.25.1 || ^2.0 || ^3.0",
"php": ">=7.2.5",
"symfony/config": "^5.4 || ^6.0 || ^7.0",
"symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0",
"symfony/http-kernel": "^5.4 || ^6.0 || ^7.0",
"symfony/monolog-bridge": "^5.4 || ^6.0 || ^7.0"
},
"require-dev": {
"symfony/console": "^5.4 || ^6.0 || ^7.0",
"symfony/phpunit-bridge": "^6.3 || ^7.0",
"symfony/yaml": "^5.4 || ^6.0 || ^7.0"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Bundle\\MonologBundle\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony MonologBundle",
"homepage": "https://symfony.com",
"keywords": [
"log",
"logging"
],
"support": {
"issues": "https://github.com/symfony/monolog-bundle/issues",
"source": "https://github.com/symfony/monolog-bundle/tree/v3.10.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2023-11-06T17:08:13+00:00"
},
{ {
"name": "symfony/options-resolver", "name": "symfony/options-resolver",
"version": "v6.4.16", "version": "v6.4.16",
@ -6027,6 +6290,88 @@
} }
], ],
"time": "2025-03-10T17:11:00+00:00" "time": "2025-03-10T17:11:00+00:00"
},
{
"name": "symfony/web-profiler-bundle",
"version": "v6.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/web-profiler-bundle.git",
"reference": "7d1026a8e950d416cb5148ae88ac23db5d264839"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/7d1026a8e950d416cb5148ae88ac23db5d264839",
"reference": "7d1026a8e950d416cb5148ae88ac23db5d264839",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/config": "^5.4|^6.0|^7.0",
"symfony/framework-bundle": "^6.4|^7.0",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/routing": "^5.4|^6.0|^7.0",
"symfony/twig-bundle": "^5.4|^6.0",
"twig/twig": "^2.13|^3.0.4"
},
"conflict": {
"symfony/form": "<5.4",
"symfony/mailer": "<5.4",
"symfony/messenger": "<5.4",
"symfony/twig-bundle": ">=7.0"
},
"require-dev": {
"symfony/browser-kit": "^5.4|^6.0|^7.0",
"symfony/console": "^5.4|^6.0|^7.0",
"symfony/css-selector": "^5.4|^6.0|^7.0",
"symfony/stopwatch": "^5.4|^6.0|^7.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Symfony\\Bundle\\WebProfilerBundle\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a development tool that gives detailed information about the execution of any request",
"homepage": "https://symfony.com",
"keywords": [
"dev"
],
"support": {
"source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.19"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-02-14T12:21:59+00:00"
} }
], ],
"aliases": [], "aliases": [],
@ -6035,7 +6380,7 @@
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": ">=8.1", "php": ">=8.4",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*" "ext-iconv": "*"
}, },

View File

@ -8,4 +8,6 @@ return [
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
]; ];

View File

@ -0,0 +1,62 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
# type: firephp
# level: info
#chromephp:
# type: chromephp
# level: info
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: stream
path: php://stderr
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json

View File

@ -0,0 +1,11 @@
when@dev:
web_profiler:
toolbar: true
framework:
profiler:
collect_serializer_data: true
when@test:
framework:
profiler: { collect: false }

View File

@ -0,0 +1,8 @@
when@dev:
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
prefix: /_wdt
web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
prefix: /_profiler

View File

@ -4,6 +4,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed # Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
app.timezone: 'Europe/Berlin'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file

View File

@ -15,6 +15,7 @@ use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
#[Route('/api/events', name: 'api_events_')] #[Route('/api/events', name: 'api_events_')]
#[OA\Tag(name: 'Events')]
class GetEventsController extends AbstractController class GetEventsController extends AbstractController
{ {
public function __construct( public function __construct(
@ -23,7 +24,6 @@ class GetEventsController extends AbstractController
} }
#[Route('', name: 'list', methods: ['GET'])] #[Route('', name: 'list', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Response( #[OA\Response(
response: 200, response: 200,
description: 'Returns list of events', description: 'Returns list of events',
@ -39,7 +39,6 @@ class GetEventsController extends AbstractController
} }
#[Route('/{id}', name: 'get', methods: ['GET'])] #[Route('/{id}', name: 'get', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Parameter( #[OA\Parameter(
name: 'id', name: 'id',
description: 'Event ID', description: 'Event ID',

View File

@ -0,0 +1,35 @@
<?php
namespace App\Application\Controller\Event;
use App\Application\DTO\PersistEventDTO;
use App\Domain\Event\PersistEventHandler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use OpenApi\Attributes as OA;
#[Route('/api/events', name: 'api_events_persist', methods: ['POST'])]
#[OA\Tag(name: 'Events')]
class PersistEventController extends AbstractController
{
public function __construct(
private readonly PersistEventHandler $persistEventHandler,
) {
}
#[Route('', name: 'persist', methods: ['POST'])]
public function persist(#[MapRequestPayload] PersistEventDTO $dto): JsonResponse
{
$this->persistEventHandler->handle($dto->toDomain());
return $this->json(['message' => 'Event persisted']);
}
#[Route('/{id}', name: 'update', methods: ['PUT'])]
public function update(#[MapRequestPayload] PersistEventDTO $dto, string $id): JsonResponse
{
$this->persistEventHandler->handle($dto->toDomain()->withId($id));
return $this->json(['message' => 'Event updated']);
}
}

View File

@ -5,36 +5,48 @@ declare(strict_types=1);
namespace App\Application\DTO; namespace App\Application\DTO;
use App\Domain\Model\EventDraft; use App\Domain\Model\EventDraft;
use DateTimeInterface;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
#[OA\Schema] #[OA\Schema]
final readonly class EventDraftDTO final readonly class EventDraftDTO
{ {
public function __construct( public function __construct(
#[OA\Property(type: 'string')] #[OA\Property(type: 'string', nullable: true)]
public string $id, public ?string $title = null,
#[OA\Property(type: 'string')] #[OA\Property(type: 'string', nullable: true)]
public string $title, public ?string $description = null,
#[OA\Property(type: 'string')] #[OA\Property(type: 'string', nullable: true)]
public string $description, public ?string $location = null,
#[OA\Property(type: 'string')] #[OA\Property(type: 'datetime', nullable: true)]
public ?string $start, public ?DateTimeInterface $start = null,
#[OA\Property(type: 'string')] #[OA\Property(type: 'datetime', nullable: true)]
public ?string $end, public ?DateTimeInterface $end = null,
#[OA\Property(type: 'boolean')] #[OA\Property(type: 'boolean', nullable: true)]
public bool $allDay public ?bool $allDay = null,
) { ) {
} }
public function toDomain(): EventDraft
{
return new EventDraft(
$this->title,
$this->description,
$this->location,
$this->start,
$this->end,
$this->allDay,
);
}
public static function fromDraft(EventDraft $draft): self public static function fromDraft(EventDraft $draft): self
{ {
return new self( return new self(
$draft->title(), $draft->title,
$draft->description(), $draft->description,
$draft->location(), $draft->location,
$draft->start()?->format('Y-m-d H:i:s'), $draft->start,
$draft->end()?->format('Y-m-d H:i:s'), $draft->end,
$draft->allDay() $draft->allDay,
); );
} }
} }

View File

@ -0,0 +1,24 @@
<?php
namespace App\Application\DTO;
use App\Domain\Event\PersistEvent;
use OpenApi\Attributes as OA;
use App\Application\DTO\EventDraftDTO;
#[OA\Schema]
final readonly class PersistEventDTO
{
public function __construct(
#[OA\Property]
public readonly EventDraftDTO $draft,
) {
}
public function toDomain(): PersistEvent
{
return new PersistEvent(
$this->draft->toDomain(),
);
}
}

View File

@ -6,12 +6,14 @@ use App\Domain\Chat\ChatProviderInterface;
use App\Domain\Chat\ChatSession; use App\Domain\Chat\ChatSession;
use App\Domain\Model\EventDraft; use App\Domain\Model\EventDraft;
use App\Domain\User\UserContextProvider; use App\Domain\User\UserContextProvider;
use Psr\Log\LoggerInterface;
class GenerateDraftHandler class GenerateDraftHandler
{ {
public function __construct( public function __construct(
private readonly ChatProviderInterface $chatProvider, private readonly ChatProviderInterface $chatProvider,
private readonly UserContextProvider $userContextProvider, private readonly UserContextProvider $userContextProvider,
private readonly LoggerInterface $logger,
) { ) {
} }
@ -19,14 +21,20 @@ class GenerateDraftHandler
{ {
$userContext = $this->userContextProvider->getUserContext(); $userContext = $this->userContextProvider->getUserContext();
$chat = new ChatSession($this->chatProvider); $chat = new ChatSession($this->chatProvider);
$chat->system(<<<PROMPT
You are a helpful assistant that generates event drafts based on user input. $systemPrompt = <<<PROMPT
You are a helpful assistant that generates calendar event drafts based on user input.
The user input can be anything from a very detailed description to a simple sentence or just a snippet from another source (Chat, email, etc.). The user input can be anything from a very detailed description to a simple sentence or just a snippet from another source (Chat, email, etc.).
You should always generate a draft even if the user input is not very detailed. You should always generate a draft even if the user input is not very detailed.
Go Step by Step by step: Go Step by Step by step:
- First, analyze the user input and analyze the context of the user input. - First, analyze the user input and asking yourself what the user wants to achieve:
a. What is the title of the event?
b. What is the description of the event?
c. What is the location of the event?
d. What is the start datetime of the event?
e. What is the end datetime of the event?
f. Is the event all day?
- Then, generate a draft of the event. - Then, generate a draft of the event.
- Finally, return the draft in the following format:
The event draft should be in the following format: The event draft should be in the following format:
```json ```json
@ -42,9 +50,14 @@ class GenerateDraftHandler
This is the current context: This is the current context:
- Time: {$userContext->now()->format('Y-m-d H:i:s')} - Time: {$userContext->now()->format('Y-m-d H:i:s')}
- Timezone: {$userContext->now()->getTimezone()->getName()}
You will only respond with the JSON object in ```json``` tags, nothing else. You will only respond with the JSON object in ```json``` tags, nothing else.
PROMPT); PROMPT;
$chat->system($systemPrompt);
$this->logger->info('Generating draft for input: ' . $generateDraft->input() . " System prompt: " . $systemPrompt);
$chat->user($generateDraft->input()); $chat->user($generateDraft->input());
$chat->commit(reasoning: false); $chat->commit(reasoning: false);

View File

@ -0,0 +1,19 @@
<?php
namespace App\Domain\Event;
use App\Domain\Model\EventDraft;
class PersistEvent
{
public function __construct(
public readonly EventDraft $draft,
public readonly ?string $id = null,
) {
}
public function withId(string $id): self
{
return new self($this->draft, $id);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Domain\Event;
use App\Domain\Event\PersistEvent;
use App\Domain\Model\Persisted\PersistedEvent;
use App\Infrastructure\Repository\EventRepository;
class PersistEventHandler
{
public function __construct(
private readonly EventRepository $eventRepository,
) {
}
public function handle(PersistEvent $event): void
{
$persistedEvent = new PersistedEvent();
if ($event->id !== null) {
$persistedEvent = $this->eventRepository->find($event->id);
if (!$persistedEvent) {
throw new \Exception('Event not found');
}
}
$this->eventRepository->save($event->draft->mergeIntoPersisted($persistedEvent));
}
}

View File

@ -2,47 +2,49 @@
namespace App\Domain\Model; namespace App\Domain\Model;
use App\Domain\Model\Persisted\PersistedEvent;
use DateTimeInterface; use DateTimeInterface;
class EventDraft class EventDraft
{ {
public function __construct( public function __construct(
private readonly string $title, public readonly ?string $title,
private readonly string $description, public readonly ?string $description,
private readonly string $location, public readonly ?string $location,
private readonly ?DateTimeInterface $start, public readonly ?DateTimeInterface $start,
private readonly ?DateTimeInterface $end, public readonly ?DateTimeInterface $end,
private readonly bool $allDay, public readonly ?bool $allDay,
) { ) {
} }
public function title(): string public function mergeIntoPersisted(PersistedEvent $persistedEvent = new PersistedEvent()): PersistedEvent
{ {
return $this->title; if (!$this->start instanceof \DateTimeImmutable) {
throw new \Exception('Start date must be a DateTimeImmutable');
} }
public function description(): string if (!$this->end instanceof \DateTimeImmutable) {
{ throw new \Exception('End date must be a DateTimeImmutable');
return $this->description;
} }
public function location(): string if ($this->allDay === null) {
{ throw new \Exception('All day must be a boolean');
return $this->location;
} }
public function start(): ?DateTimeInterface if ($this->title === null) {
{ throw new \Exception('Title must be a string');
return $this->start;
} }
public function end(): ?DateTimeInterface if ($this->description === null) {
{ throw new \Exception('Description must be a string');
return $this->end;
} }
public function allDay(): bool return $persistedEvent
{ ->setTitle($this->title)
return $this->allDay; ->setDescription($this->description)
->setLocation($this->location)
->setStart($this->start)
->setEnd($this->end)
->setAllDay($this->allDay);
} }
} }

View File

@ -23,6 +23,9 @@ class PersistedEvent
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null; private ?string $description = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $location = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'event_from')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'event_from')]
#[Assert\NotNull] #[Assert\NotNull]
private \DateTimeImmutable $from; private \DateTimeImmutable $from;
@ -128,4 +131,16 @@ class PersistedEvent
return $this; return $this;
} }
public function getLocation(): ?string
{
return $this->location;
}
public function setLocation(?string $location): self
{
$this->location = $location;
return $this;
}
} }

View File

@ -0,0 +1,33 @@
<?php
namespace App\Infrastructure\EventSubscriber;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class TimezoneSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly ParameterBagInterface $params,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['setTimezone', 100],
];
}
public function setTimezone(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$timezone = $this->params->get('app.timezone');
date_default_timezone_set($timezone);
}
}

View File

@ -16,6 +16,12 @@ class EventRepository extends ServiceEntityRepository
parent::__construct($registry, PersistedEvent::class); parent::__construct($registry, PersistedEvent::class);
} }
public function save(PersistedEvent $event): void
{
$this->getEntityManager()->persist($event);
$this->getEntityManager()->flush();
}
/** /**
* @return array<PersistedEvent> * @return array<PersistedEvent>
*/ */

View File

@ -121,6 +121,18 @@
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
} }
}, },
"symfony/monolog-bundle": {
"version": "3.10",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "aff23899c4440dd995907613c1dd709b6f59503f"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/routing": { "symfony/routing": {
"version": "6.4", "version": "6.4",
"recipe": { "recipe": {
@ -170,5 +182,18 @@
"files": [ "files": [
"config/packages/validator.yaml" "config/packages/validator.yaml"
] ]
},
"symfony/web-profiler-bundle": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.1",
"ref": "8b51135b84f4266e3b4c8a6dc23c9d1e32e543b7"
},
"files": [
"config/packages/web_profiler.yaml",
"config/routes/web_profiler.yaml"
]
} }
} }

View File

@ -1,6 +1,7 @@
services: services:
app: app:
image: app:latest image: app:latest
hostname: calendi.test
build: build:
context: . context: .
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
@ -17,7 +18,7 @@ services:
- proxy - proxy
postgres: postgres:
hostname: calendi-postgres hostname: calendi-postgres.test
image: postgres:15 image: postgres:15
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
@ -29,6 +30,11 @@ services:
- ./var/postgres_data:/var/lib/postgresql/data - ./var/postgres_data:/var/lib/postgresql/data
networks: networks:
- proxy - proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.postgres.entrypoints=postgres"
- "traefik.http.routers.postgres.rule=Host(`calendi-postgres.test`)"
- "traefik.http.services.postgres.loadbalancer.server.port=5432"
networks: networks:
proxy: proxy:

View File

@ -10,7 +10,13 @@ RUN apt-get update && apt-get install -y \
apt-transport-https \ apt-transport-https \
software-properties-common \ software-properties-common \
nginx \ nginx \
unzip unzip \
tzdata
# Set timezone to Germany
RUN ln -fs /usr/share/zoneinfo/Europe/Berlin /etc/localtime && \
dpkg-reconfigure -f noninteractive tzdata && \
echo "Europe/Berlin" > /etc/timezone
# Add PHP repository # Add PHP repository
RUN wget -O /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg \ RUN wget -O /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg \

View File

@ -9,6 +9,11 @@ server {
try_files $uri $uri/ /index.php$is_args$args; try_files $uri $uri/ /index.php$is_args$args;
} }
# PHP Backend API
location /_profiler {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ { location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000; fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php; fastcgi_index index.php;

View File

@ -26,19 +26,16 @@ export type Event = {
allDay: boolean; allDay: boolean;
}; };
export type CreateEventRequest = { export type PersistEventRequest = {
title: string; draft: EventDraft;
description?: string; id?: string;
start: string;
end: string;
allDay?: boolean;
}; };
// Event endpoints // Event endpoints
export const getEvents = () => get<Event[]>('/api/events'); export const getEvents = () => get<Event[]>('/api/events');
export const getEvent = (id: string) => get<Event>(`/api/events/${id}`); export const getEvent = (id: string) => get<Event>(`/api/events/${id}`);
export const createEvent = (data: CreateEventRequest) => post<Event>('/api/events', data); export const persistEvent = (data: PersistEventRequest) => post<Event>('/api/events', data);
export const updateEvent = (id: string, data: Partial<CreateEventRequest>) => put<Event>(`/api/events/${id}`, data); export const updateEvent = (id: string, data: Partial<PersistEventRequest>) => put<Event>(`/api/events/${id}`, data);
export const deleteEvent = (id: string) => del<void>(`/api/events/${id}`); export const deleteEvent = (id: string) => del<void>(`/api/events/${id}`);
// Event draft types // Event draft types

View File

@ -44,24 +44,3 @@ export function useApi<T, P extends unknown[]>(
return [execute, state]; return [execute, state];
} }
// Example usage:
//
// function UserList() {
// const [fetchUsers, { data: users, loading, error }] = useApi(getUsers, []);
//
// useEffect(() => {
// fetchUsers();
// }, [fetchUsers]);
//
// if (loading) return <div>Loading...</div>;
// if (error) return <div>Error: {error}</div>;
//
// return (
// <ul>
// {users?.map(user => (
// <li key={user.id}>{user.name}</li>
// ))}
// </ul>
// );
// }

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { EventDraft, createEvent } from '../../lib/api/endpoints'; import { EventDraft, persistEvent } from '../../lib/api/endpoints';
import LoadingSpinner from '../../components/ui/LoadingSpinner'; import LoadingSpinner from '../../components/ui/LoadingSpinner';
import './EditEvent.css'; import './EditEvent.css';
@ -85,14 +85,17 @@ const EditEvent: React.FC = () => {
setLoading(true); setLoading(true);
const eventData = { const eventData = {
draft: {
id: id || '',
title, title,
description, description,
start: startDate ? formatForServer(startDate, startTime || '00:00') : new Date().toISOString(), start: startDate ? formatForServer(startDate, startTime || '00:00') : new Date().toISOString(),
end: endDate ? formatForServer(endDate, endTime || '00:00') : new Date().toISOString(), end: endDate ? formatForServer(endDate, endTime || '00:00') : new Date().toISOString(),
allDay allDay
}
}; };
const savedEvent = await createEvent(eventData); const savedEvent = await persistEvent(eventData);
console.log('Event created:', savedEvent); console.log('Event created:', savedEvent);
// Clear the draft from sessionStorage // Clear the draft from sessionStorage