Skip to content

Commit 73f2d0c

Browse files
committedJan 28, 2025
feat: create example event when a user logs in for the first time
Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
1 parent 9f1ca85 commit 73f2d0c

16 files changed

+884
-0
lines changed
 

‎appinfo/info.xml

+3
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,7 @@
5656
<order>5</order>
5757
</navigation>
5858
</navigations>
59+
<settings>
60+
<admin>OCA\Calendar\Settings\ExampleEventSettings</admin>
61+
</settings>
5962
</info>

‎lib/AppInfo/Application.php

+3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use OCA\Calendar\Events\BeforeAppointmentBookedEvent;
1212
use OCA\Calendar\Listener\AppointmentBookedListener;
1313
use OCA\Calendar\Listener\CalendarReferenceListener;
14+
use OCA\Calendar\Listener\UserFirstLoginListener;
1415
use OCA\Calendar\Listener\UserDeletedListener;
1516
use OCA\Calendar\Notification\Notifier;
1617
use OCA\Calendar\Profile\AppointmentsAction;
@@ -22,6 +23,7 @@
2223
use OCP\Collaboration\Reference\RenderReferenceEvent;
2324
use OCP\ServerVersion;
2425
use OCP\User\Events\UserDeletedEvent;
26+
use OCP\User\Events\UserFirstTimeLoggedInEvent;
2527
use OCP\Util;
2628
use Psr\Container\ContainerExceptionInterface;
2729
use Psr\Container\ContainerInterface;
@@ -53,6 +55,7 @@ public function register(IRegistrationContext $context): void {
5355
$context->registerEventListener(BeforeAppointmentBookedEvent::class, AppointmentBookedListener::class);
5456
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
5557
$context->registerEventListener(RenderReferenceEvent::class, CalendarReferenceListener::class);
58+
$context->registerEventListener(UserFirstTimeLoggedInEvent::class, UserFirstLoginListener::class);
5659

5760
$context->registerNotifierService(Notifier::class);
5861
}
+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Calendar\Controller;
11+
12+
use OCA\Calendar\AppInfo\Application;
13+
use OCA\Calendar\Http\JsonResponse;
14+
use OCA\Calendar\Service\ExampleEventService;
15+
use OCP\AppFramework\Controller;
16+
use OCP\AppFramework\Http;
17+
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
18+
use OCP\IRequest;
19+
20+
class ExampleEventController extends Controller {
21+
public function __construct(
22+
IRequest $request,
23+
private readonly ExampleEventService $exampleEventService,
24+
) {
25+
parent::__construct(Application::APP_ID, $request);
26+
}
27+
28+
#[FrontpageRoute(verb: 'POST', url: '/v1/exampleEvent/enable')]
29+
public function setCreateExampleEvent(bool $enable): JSONResponse {
30+
$this->exampleEventService->setCreateExampleEvent($enable);
31+
return JsonResponse::success([]);
32+
}
33+
34+
#[FrontpageRoute(verb: 'POST', url: '/v1/exampleEvent/event')]
35+
public function uploadExampleEvent(string $ics): JSONResponse {
36+
if (!$this->exampleEventService->shouldCreateExampleEvent()) {
37+
return JSONResponse::fail([], Http::STATUS_FORBIDDEN);
38+
}
39+
40+
$this->exampleEventService->saveCustomExampleEvent($ics);
41+
return JsonResponse::success([]);
42+
}
43+
44+
#[FrontpageRoute(verb: 'DELETE', url: '/v1/exampleEvent/event')]
45+
public function deleteExampleEvent(): JSONResponse {
46+
$this->exampleEventService->deleteCustomExampleEvent();
47+
return JsonResponse::success([]);
48+
}
49+
}
+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Calendar\Listener;
11+
12+
use OCA\Calendar\Exception\ServiceException;
13+
use OCA\Calendar\Service\ExampleEventService;
14+
use OCP\EventDispatcher\Event;
15+
use OCP\EventDispatcher\IEventListener;
16+
use OCP\ServerVersion;
17+
use OCP\User\Events\UserFirstTimeLoggedInEvent;
18+
use Psr\Container\ContainerExceptionInterface;
19+
use Psr\Container\ContainerInterface;
20+
use Psr\Log\LoggerInterface;
21+
22+
/** @template-implements IEventListener<UserFirstTimeLoggedInEvent> */
23+
class UserFirstLoginListener implements IEventListener {
24+
private bool $is31OrAbove;
25+
26+
public function __construct(
27+
private readonly ExampleEventService $exampleEventService,
28+
private readonly LoggerInterface $logger,
29+
ContainerInterface $container,
30+
) {
31+
$this->is31OrAbove = self::isNextcloud31OrAbove($container);
32+
}
33+
34+
private static function isNextcloud31OrAbove(ContainerInterface $container): bool {
35+
// ServerVersion was added in 31, but we don't care about older versions anyway
36+
try {
37+
/** @var ServerVersion $serverVersion */
38+
$serverVersion = $container->get(ServerVersion::class);
39+
} catch (ContainerExceptionInterface $e) {
40+
return false;
41+
}
42+
43+
return $serverVersion->getMajorVersion() >= 31;
44+
}
45+
46+
public function handle(Event $event): void {
47+
if (!($event instanceof UserFirstTimeLoggedInEvent)) {
48+
return;
49+
}
50+
51+
// TODO: drop condition once we only support Nextcloud >= 31
52+
if (!$this->is31OrAbove) {
53+
return;
54+
}
55+
56+
if (!$this->exampleEventService->shouldCreateExampleEvent()) {
57+
return;
58+
}
59+
60+
$userId = $event->getUser()->getUID();
61+
try {
62+
$this->exampleEventService->createExampleEvent($userId);
63+
} catch (ServiceException $e) {
64+
$this->logger->error(
65+
"Failed to create example event for user $userId: " . $e->getMessage(),
66+
[
67+
'exception' => $e,
68+
'userId' => $userId,
69+
],
70+
);
71+
}
72+
}
73+
}

