Create a connector to synchronize with zimbra calendars

This commit is contained in:
2025-12-05 11:59:32 +00:00
parent 0ba5cd849c
commit 92d5fe154e
34 changed files with 1116 additions and 22 deletions

View File

@@ -47,6 +47,8 @@ class ChillCalendarExtension extends Extension implements PrependExtensionInterf
} else {
$container->setParameter('chill_calendar.short_messages', null);
}
$container->setParameter('chill_calendar.remote_calendar_dsn', $config['remote_calendar_dsn']);
}
public function prepend(ContainerBuilder $container)

View File

@@ -32,9 +32,10 @@ class Configuration implements ConfigurationInterface
->canBeDisabled()
->children()->end()
->end() // end for short_messages
->scalarNode('remote_calendar_dsn')->defaultValue('null://null')->cannotBeEmpty()->end()
->arrayNode('remote_calendars_sync')->canBeEnabled()
->children()
->arrayNode('microsoft_graph')->canBeEnabled()
->arrayNode('microsoft_graph')->canBeEnabled()->setDeprecated('chill-project/chill-bundles', '4.7.0', 'The child node %node% at path %path% is deprecated: use remote_calendar_dsn instead, with a "msgraph://default" value')
->children()
->end() // end of machine_access_token
->end() // end of microsoft_graph children

View File

@@ -107,6 +107,11 @@ class CalendarRange implements TrackCreationInterface, TrackUpdateInterface
return $this;
}
public function hasLocation(): bool
{
return null !== $this->location;
}
public function setLocation(?Location $location): self
{
$this->location = $location;

View File

@@ -22,6 +22,7 @@ use Chill\CalendarBundle\Messenger\Message\CalendarRemovedMessage;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\CalendarBundle\Repository\CalendarRangeRepository;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
@@ -31,7 +32,12 @@ use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
*/
class CalendarRemoveHandler implements MessageHandlerInterface
{
public function __construct(private readonly RemoteCalendarConnectorInterface $remoteCalendarConnector, private readonly CalendarRangeRepository $calendarRangeRepository, private readonly UserRepositoryInterface $userRepository) {}
public function __construct(
private readonly RemoteCalendarConnectorInterface $remoteCalendarConnector,
private readonly CalendarRangeRepository $calendarRangeRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly EntityManagerInterface $entityManager,
) {}
public function __invoke(CalendarRemovedMessage $message)
{
@@ -47,5 +53,7 @@ class CalendarRemoveHandler implements MessageHandlerInterface
$this->userRepository->find($message->getCalendarUserId()),
$associatedRange
);
$this->entityManager->flush();
}
}

View File

