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.checkValidity": true,
"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="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="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 ###

View File

@ -4,7 +4,7 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.1",
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/annotations": "^2.0",
@ -20,6 +20,7 @@
"symfony/flex": "^2",
"symfony/framework-bundle": "6.4.*",
"symfony/http-client": "6.4.*",
"symfony/monolog-bundle": "^3.10",
"symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*",
"symfony/runtime": "6.4.*",
@ -83,6 +84,8 @@
"doctrine/doctrine-fixtures-bundle": "^4.1",
"phpstan/phpstan": "^2.1",
"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",
"This file is @generated automatically"
],
"content-hash": "f41287711c3c1d476ebbca47f5b529b5",
"content-hash": "7ec99e86c547c32beef698aea0e9e346",
"packages": [
{
"name": "doctrine/annotations",
@ -1202,6 +1202,109 @@
},
"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",
"version": "v5.0.1",
@ -3485,6 +3588,166 @@
],
"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",
"version": "v6.4.16",
@ -6027,6 +6290,88 @@
}
],
"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": [],
@ -6035,7 +6380,7 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": ">=8.1",
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*"
},

View File

@ -8,4 +8,6 @@ return [
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => 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
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
app.timezone: 'Europe/Berlin'
services:
# default configuration for services in *this* file

View File

@ -15,6 +15,7 @@ use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
#[Route('/api/events', name: 'api_events_')]
#[OA\Tag(name: 'Events')]
class GetEventsController extends AbstractController
{
public function __construct(
@ -23,7 +24,6 @@ class GetEventsController extends AbstractController
}
#[Route('', name: 'list', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Response(
response: 200,
description: 'Returns list of events',
@ -39,7 +39,6 @@ class GetEventsController extends AbstractController
}
#[Route('/{id}', name: 'get', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Parameter(
name: '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;
use App\Domain\Model\EventDraft;
use DateTimeInterface;
use OpenApi\Attributes as OA;
#[OA\Schema]
final readonly class EventDraftDTO
{
public function __construct(
#[OA\Property(type: 'string')]
public string $id,
#[OA\Property(type: 'string')]
public string $title,
#[OA\Property(type: 'string')]
public string $description,
#[OA\Property(type: 'string')]
public ?string $start,
#[OA\Property(type: 'string')]
public ?string $end,
#[OA\Property(type: 'boolean')]
public bool $allDay
#[OA\Property(type: 'string', nullable: true)]
public ?string $title = null,
#[OA\Property(type: 'string', nullable: true)]
public ?string $description = null,
#[OA\Property(type: 'string', nullable: true)]
public ?string $location = null,
#[OA\Property(type: 'datetime', nullable: true)]
public ?DateTimeInterface $start = null,
#[OA\Property(type: 'datetime', nullable: true)]
public ?DateTimeInterface $end = null,
#[OA\Property(type: 'boolean', nullable: true)]
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
{
return new self(
$draft->title(),
$draft->description(),
$draft->location(),
$draft->start()?->format('Y-m-d H:i:s'),
$draft->end()?->format('Y-m-d H:i:s'),
$draft->allDay()
$draft->title,
$draft->description,
$draft->location,
$draft->start,
$draft->end,
$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\Model\EventDraft;
use App\Domain\User\UserContextProvider;
use Psr\Log\LoggerInterface;
class GenerateDraftHandler
{
public function __construct(
private readonly ChatProviderInterface $chatProvider,
private readonly UserContextProvider $userContextProvider,
private readonly LoggerInterface $logger,
) {
}
@ -19,14 +21,20 @@ class GenerateDraftHandler
{
$userContext = $this->userContextProvider->getUserContext();
$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.).
You should always generate a draft even if the user input is not very detailed.
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.
- Finally, return the draft in the following format:
The event draft should be in the following format:
```json
@ -42,9 +50,14 @@ class GenerateDraftHandler
This is the current context:
- 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.
PROMPT);
PROMPT;
$chat->system($systemPrompt);
$this->logger->info('Generating draft for input: ' . $generateDraft->input() . " System prompt: " . $systemPrompt);
$chat->user($generateDraft->input());
$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;
use App\Domain\Model\Persisted\PersistedEvent;
use DateTimeInterface;
class EventDraft
{
public function __construct(
private readonly string $title,
private readonly string $description,
private readonly string $location,
private readonly ?DateTimeInterface $start,
private readonly ?DateTimeInterface $end,
private readonly bool $allDay,
public readonly ?string $title,
public readonly ?string $description,
public readonly ?string $location,
public readonly ?DateTimeInterface $start,
public readonly ?DateTimeInterface $end,
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
{
return $this->description;
}
if (!$this->end instanceof \DateTimeImmutable) {
throw new \Exception('End date must be a DateTimeImmutable');
}
public function location(): string
{
return $this->location;
}
if ($this->allDay === null) {
throw new \Exception('All day must be a boolean');
}
public function start(): ?DateTimeInterface
{
return $this->start;
}
if ($this->title === null) {
throw new \Exception('Title must be a string');
}
public function end(): ?DateTimeInterface
{
return $this->end;
}
if ($this->description === null) {
throw new \Exception('Description must be a string');
}
public function allDay(): bool
{
return $this->allDay;
return $persistedEvent
->setTitle($this->title)
->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)]
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')]
#[Assert\NotNull]
private \DateTimeImmutable $from;
@ -128,4 +131,16 @@ class PersistedEvent
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);
}
public function save(PersistedEvent $event): void
{
$this->getEntityManager()->persist($event);
$this->getEntityManager()->flush();
}
/**
* @return array<PersistedEvent>
*/

View File

@ -121,6 +121,18 @@
"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": {
"version": "6.4",
"recipe": {
@ -170,5 +182,18 @@
"files": [
"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:
app:
image: app:latest
hostname: calendi.test
build:
context: .
dockerfile: docker/Dockerfile
@ -17,7 +18,7 @@ services:
- proxy
postgres:
hostname: calendi-postgres
hostname: calendi-postgres.test
image: postgres:15
environment:
POSTGRES_USER: postgres
@ -29,6 +30,11 @@ services:
- ./var/postgres_data:/var/lib/postgresql/data
networks:
- 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:
proxy:

View File

@ -10,7 +10,13 @@ RUN apt-get update && apt-get install -y \
apt-transport-https \
software-properties-common \
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
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;
}
# PHP Backend API
location /_profiler {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;

View File

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

View File

@ -43,25 +43,4 @@ export function useApi<T, P extends unknown[]>(
);
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 { 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 './EditEvent.css';
@ -85,14 +85,17 @@ const EditEvent: React.FC = () => {
setLoading(true);
const eventData = {
title,
description,
start: startDate ? formatForServer(startDate, startTime || '00:00') : new Date().toISOString(),
end: endDate ? formatForServer(endDate, endTime || '00:00') : new Date().toISOString(),
allDay
draft: {
id: id || '',
title,
description,
start: startDate ? formatForServer(startDate, startTime || '00:00') : new Date().toISOString(),
end: endDate ? formatForServer(endDate, endTime || '00:00') : new Date().toISOString(),
allDay
}
};
const savedEvent = await createEvent(eventData);
const savedEvent = await persistEvent(eventData);
console.log('Event created:', savedEvent);
// Clear the draft from sessionStorage