‎lib/Service/ExampleEventService.php

+177
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Calendar\Service;
11+
12+
use OCA\Calendar\AppInfo\Application;
13+
use OCA\Calendar\Exception\ServiceException;
14+
use OCP\AppFramework\Utility\ITimeFactory;
15+
use OCP\Calendar\ICreateFromString;
16+
use OCP\Calendar\IManager as ICalendarManager;
17+
use OCP\Files\IAppData;
18+
use OCP\Files\NotFoundException;
19+
use OCP\Files\NotPermittedException;
20+
use OCP\IAppConfig;
21+
use OCP\Security\ISecureRandom;
22+
use Sabre\VObject\Component\VCalendar;
23+
use Sabre\VObject\Component\VEvent;
24+
25+
class ExampleEventService {
26+
private const FOLDER_NAME = 'example_event';
27+
private const FILE_NAME = 'example_event.ics';
28+
private const ENABLE_CONFIG_KEY = 'create_example_event';
29+
30+
public function __construct(
31+
private readonly ICalendarManager $calendarManager,
32+
private readonly ISecureRandom $random,
33+
private readonly ITimeFactory $time,
34+
private readonly IAppData $appData,
35+
private readonly IAppConfig $appConfig,
36+
) {
37+
}
38+
39+
public function createExampleEvent(string $userId): void {
40+
$calendars = $this->calendarManager->getCalendarsForPrincipal("principals/users/$userId");
41+
if ($calendars === []) {
42+
throw new ServiceException("User $userId has no calendars");
43+
}
44+
45+
/** @var ICreateFromString $firstCalendar */
46+
$firstCalendar = $calendars[0];
47+
48+
$customIcs = $this->getCustomExampleEvent();
49+
if ($customIcs === null) {
50+
$this->createDefaultEvent($firstCalendar);
51+
return;
52+
}
53+
54+
// TODO: parsing should be handled inside OCP
55+
try {
56+
$vCalendar = \Sabre\VObject\Reader::read($customIcs);
57+
if (!($vCalendar instanceof VCalendar)) {
58+
throw new ServiceException('Custom event does not contain a VCALENDAR component');
59+
}
60+
61+
/** @var VEvent|null $vEvent */
62+
$vEvent = $vCalendar->getBaseComponent('VEVENT');
63+
if ($vEvent === null) {
64+
throw new ServiceException('Custom event does not contain a VEVENT component');
65+
}
66+
} catch (\Exception $e) {
67+
throw new ServiceException('Failed to parse custom event: ' . $e->getMessage(), 0, $e);
68+
}
69+
70+
$uid = $this->random->generate(32, ISecureRandom::CHAR_ALPHANUMERIC);
71+
$vEvent->UID = $uid;
72+
$vEvent->DTSTART = $this->getStartDate();
73+
$vEvent->DTEND = $this->getEndDate();
74+
$vEvent->remove('ORGANIZER');
75+
$vEvent->remove('ATTENDEE');
76+
$firstCalendar->createFromString("$uid.ics", $vCalendar->serialize());
77+
}
78+
79+
private function getStartDate(): \DateTimeInterface {
80+
return $this->time->now()
81+
->add(new \DateInterval('P7D'))
82+
->setTime(10, 00);
83+
}
84+
85+
private function getEndDate(): \DateTimeInterface {
86+
return $this->time->now()
87+
->add(new \DateInterval('P7D'))
88+
->setTime(11, 00);
89+
}
90+
91+
private function createDefaultEvent(ICreateFromString $calendar): void {
92+
$defaultDescription = <<<EOF
93+
Welcome to Nextcloud Calendar!
94+
95+
This is a sample event - explore the flexibility of planning with Nextcloud Calendar by making any edits you want!
96+
97+
With Nextcloud Calendar, you can:
98+
- Create, edit, and manage events effortlessly.
99+
- Create multiple calendars and share them with teammates, friends, or family.
100+
- Check availability and display your busy times to others.
101+
- Seamlessly integrate with apps and devices via CalDAV.
102+
- Customize your experience: schedule recurring events, adjust notifications and other settings.
103+
EOF;
104+
105+
$eventBuilder = $this->calendarManager->createEventBuilder();
106+
$eventBuilder->setSummary('Example event - open me!');
107+
$eventBuilder->setDescription($defaultDescription);
108+
$eventBuilder->setStartDate($this->getStartDate());
109+
$eventBuilder->setEndDate($this->getEndDate());
110+
$eventBuilder->createInCalendar($calendar);
111+
}
112+
113+
/**
114+
* @return string|null The ics of the custom example event or null if no custom event was uploaded.
115+
* @throws ServiceException If reading the custom ics file fails.
116+
*/
117+
private function getCustomExampleEvent(): ?string {
118+
try {
119+
$folder = $this->appData->getFolder(self::FOLDER_NAME);
120+
$icsFile = $folder->getFile(self::FILE_NAME);
121+
} catch (NotFoundException $e) {
122+
return null;
123+
}
124+
125+
try {
126+
return $icsFile->getContent();
127+
} catch (NotFoundException|NotPermittedException $e) {
128+
throw new ServiceException(
129+
'Failed to read custom example event',
130+
0,
131+
$e,
132+
);
133+
}
134+
}
135+
136+
public function saveCustomExampleEvent(string $ics): void {
137+
try {
138+
$folder = $this->appData->getFolder(self::FOLDER_NAME);
139+
} catch (NotFoundException $e) {
140+
$folder = $this->appData->newFolder(self::FOLDER_NAME);
141+
}
142+
143+
try {
144+
$existingFile = $folder->getFile(self::FILE_NAME);
145+
$existingFile->putContent($ics);
146+
} catch (NotFoundException $e) {
147+
$folder->newFile(self::FILE_NAME, $ics);
148+
}
149+
}
150+
151+
public function deleteCustomExampleEvent(): void {
152+
try {
153+
$folder = $this->appData->getFolder(self::FOLDER_NAME);
154+
$file = $folder->getFile(self::FILE_NAME);
155+
} catch (NotFoundException $e) {
156+
return;
157+
}
158+
159+
$file->delete();
160+
}
161+
162+
public function hasCustomExampleEvent(): bool {
163+
try {
164+
return $this->getCustomExampleEvent() !== null;
165+
} catch (ServiceException $e) {
166+
return false;
167+
}
168+
}
169+
170+
public function setCreateExampleEvent(bool $enable) {
171+
$this->appConfig->setValueBool(Application::APP_ID, self::ENABLE_CONFIG_KEY, $enable);
172+
}
173+
174+
public function shouldCreateExampleEvent(): bool {
175+
return $this->appConfig->getValueBool(Application::APP_ID, self::ENABLE_CONFIG_KEY, true);
176+
}
177+
}

‎lib/Settings/ExampleEventSettings.php

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Calendar\Settings;
11+
12+
use OCA\Calendar\AppInfo\Application;
13+
use OCA\Calendar\Service\ExampleEventService;
14+
use OCP\AppFramework\Http\TemplateResponse;
15+
use OCP\AppFramework\Services\IInitialState;
16+
use OCP\Settings\ISettings;
17+
18+
class ExampleEventSettings implements ISettings {
19+
public function __construct(
20+
private readonly IInitialState $initialState,
21+
private readonly ExampleEventService $exampleEventService,
22+
) {
23+
}
24+
25+
public function getForm() {
26+
$this->initialState->provideInitialState(
27+
'create_example_event',
28+
$this->exampleEventService->shouldCreateExampleEvent(),
29+
);
30+
$this->initialState->provideInitialState(
31+
'has_custom_example_event',
32+
$this->exampleEventService->hasCustomExampleEvent(),
33+
);
34+
return new TemplateResponse(Application::APP_ID, 'settings-admin-groupware');
35+
}
36+
37+
public function getSection() {
38+
return 'groupware';
39+
}
40+
41+
public function getPriority() {
42+
return 60;
43+
}
44+
}

0 commit comments

Comments
 (0)
Failed to load comments.