@@ -21,42 +21,137 @@ namespace Chill\CalendarBundle\RemoteCalendar\Connector;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\Messenger\Message\CalendarMessage;
use Chill\CalendarBundle\RemoteCalendar\Model\RemoteEvent;
use Chill\MainBundle\Entity\User;
use Symfony\Component\HttpFoundation\Response;
/**
* Contract for connectors that synchronize Chill calendars with a remote
* calendar provider (for example Microsoft 365/Graph, Zimbra, ...).
*
* Implementations act as an adapter between Chill domain objects
* (Calendar, CalendarRange, Invite) and the remote provider API. They must:
* - expose a readiness flow for per-user authorization when applicable
* (see {@see getMakeReadyResponse()} and {@see isReady()});
* - list and count remote events in a time range for a given user;
* - mirror local lifecycle changes to the remote provider for calendars,
* calendar ranges (availability/busy blocks) and invites/attendees.
*
* Use {@see MSGraphRemoteCalendarConnector} as a reference implementation for
* expected behaviours, error handling and parameter semantics.
*/
interface RemoteCalendarConnectorInterface
{
public function countEventsForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate): int;
/**
* Return a response, more probably a RedirectResponse, where the user
* will be able to fullfill requirements to prepare this connector and
* make it ready.
* Returns a Response (typically a RedirectResponse) that lets the current
* user perform the steps required to make the connector usable (for
* example, OAuth consent or account linking). After completion, the user
* should be redirected back to the given path.
*/
public function getMakeReadyResponse(string $returnPath): Response;
/**
* Return true if the connector is ready to act as a proxy for reading
* remote calendars.
* Returns true when the connector is ready to access the remote provider
* on behalf of the current user (e.g. required tokens/consent exist).
*/
public function isReady(): bool;
/**
* Lists events from the remote provider for the given user and time range.
*
* Implementations should map provider-specific payloads to instances of
* {@see RemoteEvent}.
*
* @return array|RemoteEvent[]
*/
public function listEventsForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate, ?int $offset = 0, ?int $limit = 50): array;
/**
* Removes a calendar (single event) from the remote provider.
*
* **Note**: calendar (single event) which are canceled will appears in this
* method, and not in syncCalendar method.
*
* Parameters:
* - remoteId: the provider identifier of the remote event to delete. If
* empty, implementations should no-op.
* - remoteAttributes: provider-specific metadata previously stored with the
* local entity (e.g. change keys, etags) that can help perform safe
* concurrency checks when deleting. Implementations may ignore unknown
* keys.
* - user: the user in whose remote calendar the event lives and on whose
* behalf the deletion must be performed.
* - associatedCalendarRange: when provided, the implementation should
* update/synchronize the corresponding remote busy-time block after the
* event removal so that availability stays consistent.
*/
public function removeCalendar(string $remoteId, array $remoteAttributes, User $user, ?CalendarRange $associatedCalendarRange = null): void;
/**
* Removes a remote busy-time block (calendar range) identified by
* provider-specific id and attributes for the given user.
*
* Implementations should no-op if the id is empty.
*/
public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void;
/**
* Synchronizes a Calendar entity to the remote provider.
*
* Typical cases to support (see MSGraph implementation):
* - Creating the event on the remote calendar when it has no remote id.
* - Updating the existing remote event when details or attendees change.
* - Handling main user changes: cancel on the previous user's calendar,
* (re)create associated ranges where needed, then create on the new
* main user's calendar.
* - If the Calendar uses a CalendarRange that already exists remotely,
* implementations should remove/update that remote range when the event
* becomes the source of truth for busy times.
*
* The implementation should not expects to receive calendar which are canceled
* here.
*
* Parameters:
* - calendar: the domain Calendar to mirror remotely.
* - action: a hint about what triggered the sync; implementations should not rely
* solely on this value and must base decisions on the Calendar state.
* - previousCalendarRange: if the Calendar was previously attached to a
* different range, this contains the former range so it can be recreated
* remotely to preserve availability history when applicable.
* - previousMainUser: the former main user, when the main user changed;
* used to cancel the event in the previous user's calendar.
* - oldInvites: the attendee snapshot before the change. Each item is an
* array with keys: inviteId, userId, userEmail, userLabel.
* - newInvites: the attendee snapshot after the change, same shape as
* oldInvites. Implementations can compute diffs to add/remove attendees.
*
* The $action argument is a string tag indicating what happened to the
* calendar. It MUST be one of the constants defined on
* {@see CalendarMessage}:
* - {@see CalendarMessage::CALENDAR_PERSIST}
* - {@see CalendarMessage::CALENDAR_UPDATE}
*
* @param array<array{inviteId: int, userId: int, userEmail: int, userLabel: string}> $oldInvites
*
* @phpstan-param (CalendarMessage::CALENDAR_PERSIST|CalendarMessage::CALENDAR_UPDATE) $action
*/
public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void;
/**
* Creates or updates a remote busy-time block representing the provided
* CalendarRange. If the range has a remote id, it should be updated;
* otherwise it should be created remotely, and the range enriched with
* the new id/attributes by the caller.
*/
public function syncCalendarRange(CalendarRange $calendarRange): void;
/**
* Synchronizes a single Invite (attendee) change to the remote provider.
* Implementations may need to lookup the attendee's personal calendar to
* find provider-specific identifiers before patching the main event.
*/
public function syncInvite(Invite $invite): void;
}

View File

