Skip to content

Commit

Permalink
feat: create example event when a user logs in for the first time
Browse files Browse the repository at this point in the history
Signed-off-by: Richard Steinmetz <[email protected]>
  • Loading branch information
st3iny committed Jan 28, 2025
1 parent 9f1ca85 commit 73f2d0c
Show file tree
Hide file tree
Showing 16 changed files with 884 additions and 0 deletions.
3 changes: 3 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,7 @@
<order>5</order>
</navigation>
</navigations>
<settings>
<admin>OCA\Calendar\Settings\ExampleEventSettings</admin>
</settings>
</info>
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use OCA\Calendar\Events\BeforeAppointmentBookedEvent;
use OCA\Calendar\Listener\AppointmentBookedListener;
use OCA\Calendar\Listener\CalendarReferenceListener;
use OCA\Calendar\Listener\UserFirstLoginListener;
use OCA\Calendar\Listener\UserDeletedListener;
use OCA\Calendar\Notification\Notifier;
use OCA\Calendar\Profile\AppointmentsAction;
Expand All @@ -22,6 +23,7 @@
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\ServerVersion;
use OCP\User\Events\UserDeletedEvent;
use OCP\User\Events\UserFirstTimeLoggedInEvent;
use OCP\Util;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
Expand Down Expand Up @@ -53,6 +55,7 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(BeforeAppointmentBookedEvent::class, AppointmentBookedListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(RenderReferenceEvent::class, CalendarReferenceListener::class);
$context->registerEventListener(UserFirstTimeLoggedInEvent::class, UserFirstLoginListener::class);

$context->registerNotifierService(Notifier::class);
}
Expand Down
49 changes: 49 additions & 0 deletions lib/Controller/ExampleEventController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Calendar\Controller;

use OCA\Calendar\AppInfo\Application;
use OCA\Calendar\Http\JsonResponse;
use OCA\Calendar\Service\ExampleEventService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\IRequest;

class ExampleEventController extends Controller {
public function __construct(
IRequest $request,
private readonly ExampleEventService $exampleEventService,
) {
parent::__construct(Application::APP_ID, $request);
}

#[FrontpageRoute(verb: 'POST', url: '/v1/exampleEvent/enable')]
public function setCreateExampleEvent(bool $enable): JSONResponse {
$this->exampleEventService->setCreateExampleEvent($enable);
return JsonResponse::success([]);
}

#[FrontpageRoute(verb: 'POST', url: '/v1/exampleEvent/event')]
public function uploadExampleEvent(string $ics): JSONResponse {
if (!$this->exampleEventService->shouldCreateExampleEvent()) {
return JSONResponse::fail([], Http::STATUS_FORBIDDEN);
}

$this->exampleEventService->saveCustomExampleEvent($ics);
return JsonResponse::success([]);
}

#[FrontpageRoute(verb: 'DELETE', url: '/v1/exampleEvent/event')]
public function deleteExampleEvent(): JSONResponse {
$this->exampleEventService->deleteCustomExampleEvent();
return JsonResponse::success([]);
}
}
73 changes: 73 additions & 0 deletions lib/Listener/UserFirstLoginListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Calendar\Listener;

use OCA\Calendar\Exception\ServiceException;
use OCA\Calendar\Service\ExampleEventService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\ServerVersion;
use OCP\User\Events\UserFirstTimeLoggedInEvent;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;

/** @template-implements IEventListener<UserFirstTimeLoggedInEvent> */
class UserFirstLoginListener implements IEventListener {
private bool $is31OrAbove;

public function __construct(
private readonly ExampleEventService $exampleEventService,
private readonly LoggerInterface $logger,
ContainerInterface $container,
) {
$this->is31OrAbove = self::isNextcloud31OrAbove($container);
}

private static function isNextcloud31OrAbove(ContainerInterface $container): bool {
// ServerVersion was added in 31, but we don't care about older versions anyway
try {
/** @var ServerVersion $serverVersion */
$serverVersion = $container->get(ServerVersion::class);

Check failure on line 38 in lib/Listener/UserFirstLoginListener.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

UndefinedDocblockClass

lib/Listener/UserFirstLoginListener.php:38:4: UndefinedDocblockClass: Docblock-defined class, interface or enum named OCP\ServerVersion does not exist (see https://psalm.dev/200)

Check failure on line 38 in lib/Listener/UserFirstLoginListener.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

UndefinedClass

lib/Listener/UserFirstLoginListener.php:38:37: UndefinedClass: Class, interface or enum named OCP\ServerVersion does not exist (see https://psalm.dev/019)
} catch (ContainerExceptionInterface $e) {
return false;
}

return $serverVersion->getMajorVersion() >= 31;

Check failure on line 43 in lib/Listener/UserFirstLoginListener.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

UndefinedDocblockClass

lib/Listener/UserFirstLoginListener.php:43:10: UndefinedDocblockClass: Docblock-defined class, interface or enum named OCP\ServerVersion does not exist (see https://psalm.dev/200)
}

public function handle(Event $event): void {
if (!($event instanceof UserFirstTimeLoggedInEvent)) {
return;
}

// TODO: drop condition once we only support Nextcloud >= 31
if (!$this->is31OrAbove) {
return;
}

if (!$this->exampleEventService->shouldCreateExampleEvent()) {
return;
}

$userId = $event->getUser()->getUID();
try {
$this->exampleEventService->createExampleEvent($userId);
} catch (ServiceException $e) {
$this->logger->error(
"Failed to create example event for user $userId: " . $e->getMessage(),
[
'exception' => $e,
'userId' => $userId,
],
);
}
}
}
177 changes: 177 additions & 0 deletions lib/Service/ExampleEventService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Calendar\Service;

use OCA\Calendar\AppInfo\Application;
use OCA\Calendar\Exception\ServiceException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IManager as ICalendarManager;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IAppConfig;
use OCP\Security\ISecureRandom;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;

class ExampleEventService {
private const FOLDER_NAME = 'example_event';
private const FILE_NAME = 'example_event.ics';
private const ENABLE_CONFIG_KEY = 'create_example_event';

public function __construct(
private readonly ICalendarManager $calendarManager,
private readonly ISecureRandom $random,
private readonly ITimeFactory $time,
private readonly IAppData $appData,
private readonly IAppConfig $appConfig,
) {
}

public function createExampleEvent(string $userId): void {
$calendars = $this->calendarManager->getCalendarsForPrincipal("principals/users/$userId");
if ($calendars === []) {
throw new ServiceException("User $userId has no calendars");
}

/** @var ICreateFromString $firstCalendar */
$firstCalendar = $calendars[0];

$customIcs = $this->getCustomExampleEvent();
if ($customIcs === null) {
$this->createDefaultEvent($firstCalendar);
return;
}

// TODO: parsing should be handled inside OCP
try {
$vCalendar = \Sabre\VObject\Reader::read($customIcs);
if (!($vCalendar instanceof VCalendar)) {
throw new ServiceException('Custom event does not contain a VCALENDAR component');
}

/** @var VEvent|null $vEvent */
$vEvent = $vCalendar->getBaseComponent('VEVENT');
if ($vEvent === null) {
throw new ServiceException('Custom event does not contain a VEVENT component');
}
} catch (\Exception $e) {
throw new ServiceException('Failed to parse custom event: ' . $e->getMessage(), 0, $e);
}

$uid = $this->random->generate(32, ISecureRandom::CHAR_ALPHANUMERIC);
$vEvent->UID = $uid;
$vEvent->DTSTART = $this->getStartDate();
$vEvent->DTEND = $this->getEndDate();
$vEvent->remove('ORGANIZER');
$vEvent->remove('ATTENDEE');
$firstCalendar->createFromString("$uid.ics", $vCalendar->serialize());
}

private function getStartDate(): \DateTimeInterface {
return $this->time->now()
->add(new \DateInterval('P7D'))
->setTime(10, 00);
}

private function getEndDate(): \DateTimeInterface {
return $this->time->now()
->add(new \DateInterval('P7D'))
->setTime(11, 00);
}

private function createDefaultEvent(ICreateFromString $calendar): void {
$defaultDescription = <<<EOF
Welcome to Nextcloud Calendar!
This is a sample event - explore the flexibility of planning with Nextcloud Calendar by making any edits you want!
With Nextcloud Calendar, you can:
- Create, edit, and manage events effortlessly.
- Create multiple calendars and share them with teammates, friends, or family.
- Check availability and display your busy times to others.
- Seamlessly integrate with apps and devices via CalDAV.
- Customize your experience: schedule recurring events, adjust notifications and other settings.
EOF;

$eventBuilder = $this->calendarManager->createEventBuilder();

Check failure on line 105 in lib/Service/ExampleEventService.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

UndefinedInterfaceMethod

lib/Service/ExampleEventService.php:105:43: UndefinedInterfaceMethod: Method OCP\Calendar\IManager::createEventBuilder does not exist (see https://psalm.dev/181)
$eventBuilder->setSummary('Example event - open me!');
$eventBuilder->setDescription($defaultDescription);
$eventBuilder->setStartDate($this->getStartDate());
$eventBuilder->setEndDate($this->getEndDate());
$eventBuilder->createInCalendar($calendar);
}

/**
* @return string|null The ics of the custom example event or null if no custom event was uploaded.
* @throws ServiceException If reading the custom ics file fails.
*/
private function getCustomExampleEvent(): ?string {
try {
$folder = $this->appData->getFolder(self::FOLDER_NAME);
$icsFile = $folder->getFile(self::FILE_NAME);
} catch (NotFoundException $e) {
return null;
}

try {
return $icsFile->getContent();
} catch (NotFoundException|NotPermittedException $e) {
throw new ServiceException(
'Failed to read custom example event',
0,
$e,
);
}
}

public function saveCustomExampleEvent(string $ics): void {
try {
$folder = $this->appData->getFolder(self::FOLDER_NAME);
} catch (NotFoundException $e) {
$folder = $this->appData->newFolder(self::FOLDER_NAME);
}

try {
$existingFile = $folder->getFile(self::FILE_NAME);
$existingFile->putContent($ics);
} catch (NotFoundException $e) {
$folder->newFile(self::FILE_NAME, $ics);
}
}

public function deleteCustomExampleEvent(): void {
try {
$folder = $this->appData->getFolder(self::FOLDER_NAME);
$file = $folder->getFile(self::FILE_NAME);
} catch (NotFoundException $e) {
return;
}

$file->delete();
}

public function hasCustomExampleEvent(): bool {
try {
return $this->getCustomExampleEvent() !== null;
} catch (ServiceException $e) {
return false;
}
}

public function setCreateExampleEvent(bool $enable) {
$this->appConfig->setValueBool(Application::APP_ID, self::ENABLE_CONFIG_KEY, $enable);
}

public function shouldCreateExampleEvent(): bool {
return $this->appConfig->getValueBool(Application::APP_ID, self::ENABLE_CONFIG_KEY, true);
}
}
44 changes: 44 additions & 0 deletions lib/Settings/ExampleEventSettings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Calendar\Settings;

use OCA\Calendar\AppInfo\Application;
use OCA\Calendar\Service\ExampleEventService;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Settings\ISettings;

class ExampleEventSettings implements ISettings {
public function __construct(
private readonly IInitialState $initialState,
private readonly ExampleEventService $exampleEventService,
) {
}

public function getForm() {
$this->initialState->provideInitialState(
'create_example_event',
$this->exampleEventService->shouldCreateExampleEvent(),
);
$this->initialState->provideInitialState(
'has_custom_example_event',
$this->exampleEventService->hasCustomExampleEvent(),
);
return new TemplateResponse(Application::APP_ID, 'settings-admin-groupware');
}

public function getSection() {
return 'groupware';
}

public function getPriority() {
return 60;
}
}
Loading

0 comments on commit 73f2d0c

Please sign in to comment.