@@ -35,25 +35,46 @@ use TheNetworg\OAuth2\Client\Provider\Azure;
class RemoteCalendarCompilerPass implements CompilerPassInterface
{
private const ZIMBRA_CONNECTOR = 'Chill\ZimbraBundle\Calendar\Connector\ZimbraConnector';
private const MS_GRAPH_SERVICES_TO_REMOVE = [
MapAndSubscribeUserCalendarCommand::class,
AzureGrantAdminConsentAndAcquireToken::class,
RemoteCalendarConnectAzureController::class,
MachineTokenStorage::class,
MachineHttpClient::class,
MSGraphRemoteCalendarConnector::class,
MSUserAbsenceReaderInterface::class,
MSUserAbsenceSync::class,
];
public function process(ContainerBuilder $container)
{
$config = $container->getParameter('chill_calendar');
$config = $container->getParameter('chill_calendar.remote_calendar_dsn');
if (true === $container->getParameter('chill_calendar')['remote_calendars_sync']['microsoft_graph']['enabled']) {
$dsn = 'msgraph://default';
} else {
$dsn = $config;
}
if (true === $config['remote_calendars_sync']['microsoft_graph']['enabled']) {
$scheme = parse_url($dsn, PHP_URL_SCHEME);
if ('msgraph' === $scheme) {
$connector = MSGraphRemoteCalendarConnector::class;
$container->setAlias(HttpClientInterface::class.' $machineHttpClient', MachineHttpClient::class);
} else {
} elseif ('zimbra+http' === $scheme || 'zimbra+https' === $scheme) {
$connector = self::ZIMBRA_CONNECTOR;
foreach (self::MS_GRAPH_SERVICES_TO_REMOVE as $serviceId) {
$container->removeDefinition($serviceId);
}
} elseif ('null' === $scheme) {
$connector = NullRemoteCalendarConnector::class;
// remove services which cannot be loaded
$container->removeDefinition(MapAndSubscribeUserCalendarCommand::class);
$container->removeDefinition(AzureGrantAdminConsentAndAcquireToken::class);
$container->removeDefinition(RemoteCalendarConnectAzureController::class);
$container->removeDefinition(MachineTokenStorage::class);
$container->removeDefinition(MachineHttpClient::class);
$container->removeDefinition(MSGraphRemoteCalendarConnector::class);
$container->removeDefinition(MSUserAbsenceReaderInterface::class);
$container->removeDefinition(MSUserAbsenceSync::class);
foreach (self::MS_GRAPH_SERVICES_TO_REMOVE as $serviceId) {
$container->removeDefinition($serviceId);
}
} else {
throw new \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException('Unsupported remote calendar scheme: '.$scheme);
}
if (!$container->hasAlias(Azure::class) && $container->hasDefinition('knpu.oauth2.client.azure')) {
@@ -62,7 +83,9 @@ class RemoteCalendarCompilerPass implements CompilerPassInterface
foreach ([
NullRemoteCalendarConnector::class,
MSGraphRemoteCalendarConnector::class, ] as $serviceId) {
MSGraphRemoteCalendarConnector::class,
self::ZIMBRA_CONNECTOR,
] as $serviceId) {
if ($connector === $serviceId) {
$container->getDefinition($serviceId)
->setDecoratedService(RemoteCalendarConnectorInterface::class);

View File

@@ -87,6 +87,8 @@ remote_ms_graph:
remote_calendar:
calendar_range_title: Plage de disponibilité Chill
# small type-hint in remote calendar to says that the appointment is created through an invitation, and not as main referrer
calendar_invite_statement_in_calendar: Par invitation
invite:
accepted: Accepté

View File

@@ -26,6 +26,7 @@ class AddressRender implements ChillEntityRenderInterface
'with_delimiter' => false,
'has_no_address' => false,
'multiline' => true,
'separator' => ' — ',
/* deprecated */
'extended_infos' => false,
];
@@ -114,7 +115,9 @@ class AddressRender implements ChillEntityRenderInterface
public function renderString($addr, array $options): string
{
return implode(' — ', $this->renderLines($addr));
$opts = [...self::DEFAULT_OPTIONS, ...$options];
return implode($opts['separator'], $this->renderLines($addr));
}
public function supports($entity, array $options): bool

View File

@@ -52,7 +52,7 @@ class CommentRender implements ChillEntityRenderInterface
public function renderString($entity, array $options): string
{
return $entity->getComment();
return (string) $entity->getComment();
}
public function supports($entity, array $options): bool