From 8250561cabf8c6966f3b59b86cbfb10acc381192 Mon Sep 17 00:00:00 2001 From: Florian Ludwig Date: Fri, 24 Jul 2026 23:36:04 +0200 Subject: [PATCH 1/8] feat(organizers): per-appointment organizers with delegated creation (#73) Appointments get an organizer list (individual users, stored like the visibility fields). Organizers hold a fixed right set on their own appointment: edit everything, delete, see responses/comments/audit log, manage bookings and send reminders. Check-in stays a global permission. - New additive permission create_appointments (empty config = nobody additional, so upgrades stay behavior-neutral); creators become organizers of their own appointments automatically - Organizer list editing: managers freely; organizers may add anyone but remove only themselves; guests can never be organizers - Existing appointments are backfilled with organizers=[created_by], generalizing the previous createdBy special cases - Per-appointment myPermissions payload (canEdit, canSeeResponses, canSeeComments, canSeeAuditLog, isOrganizer) consumed by the web UI; new capability flag 'organizers' for mobile clients - Organizers receive response-change notifications for their appointments; personal-settings toggle extended accordingly - Web UI: organizer picker in the appointment form (creator prefilled), 'Organized by' line on cards, admin setting for the new permission --- lib/Controller/AppointmentController.php | 187 ++++++++-------- lib/Controller/AuditController.php | 18 +- lib/Controller/CalendarController.php | 4 +- lib/Controller/Traits/RequiresAuthTrait.php | 1 + lib/Db/Appointment.php | 24 ++ lib/Db/AppointmentMapper.php | 24 ++ .../ResponseChangeNotificationListener.php | 22 +- .../Version000017Date20260724120000.php | 77 +++++++ lib/ResponseDefinitions.php | 12 + lib/Service/AppointmentService.php | 210 ++++++++++++++++-- lib/Service/PermissionService.php | 59 ++++- lib/Service/VisibilityService.php | 6 + openapi-administration.json | 6 +- openapi-full.json | 120 +++++++++- openapi.json | 120 +++++++++- src/App.vue | 2 +- .../appointment/AppointmentCard.vue | 63 ++++-- src/composables/usePermissions.js | 7 + src/views/AdminSettings.vue | 21 ++ src/views/AppointmentDetail.vue | 5 + src/views/AppointmentForm.vue | 165 ++++++++++++-- src/views/PersonalSettings.vue | 2 +- tests/unit/Service/AppointmentServiceTest.php | 133 +++++++++++ tests/unit/Service/PermissionServiceTest.php | 125 ++++++++++- 24 files changed, 1229 insertions(+), 184 deletions(-) create mode 100644 lib/Migration/Version000017Date20260724120000.php diff --git a/lib/Controller/AppointmentController.php b/lib/Controller/AppointmentController.php index 734571a9..cbcd96e3 100644 --- a/lib/Controller/AppointmentController.php +++ b/lib/Controller/AppointmentController.php @@ -78,6 +78,24 @@ public function __construct( $this->bookingService = $bookingService; } + /** + * Load an appointment and authorize the current user to manage it + * (global manager or organizer of this appointment). Returns the error + * DataResponse to send when loading or authorization fails, the + * Appointment otherwise. + */ + private function findManageableAppointment(int $id, string $userId, string $permissionError): \OCA\Attendance\Db\Appointment|DataResponse { + try { + $appointment = $this->appointmentService->getAppointment($id); + } catch (\Exception $e) { + return new DataResponse(['error' => 'Appointment not found'], 404); + } + if (!$this->permissionService->canManageAppointment($userId, $appointment)) { + return new DataResponse(['error' => $permissionError], 403); + } + return $appointment; + } + /** * List appointments visible to the current user * @@ -112,9 +130,10 @@ public function index(bool $showPastAppointments = false, bool $unansweredOnly = $canSeeComments, ); - // Add checkin summary to each appointment if user can see response overview - if ($canSeeResponseOverview) { - foreach ($appointments as &$appointment) { + // Add checkin summary to each appointment the user may see responses + // for (global permission or organizer of that appointment) + foreach ($appointments as &$appointment) { + if ($appointment['myPermissions']['canSeeResponses']) { $appointment['checkinSummary'] = $this->checkinService->getCheckinSummary($appointment['id']); } } @@ -155,6 +174,7 @@ public function navigation(): DataResponse { * @param ?string $calendarEventUid UID of the source calendar event * @param list $attachments File IDs to attach to the appointment * @param ?string $responseDeadline Optional response deadline (ISO 8601). Cron auto-closes the inquiry once passed. + * @param list $organizers User IDs to set as organizers; empty defaults to the creator * @return DataResponse|DataResponse|DataResponse|DataResponse */ #[NoAdminRequired] @@ -173,14 +193,16 @@ public function create( ?string $calendarEventUid = null, array $attachments = [], ?string $responseDeadline = null, + array $organizers = [], ): DataResponse { $user = $this->userSession->getUser(); if (!$user) { return new DataResponse(['error' => 'User not authenticated'], 401); } - // Check if user can manage appointments - if (!$this->permissionService->canManageAppointments($user->getUID())) { + // Managers or members of the create_appointments groups may create; + // the creator becomes organizer of the new appointment. + if (!$this->permissionService->canCreateAppointments($user->getUID())) { return new DataResponse(['error' => 'Insufficient permissions to create appointments'], 403); } @@ -200,6 +222,7 @@ public function create( null, null, $responseDeadline, + $organizers === [] ? null : $organizers, ); $this->addAttachmentsToAppointment($appointment->getId(), $attachments, $user->getUID()); @@ -216,18 +239,19 @@ public function create( * @param list $appointments List of appointments to create * @param bool $sendNotification Whether to send a batch notification to affected users * @param list $attachments File IDs to attach to all created appointments + * @param list $organizers User IDs to set as organizers on all created appointments; empty defaults to the creator * @return DataResponse, errors: list}, array{}>|DataResponse|DataResponse */ #[NoAdminRequired] #[NoCSRFRequired] #[OpenAPI] - public function bulkCreate(array $appointments, bool $sendNotification = false, array $attachments = []): DataResponse { + public function bulkCreate(array $appointments, bool $sendNotification = false, array $attachments = [], array $organizers = []): DataResponse { $user = $this->userSession->getUser(); if (!$user) { return new DataResponse(['error' => 'User not authenticated'], 401); } - if (!$this->permissionService->canManageAppointments($user->getUID())) { + if (!$this->permissionService->canCreateAppointments($user->getUID())) { return new DataResponse(['error' => 'Insufficient permissions to create appointments'], 403); } @@ -255,6 +279,7 @@ public function bulkCreate(array $appointments, bool $sendNotification = false, $seriesId, $index, $data['responseDeadline'] ?? null, + $organizers === [] ? null : $organizers, ); $createdIds[] = $appointment->getId(); if ($firstAppointment === null) { @@ -307,6 +332,7 @@ public function bulkCreate(array $appointments, bool $sendNotification = false, * @param list $attachments File IDs to attach to the appointment * @param string $scope Series update scope: single, future, or all * @param ?string $responseDeadline Optional response deadline (ISO 8601). Empty string clears it; null leaves unchanged. + * @param ?list $organizers New organizer list, or null to leave unchanged. Organizers may add anyone but remove only themselves; managers may change freely. * @return DataResponse, array{}>|DataResponse|DataResponse|DataResponse|DataResponse */ #[NoAdminRequired] @@ -324,20 +350,16 @@ public function update( array $attachments = [], string $scope = 'single', ?string $responseDeadline = null, + ?array $organizers = null, ): DataResponse { $user = $this->userSession->getUser(); if (!$user) { return new DataResponse(['error' => 'User not authenticated'], 401); } - // Check if user can manage appointments or is creator - try { - $appointment = $this->appointmentService->getAppointment($id); - if (!$this->permissionService->canManageAppointments($user->getUID()) && $appointment->getCreatedBy() !== $user->getUID()) { - return new DataResponse(['error' => 'Insufficient permissions to update appointments'], 403); - } - } catch (\Exception $e) { - return new DataResponse(['error' => 'Appointment not found'], 404); + $appointment = $this->findManageableAppointment($id, $user->getUID(), 'Insufficient permissions to update appointments'); + if ($appointment instanceof DataResponse) { + return $appointment; } try { @@ -347,7 +369,7 @@ public function update( $updatedAppointments = $this->appointmentService->updateSeriesAppointments( $id, $scope, $name, $description, $startDatetime, $endDatetime, $user->getUID(), $visibleUsers, $visibleGroups, $visibleTeams, - $deadlineUpdate, + $deadlineUpdate, $organizers, ); // Sync attachments across all affected appointments @@ -363,7 +385,7 @@ public function update( $updatedAppointments = $this->appointmentService->updateSeriesAppointments( $id, 'single', $name, $description, $startDatetime, $endDatetime, $user->getUID(), $visibleUsers, $visibleGroups, $visibleTeams, - $deadlineUpdate, + $deadlineUpdate, $organizers, ); $this->syncAttachments($id, $attachments, $user->getUID()); return new DataResponse($updatedAppointments[0]); @@ -372,7 +394,7 @@ public function update( $appointment = $this->appointmentService->updateAppointment( $id, $name, $description, $startDatetime, $endDatetime, $user->getUID(), $visibleUsers, $visibleGroups, $visibleTeams, - $deadlineUpdate, + $deadlineUpdate, $organizers, ); $this->syncAttachments($id, $attachments, $user->getUID()); @@ -403,14 +425,9 @@ public function close(int $id): DataResponse { return new DataResponse(['error' => 'User not authenticated'], 401); } - try { - $appointment = $this->appointmentService->getAppointment($id); - if (!$this->permissionService->canManageAppointments($user->getUID()) - && $appointment->getCreatedBy() !== $user->getUID()) { - return new DataResponse(['error' => 'Insufficient permissions to close this appointment'], 403); - } - } catch (\Exception $e) { - return new DataResponse(['error' => 'Appointment not found'], 404); + $appointment = $this->findManageableAppointment($id, $user->getUID(), 'Insufficient permissions to close this appointment'); + if ($appointment instanceof DataResponse) { + return $appointment; } $updated = $this->appointmentService->closeAppointment($id); @@ -432,14 +449,9 @@ public function reopen(int $id): DataResponse { return new DataResponse(['error' => 'User not authenticated'], 401); } - try { - $appointment = $this->appointmentService->getAppointment($id); - if (!$this->permissionService->canManageAppointments($user->getUID()) - && $appointment->getCreatedBy() !== $user->getUID()) { - return new DataResponse(['error' => 'Insufficient permissions to re-open this appointment'], 403); - } - } catch (\Exception $e) { - return new DataResponse(['error' => 'Appointment not found'], 404); + $appointment = $this->findManageableAppointment($id, $user->getUID(), 'Insufficient permissions to re-open this appointment'); + if ($appointment instanceof DataResponse) { + return $appointment; } $updated = $this->appointmentService->reopenAppointment($id); @@ -466,14 +478,9 @@ public function cancel(int $id): DataResponse { return new DataResponse(['error' => 'User not authenticated'], 401); } - try { - $appointment = $this->appointmentService->getAppointment($id); - if (!$this->permissionService->canManageAppointments($user->getUID()) - && $appointment->getCreatedBy() !== $user->getUID()) { - return new DataResponse(['error' => 'Insufficient permissions to cancel this appointment'], 403); - } - } catch (\Exception $e) { - return new DataResponse(['error' => 'Appointment not found'], 404); + $appointment = $this->findManageableAppointment($id, $user->getUID(), 'Insufficient permissions to cancel this appointment'); + if ($appointment instanceof DataResponse) { + return $appointment; } $updated = $this->appointmentService->cancelAppointment($id, $user->getUID()); @@ -495,14 +502,9 @@ public function uncancel(int $id): DataResponse { return new DataResponse(['error' => 'User not authenticated'], 401); } - try { - $appointment = $this->appointmentService->getAppointment($id); - if (!$this->permissionService->canManageAppointments($user->getUID()) - && $appointment->getCreatedBy() !== $user->getUID()) { - return new DataResponse(['error' => 'Insufficient permissions to reactivate this appointment'], 403); - } - } catch (\Exception $e) { - return new DataResponse(['error' => 'Appointment not found'], 404); + $appointment = $this->findManageableAppointment($id, $user->getUID(), 'Insufficient permissions to reactivate this appointment'); + if ($appointment instanceof DataResponse) { + return $appointment; } $updated = $this->appointmentService->uncancelAppointment($id); @@ -554,14 +556,9 @@ private function setBooking(int $id, string $userId, bool $book): DataResponse { return new DataResponse(['error' => 'Booking is not enabled'], 400); } - try { - $appointment = $this->appointmentService->getAppointment($id); - if (!$this->permissionService->canManageAppointments($user->getUID()) - && $appointment->getCreatedBy() !== $user->getUID()) { - return new DataResponse(['error' => 'Insufficient permissions to change bookings'], 403); - } - } catch (\Exception $e) { - return new DataResponse(['error' => 'Appointment not found'], 404); + $appointment = $this->findManageableAppointment($id, $user->getUID(), 'Insufficient permissions to change bookings'); + if ($appointment instanceof DataResponse) { + return $appointment; } try { @@ -608,8 +605,8 @@ public function show(int $id): DataResponse { return new DataResponse(['error' => 'Appointment not found or not visible'], 404); } - // Add checkin summary if user can see response overview - if ($canSeeResponseOverview) { + // Add checkin summary if user may see responses for this appointment + if ($appointment['myPermissions']['canSeeResponses']) { $appointment['checkinSummary'] = $this->checkinService->getCheckinSummary($id); } @@ -636,13 +633,9 @@ public function destroy(int $id, string $scope = 'single'): DataResponse { } // Check if user can manage appointments or is creator - try { - $appointment = $this->appointmentService->getAppointment($id); - if (!$this->permissionService->canManageAppointments($user->getUID()) && $appointment->getCreatedBy() !== $user->getUID()) { - return new DataResponse(['error' => 'Insufficient permissions to delete appointments'], 403); - } - } catch (\Exception $e) { - return new DataResponse(['error' => 'Appointment not found'], 404); + $appointment = $this->findManageableAppointment($id, $user->getUID(), 'Insufficient permissions to delete appointments'); + if ($appointment instanceof DataResponse) { + return $appointment; } try { @@ -664,10 +657,10 @@ public function destroy(int $id, string $scope = 'single'): DataResponse { } /** - * Get detailed responses for an appointment (requires manage appointments permission) + * Get detailed responses for an appointment (requires manage appointments permission or being an organizer of it) * * @param int $id Appointment ID - * @return DataResponse, array{}>|DataResponse|DataResponse + * @return DataResponse, array{}>|DataResponse|DataResponse|DataResponse */ #[NoAdminRequired] #[NoCSRFRequired] @@ -678,9 +671,9 @@ public function getResponses(int $id): DataResponse { return new DataResponse(['error' => 'User not authenticated'], 401); } - // Check if user can manage appointments - if (!$this->permissionService->canManageAppointments($user->getUID())) { - return new DataResponse(['error' => 'Insufficient permissions to view detailed responses'], 403); + $appointment = $this->findManageableAppointment($id, $user->getUID(), 'Insufficient permissions to view detailed responses'); + if ($appointment instanceof DataResponse) { + return $appointment; } try { @@ -838,6 +831,7 @@ public function getPermissions(): DataResponse { return new DataResponse([ 'canManageAppointments' => $this->permissionService->canManageAppointments($user->getUID()), + 'canCreateAppointments' => $this->permissionService->canCreateAppointments($user->getUID()), 'canCheckin' => $this->permissionService->canCheckin($user->getUID()), 'canSeeResponseOverview' => $this->permissionService->canSeeResponseOverview($user->getUID()), 'canSeeComments' => $this->permissionService->canSeeComments($user->getUID()), @@ -878,6 +872,10 @@ public function getCapabilities(): DataResponse { // Server supports QR/NFC self-check-in (method param + overview // endpoint). Mobile clients hide the scan UI when this is false. 'selfCheckin' => true, + // Server supports per-appointment organizers (organizer list, + // myPermissions payload, create_appointments permission). Mobile + // clients hide organizer UI when this is false. + 'organizers' => true, ]); } @@ -924,7 +922,8 @@ public function getUserConfig(): DataResponse { * Get personal user settings * * Response-change notification toggle is only included for users who can - * actually receive these notifications (manage_appointments + audit log + * actually receive these notifications (managers, users allowed to create + * appointments, or organizers of at least one appointment — with audit log * enabled). Frontend uses the key's presence to decide whether to render * the toggle at all. * @@ -944,7 +943,7 @@ public function getUserSettings(): DataResponse { ]; if ($this->configService->isAuditLogEnabled() - && $this->permissionService->canManageAppointments($user->getUID())) { + && $this->canReceiveResponseNotifications($user->getUID())) { $payload['notifyResponseChanges'] = $this->configService->wantsResponseChangeNotifications($user->getUID()); } @@ -971,13 +970,23 @@ public function saveUserSettings(array $icalReminderTriggers = [], ?bool $notify if ($notifyResponseChanges !== null && $this->configService->isAuditLogEnabled() - && $this->permissionService->canManageAppointments($user->getUID())) { + && $this->canReceiveResponseNotifications($user->getUID())) { $this->configService->setWantsResponseChangeNotifications($user->getUID(), $notifyResponseChanges); } return new DataResponse(['success' => true]); } + /** + * Whether the user can receive response-change notifications: global + * managers, users allowed to create appointments (they will become + * organizers), and current organizers of at least one appointment. + */ + private function canReceiveResponseNotifications(string $userId): bool { + return $this->permissionService->canCreateAppointments($userId) + || $this->appointmentService->isOrganizerAnywhere($userId); + } + /** * Get check-in data for an appointment * @@ -1068,8 +1077,10 @@ public function searchUsersGroupsTeams(string $search = ''): DataResponse { return new DataResponse(['error' => 'User not authenticated'], 401); } - // Check if user can manage appointments - if (!$this->permissionService->canManageAppointments($user->getUID())) { + // Managers and users allowed to create appointments need the picker + // for visibility/organizer fields; organizers need it in the edit form. + if (!$this->permissionService->canCreateAppointments($user->getUID()) + && !$this->appointmentService->isOrganizerAnywhere($user->getUID())) { return new DataResponse(['error' => 'Insufficient permissions'], 403); } @@ -1133,18 +1144,13 @@ public function sendReminders(int $id, string $target = 'non_responders'): DataR return new DataResponse(['error' => 'User not authenticated'], 401); } - if (!$this->permissionService->canManageAppointments($user->getUID())) { - return new DataResponse(['error' => 'Insufficient permissions'], 403); - } - if (!in_array($target, ConfigService::VALID_REMINDER_TARGETS, true)) { return new DataResponse(['error' => 'Invalid target, must be one of: non_responders, maybe, both'], 400); } - try { - $appointment = $this->appointmentService->getAppointment($id); - } catch (\Exception $e) { - return new DataResponse(['error' => 'Appointment not found'], 404); + $appointment = $this->findManageableAppointment($id, $user->getUID(), 'Insufficient permissions'); + if ($appointment instanceof DataResponse) { + return $appointment; } if ($appointment->isClosed()) { @@ -1173,14 +1179,9 @@ public function sendReminderToUser(int $id, string $userId): DataResponse { return new DataResponse(['error' => 'User not authenticated'], 401); } - if (!$this->permissionService->canManageAppointments($user->getUID())) { - return new DataResponse(['error' => 'Insufficient permissions'], 403); - } - - try { - $appointment = $this->appointmentService->getAppointment($id); - } catch (\Exception $e) { - return new DataResponse(['error' => 'Appointment not found'], 404); + $appointment = $this->findManageableAppointment($id, $user->getUID(), 'Insufficient permissions'); + if ($appointment instanceof DataResponse) { + return $appointment; } if ($appointment->isClosed()) { diff --git a/lib/Controller/AuditController.php b/lib/Controller/AuditController.php index 584a7b2b..7841d40f 100644 --- a/lib/Controller/AuditController.php +++ b/lib/Controller/AuditController.php @@ -70,21 +70,23 @@ public function index(int $id, ?string $verb = null, ?string $subject = null, in return new DataResponse(['error' => 'Audit log is disabled'], Http::STATUS_PRECONDITION_FAILED); } + try { + $appointment = $this->appointmentMapper->find($id); + } catch (DoesNotExistException $e) { + return new DataResponse(['error' => 'Appointment not found'], Http::STATUS_NOT_FOUND); + } + + // Organizers get the audit timeline of their own appointment in both + // visibility modes — it is part of their per-appointment insights. $visibility = $this->configService->getAuditLogVisibility(); $canRead = $visibility === ConfigService::AUDIT_LOG_VISIBILITY_ALL_WITH_OVERVIEW - ? $this->permissionService->canSeeResponseOverview($user->getUID()) - : $this->permissionService->canManageAppointments($user->getUID()); + ? $this->permissionService->canSeeResponseOverviewFor($user->getUID(), $appointment) + : $this->permissionService->canManageAppointment($user->getUID(), $appointment); if (!$canRead) { return new DataResponse(['error' => 'Insufficient permissions'], Http::STATUS_FORBIDDEN); } - try { - $this->appointmentMapper->find($id); - } catch (DoesNotExistException $e) { - return new DataResponse(['error' => 'Appointment not found'], Http::STATUS_NOT_FOUND); - } - $limit = max(1, min(200, $limit)); $offset = max(0, $offset); diff --git a/lib/Controller/CalendarController.php b/lib/Controller/CalendarController.php index ecb970eb..86cdfd3a 100644 --- a/lib/Controller/CalendarController.php +++ b/lib/Controller/CalendarController.php @@ -69,7 +69,7 @@ public function getCalendars(): DataResponse { } // User must be able to create appointments to use calendar import - if (!$this->permissionService->canManageAppointments($user->getUID())) { + if (!$this->permissionService->canCreateAppointments($user->getUID())) { return new DataResponse(['error' => 'Insufficient permissions'], 403); } @@ -103,7 +103,7 @@ public function getEvents(string $calendarUri, string $from = '', string $to = ' } // User must be able to create appointments to use calendar import - if (!$this->permissionService->canManageAppointments($user->getUID())) { + if (!$this->permissionService->canCreateAppointments($user->getUID())) { return new DataResponse(['error' => 'Insufficient permissions'], 403); } diff --git a/lib/Controller/Traits/RequiresAuthTrait.php b/lib/Controller/Traits/RequiresAuthTrait.php index bb73fea5..46412d3e 100644 --- a/lib/Controller/Traits/RequiresAuthTrait.php +++ b/lib/Controller/Traits/RequiresAuthTrait.php @@ -60,6 +60,7 @@ protected function currentUserHasPermission(string $permission): bool { return match ($permission) { 'manage_appointments' => $this->permissionService->canManageAppointments($userId), + 'create_appointments' => $this->permissionService->canCreateAppointments($userId), 'checkin' => $this->permissionService->canCheckin($userId), 'see_response_overview' => $this->permissionService->canSeeResponseOverview($userId), 'see_comments' => $this->permissionService->canSeeComments($userId), diff --git a/lib/Db/Appointment.php b/lib/Db/Appointment.php index a2761e03..7f106573 100644 --- a/lib/Db/Appointment.php +++ b/lib/Db/Appointment.php @@ -32,6 +32,7 @@ * @method void setVisibleGroups(string $visibleGroups) * @method string getVisibleTeams() * @method void setVisibleTeams(string $visibleTeams) + * @method string|null getOrganizers() * @method string|null getCalendarUri() * @method void setCalendarUri(?string $calendarUri) * @method string|null getCalendarEventUid() @@ -62,6 +63,7 @@ class Appointment extends Entity implements JsonSerializable { protected $visibleUsers = null; protected $visibleGroups = null; protected $visibleTeams = null; + protected $organizers = null; protected $calendarUri = null; protected $calendarEventUid = null; protected $seriesId = null; @@ -84,6 +86,7 @@ public function __construct() { $this->addType('visibleUsers', 'string'); $this->addType('visibleGroups', 'string'); $this->addType('visibleTeams', 'string'); + $this->addType('organizers', 'string'); $this->addType('calendarUri', 'string'); $this->addType('calendarEventUid', 'string'); $this->addType('seriesId', 'string'); @@ -108,6 +111,7 @@ public function jsonSerialize(): array { 'visibleUsers' => $this->parseJsonField($this->getVisibleUsers()), 'visibleGroups' => $this->parseJsonField($this->getVisibleGroups()), 'visibleTeams' => $this->parseJsonField($this->getVisibleTeams()), + 'organizers' => $this->getOrganizersList(), 'calendarUri' => $this->getCalendarUri(), 'calendarEventUid' => $this->getCalendarEventUid(), 'seriesId' => $this->getSeriesId(), @@ -119,6 +123,26 @@ public function jsonSerialize(): array { ]; } + /** @var list|null memoized decoded organizers — the list endpoints + * call getOrganizersList() several times per appointment (visibility, + * myPermissions, serialization); decode once per entity. */ + private ?array $organizersListCache = null; + + public function setOrganizers(?string $organizers): void { + $this->organizersListCache = null; + $this->setter('organizers', [$organizers]); + } + + /** + * Organizer user IDs of this appointment. + * + * @return list + */ + public function getOrganizersList(): array { + return $this->organizersListCache + ??= array_values(array_map('strval', $this->parseJsonField($this->getOrganizers()))); + } + public function isClosed(): bool { return $this->getClosedAt() !== null; } diff --git a/lib/Db/AppointmentMapper.php b/lib/Db/AppointmentMapper.php index dc7b12f3..820e1175 100644 --- a/lib/Db/AppointmentMapper.php +++ b/lib/Db/AppointmentMapper.php @@ -50,6 +50,30 @@ public function findAll(): array { return $this->findEntities($qb); } + /** + * Whether the user is listed as organizer on at least one active + * appointment. LIKE on the JSON column is good enough here: user IDs are + * stored JSON-encoded in double quotes, so matching `"uid"` cannot hit a + * partial ID, and the query only runs on low-frequency paths (personal + * settings, search gate). + */ + public function existsWithOrganizer(string $userId): bool { + $qb = $this->db->getQueryBuilder(); + + $needle = '%' . $this->db->escapeLikeParameter(json_encode($userId)) . '%'; + $qb->select('id') + ->from($this->getTableName()) + ->where($qb->expr()->eq('is_active', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->like('organizers', $qb->createNamedParameter($needle))) + ->setMaxResults(1); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + return $row !== false; + } + /** * @param string $createdBy * @return array diff --git a/lib/Listener/ResponseChangeNotificationListener.php b/lib/Listener/ResponseChangeNotificationListener.php index e052d83a..82983e99 100644 --- a/lib/Listener/ResponseChangeNotificationListener.php +++ b/lib/Listener/ResponseChangeNotificationListener.php @@ -15,9 +15,9 @@ use Psr\Log\LoggerInterface; /** - * Dispatches a notification to every manage_appointments user who opted in - * via personal settings, whenever a response is submitted, changed, or - * rescinded. + * Dispatches a notification to every manage_appointments user and every + * organizer of the affected appointment who opted in via personal settings, + * whenever a response is submitted, changed, or rescinded. */ class ResponseChangeNotificationListener { private const SUBJECT_BY_VERB = [ @@ -61,7 +61,7 @@ public function handle(AuditEvent $event): void { return; } - $recipients = $this->resolveOptedInRecipients($event->getActorId()); + $recipients = $this->resolveOptedInRecipients($event->getActorId(), $appointment); if ($recipients === []) { return; } @@ -100,20 +100,22 @@ public function handle(AuditEvent $event): void { } /** - * Bulk-fetch the notify_response_changes opt-in for every manager and - * filter to those who actually want pushes, minus the actor (no self-notify). + * Bulk-fetch the notify_response_changes opt-in for every manager plus the + * organizers of the affected appointment, and filter to those who actually + * want pushes, minus the actor (no self-notify). * * @return list */ - private function resolveOptedInRecipients(?string $actorId): array { + private function resolveOptedInRecipients(?string $actorId, \OCA\Attendance\Db\Appointment $appointment): array { $managers = $this->permissionService->getUsersWith(PermissionService::PERMISSION_MANAGE_APPOINTMENTS); - if ($managers === []) { + $candidates = array_values(array_unique(array_merge($managers, $appointment->getOrganizersList()))); + if ($candidates === []) { return []; } // One bulk read instead of N getUserValue() calls. - $optInValues = $this->config->getUserValueForUsers('attendance', 'notify_response_changes', $managers); + $optInValues = $this->config->getUserValueForUsers('attendance', 'notify_response_changes', $candidates); $recipients = []; - foreach ($managers as $uid) { + foreach ($candidates as $uid) { if ($uid === $actorId) { continue; } diff --git a/lib/Migration/Version000017Date20260724120000.php b/lib/Migration/Version000017Date20260724120000.php new file mode 100644 index 00000000..4a8aa7b3 --- /dev/null +++ b/lib/Migration/Version000017Date20260724120000.php @@ -0,0 +1,77 @@ +hasTable('att_appointments')) { + $table = $schema->getTable('att_appointments'); + + if (!$table->hasColumn('organizers')) { + $table->addColumn('organizers', 'text', [ + 'notnull' => false, + ]); + } + } + + return $schema; + } + + /** + * Backfill organizers with the creator for all existing appointments. + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $select = $this->db->getQueryBuilder(); + $select->select('id', 'created_by') + ->from('att_appointments') + ->where($select->expr()->isNull('organizers')); + $result = $select->executeQuery(); + + $update = $this->db->getQueryBuilder(); + $update->update('att_appointments') + ->set('organizers', $update->createParameter('organizers')) + ->where($update->expr()->eq('id', $update->createParameter('id'))); + + while ($row = $result->fetch()) { + $createdBy = (string)($row['created_by'] ?? ''); + if ($createdBy === '') { + continue; + } + $update->setParameter('organizers', json_encode([$createdBy])); + $update->setParameter('id', (int)$row['id']); + $update->executeStatement(); + } + $result->closeCursor(); + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index cdd4d9fd..1e3ef75b 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -18,6 +18,7 @@ * visibleUsers: list, * visibleGroups: list, * visibleTeams: list, + * organizers: list, * calendarUri: ?string, * calendarEventUid: ?string, * seriesId: ?string, @@ -27,6 +28,13 @@ * cancelledAt: ?string, * responseDeadline: ?string, * } + * @psalm-type AttendanceMyPermissions = array{ + * isOrganizer: bool, + * canEdit: bool, + * canSeeResponses: bool, + * canSeeComments: bool, + * canSeeAuditLog: bool, + * } * @psalm-type AttendanceResponseData = array{ * id: int, * appointmentId: int, @@ -87,6 +95,7 @@ * visibleUsers: list, * visibleGroups: list, * visibleTeams: list, + * organizers: list, * calendarUri: ?string, * calendarEventUid: ?string, * seriesId: ?string, @@ -98,6 +107,7 @@ * userResponse: AttendanceResponseData|null, * responseSummary?: array, * attachments: list>, + * myPermissions: AttendanceMyPermissions, * } * @psalm-type AttendanceNavigationAppointment = array{ * id: int, @@ -132,6 +142,7 @@ * @psalm-type AttendancePermissionSettings = array> * @psalm-type AttendanceUserPermissions = array{ * canManageAppointments: bool, + * canCreateAppointments: bool, * canCheckin: bool, * canSeeResponseOverview: bool, * canSeeComments: bool, @@ -151,6 +162,7 @@ * guestInvitation: bool, * auditLog: bool, * selfCheckin: bool, + * organizers: bool, * } * @psalm-type AttendanceAuditUserRef = array{ * userId: string, diff --git a/lib/Service/AppointmentService.php b/lib/Service/AppointmentService.php index 573ad1b4..db9b73ff 100644 --- a/lib/Service/AppointmentService.php +++ b/lib/Service/AppointmentService.php @@ -37,6 +37,9 @@ class AppointmentService { private GuestService $guestService; private AuditEventService $auditEventService; private BookingService $bookingService; + private PermissionService $permissionService; + /** @var array per-request cache for isOrganizerAnywhere() */ + private array $organizerAnywhereCache = []; public function __construct( AppointmentMapper $appointmentMapper, @@ -53,6 +56,7 @@ public function __construct( GuestService $guestService, AuditEventService $auditEventService, BookingService $bookingService, + PermissionService $permissionService, ) { $this->appointmentMapper = $appointmentMapper; $this->responseMapper = $responseMapper; @@ -68,6 +72,7 @@ public function __construct( $this->guestService = $guestService; $this->auditEventService = $auditEventService; $this->bookingService = $bookingService; + $this->permissionService = $permissionService; } /** @@ -88,6 +93,7 @@ public function createAppointment( ?string $seriesId = null, ?int $seriesPosition = null, ?string $responseDeadline = null, + ?array $organizers = null, ): Appointment { $this->validateDateRange($startDatetime, $endDatetime); @@ -116,6 +122,14 @@ public function createAppointment( $appointment->setSeriesPosition($seriesPosition); $appointment->setSendNotification($sendNotification); $appointment->setResponseDeadline($deadlineFormatted); + // The creator freely chooses the initial organizer list; when the client + // sends nothing, the creator becomes the sole organizer. + $this->applyOrganizerChange( + $appointment, + $this->normalizeOrganizers($organizers ?? [$createdBy]), + $createdBy, + true, + ); $appointment = $this->appointmentMapper->insert($appointment); @@ -148,6 +162,7 @@ public function updateAppointment( array $visibleGroups = [], array $visibleTeams = [], ?DeadlineUpdate $deadlineUpdate = null, + ?array $organizers = null, ): Appointment { $appointment = $this->appointmentMapper->find($id); @@ -167,6 +182,15 @@ public function updateAppointment( $appointment->setVisibleGroups(empty($visibleGroups) ? null : json_encode($visibleGroups)); $appointment->setVisibleTeams(empty($visibleTeams) ? null : json_encode($visibleTeams)); + if ($organizers !== null) { + $this->applyOrganizerChange( + $appointment, + $this->normalizeOrganizers($organizers), + $userId, + $this->permissionService->canManageAppointments($userId), + ); + } + $deadlineUpdate ??= DeadlineUpdate::unchanged(); if ($deadlineUpdate->isClear()) { $appointment->setResponseDeadline(null); @@ -186,6 +210,46 @@ public function updateAppointment( return $updated; } + /** + * Normalize an organizer list: drop unknown users and guests, dedupe. + * Guests must never hold organizer rights (mirrors GUEST_BLOCKED_PERMISSIONS). + * + * @param array $organizers Raw user IDs from the client + * @return list + */ + private function normalizeOrganizers(array $organizers): array { + $normalized = []; + foreach ($organizers as $userId) { + $userId = (string)$userId; + if (isset($normalized[$userId])) { + continue; + } + if ($this->userManager->get($userId) === null || $this->guestService->isGuestUser($userId)) { + continue; + } + $normalized[$userId] = true; + } + return array_keys($normalized); + } + + /** + * Apply an already-normalized organizer list against the guardrails: + * managers may change the list freely; organizers may add anyone but + * remove only themselves. + * + * @param list $normalized Result of normalizeOrganizers() + * @throws \InvalidArgumentException When a non-manager removes someone else + */ + private function applyOrganizerChange(Appointment $appointment, array $normalized, string $actorId, bool $actorIsManager): void { + if (!$actorIsManager) { + $removed = array_diff($appointment->getOrganizersList(), $normalized); + if (array_diff($removed, [$actorId]) !== []) { + throw new \InvalidArgumentException('Organizers can only remove themselves from the organizer list.'); + } + } + $appointment->setOrganizers($normalized === [] ? null : json_encode($normalized)); + } + /** * Diff the audit-worthy fields of an appointment. Returns just the field * keys that changed — before/after values are intentionally not stored to @@ -214,6 +278,9 @@ private function computeAppointmentChanges(Appointment $before, Appointment $aft if ($before->getResponseDeadline() !== $after->getResponseDeadline()) { $fields[] = 'deadline'; } + if ($before->getOrganizersList() !== $after->getOrganizersList()) { + $fields[] = 'organizers'; + } return $fields; } @@ -356,6 +423,7 @@ public function deleteAppointment(int $id, string $userId): void { * @param list $visibleGroups Group IDs * @param list $visibleTeams Team IDs * @param ?DeadlineUpdate $deadlineUpdate Deadline change instruction. + * @param ?list $organizers New organizer list, or null to leave unchanged * @return list Updated appointments */ public function updateSeriesAppointments( @@ -370,6 +438,7 @@ public function updateSeriesAppointments( array $visibleGroups = [], array $visibleTeams = [], ?DeadlineUpdate $deadlineUpdate = null, + ?array $organizers = null, ): array { $deadlineUpdate ??= DeadlineUpdate::unchanged(); $reference = $this->appointmentMapper->find($referenceId); @@ -382,7 +451,7 @@ public function updateSeriesAppointments( $updated = $this->updateAppointment( $referenceId, $name, $description, $startDatetime, $endDatetime, $userId, $visibleUsers, $visibleGroups, $visibleTeams, - $deadlineUpdate, + $deadlineUpdate, $organizers, ); return [$updated]; } @@ -393,7 +462,7 @@ public function updateSeriesAppointments( $updated = $this->updateAppointment( $referenceId, $name, $description, $startDatetime, $endDatetime, $userId, $visibleUsers, $visibleGroups, $visibleTeams, - $deadlineUpdate, + $deadlineUpdate, $organizers, ); return [$updated]; } @@ -416,6 +485,13 @@ public function updateSeriesAppointments( $siblings = $this->appointmentMapper->findBySeriesId($seriesId); } + // Hoisted once for the whole series: the requested organizer list and + // the actor's manager status are identical for every sibling. + $actorIsManager = $this->permissionService->canManageAppointments($userId); + $normalizedOrganizers = $organizers === null ? null : $this->normalizeOrganizers($organizers); + + $this->assertCanManageSiblings($siblings, $userId, $actorIsManager); + $this->validateDateRange($startDatetime, $endDatetime); $descriptionClean = $this->stripHtmlFromMarkdown($description); @@ -454,6 +530,10 @@ public function updateSeriesAppointments( $sibling->setVisibleGroups($visibleGroupsJson); $sibling->setVisibleTeams($visibleTeamsJson); + if ($normalizedOrganizers !== null) { + $this->applyOrganizerChange($sibling, $normalizedOrganizers, $userId, $actorIsManager); + } + if ($deadlineUpdate->isClear()) { $sibling->setResponseDeadline(null); } elseif ($deadlineOffsetSeconds !== null) { @@ -512,6 +592,8 @@ public function deleteSeriesAppointments(int $referenceId, string $scope, string $siblings = $this->appointmentMapper->findBySeriesId($seriesId); } + $this->assertCanManageSiblings($siblings, $userId, $this->permissionService->canManageAppointments($userId)); + foreach ($siblings as $sibling) { $sibling->setIsActive(0); $sibling->setUpdatedAt(gmdate('Y-m-d H:i:s')); @@ -521,6 +603,25 @@ public function deleteSeriesAppointments(int $referenceId, string $scope, string return count($siblings); } + /** + * Series-wide operations touch appointments beyond the one the controller + * authorized. Managers may touch all of them; organizers only when they + * are organizer of every affected sibling. + * + * @param list $siblings + * @throws \InvalidArgumentException + */ + private function assertCanManageSiblings(array $siblings, string $userId, bool $actorIsManager): void { + if ($actorIsManager) { + return; + } + foreach ($siblings as $sibling) { + if (!$this->permissionService->isOrganizer($sibling, $userId)) { + throw new \InvalidArgumentException('You are not an organizer of all appointments in this series.'); + } + } + } + /** * Get a single appointment by ID. */ @@ -528,6 +629,16 @@ public function getAppointment(int $id): Appointment { return $this->appointmentMapper->find($id); } + /** + * Whether the user is organizer of at least one active appointment. + * Memoized per request — the user-search endpoint consults this per + * keystroke and the underlying LIKE query scans the appointments table. + */ + public function isOrganizerAnywhere(string $userId): bool { + return $this->organizerAnywhereCache[$userId] + ??= $this->appointmentMapper->existsWithOrganizer($userId); + } + /** * Get a single appointment with user response and summary. * @@ -544,6 +655,16 @@ public function getAppointmentWithUserResponse(int $id, string $userId, bool $in return null; } + // Organizers get the insights for their own appointment even without + // the global overview/comments permissions. + $myPermissions = $this->buildMyPermissions( + $appointment, + $userId, + $this->permissionService->canManageAppointments($userId), + $includeResponseSummary, + $includeComments, + ); + $appointmentData = $appointment->jsonSerialize(); $appointmentData = $this->enrichVisibilityData($appointmentData); $appointmentData = $this->enrichSeriesCount($appointmentData, $appointment); @@ -552,14 +673,54 @@ public function getAppointmentWithUserResponse(int $id, string $userId, bool $in // before they ever responded; treat that as "no response" (matches list endpoint). $userResponse = $this->getUserResponse($appointment->getId(), $userId); $appointmentData['userResponse'] = ($userResponse && $userResponse->getResponse() !== null) ? $userResponse : null; - if ($includeResponseSummary) { - $appointmentData['responseSummary'] = $this->responseSummaryService->getResponseSummary($appointment->getId(), $includeComments); + if ($myPermissions['canSeeResponses']) { + $appointmentData['responseSummary'] = $this->responseSummaryService->getResponseSummary($appointment->getId(), $myPermissions['canSeeComments']); } $appointmentData['attachments'] = $this->attachmentService->getAttachments($appointment->getId()); + $appointmentData['myPermissions'] = $myPermissions; return $appointmentData; } + /** + * Per-appointment permissions of the requesting user, shipped with every + * appointment payload so clients gate their UI on the appointment context + * instead of the global flags. + * + * Composes the same "global permission OR organizer" rules as + * PermissionService::canManageAppointment()/canSeeResponseOverviewFor(), + * but takes the global flags precomputed so the list endpoint does not + * redo the group lookups per appointment. + * + * @return array{isOrganizer: bool, canEdit: bool, canSeeResponses: bool, canSeeComments: bool, canSeeAuditLog: bool} + */ + private function buildMyPermissions( + Appointment $appointment, + string $userId, + bool $globalManage, + bool $globalSeeResponses, + bool $globalSeeComments, + ): array { + $isOrganizer = $this->permissionService->isOrganizer($appointment, $userId); + $canEdit = $globalManage || $isOrganizer; + $canSeeResponses = $globalSeeResponses || $isOrganizer; + + // Mirrors AuditController's gate — computed server-side because the + // audit visibility mode is admin config the clients don't know. + $canSeeAuditLog = $this->configService->isAuditLogEnabled() + && ($this->configService->getAuditLogVisibility() === ConfigService::AUDIT_LOG_VISIBILITY_ALL_WITH_OVERVIEW + ? $canSeeResponses + : $canEdit); + + return [ + 'isOrganizer' => $isOrganizer, + 'canEdit' => $canEdit, + 'canSeeResponses' => $canSeeResponses, + 'canSeeComments' => $globalSeeComments || $isOrganizer, + 'canSeeAuditLog' => $canSeeAuditLog, + ]; + } + /** * Get all appointments. */ @@ -801,6 +962,7 @@ public function getAppointmentsWithUserResponses( // every unanswered appointment in the system, which defeats the inbox. $onlyForMe = $onlyForMe || $unansweredOnly; + $globalManage = $this->permissionService->canManageAppointments($userId); $result = []; foreach ($appointments as $appointment) { @@ -820,14 +982,23 @@ public function getAppointmentsWithUserResponses( continue; } + $myPermissions = $this->buildMyPermissions( + $appointment, + $userId, + $globalManage, + $includeResponseSummary, + $includeComments, + ); + $appointmentData = $appointment->jsonSerialize(); $appointmentData = $this->enrichVisibilityData($appointmentData); $appointmentData = $this->enrichSeriesCount($appointmentData, $appointment); $appointmentData['userResponse'] = $hasResponse ? $userResponse : null; - if ($includeResponseSummary) { - $appointmentData['responseSummary'] = $this->responseSummaryService->getResponseSummary($appointment->getId(), $includeComments); + if ($myPermissions['canSeeResponses']) { + $appointmentData['responseSummary'] = $this->responseSummaryService->getResponseSummary($appointment->getId(), $myPermissions['canSeeComments']); } $appointmentData['attachments'] = $this->attachmentService->getAttachments($appointment->getId()); + $appointmentData['myPermissions'] = $myPermissions; $result[] = $appointmentData; } @@ -1222,13 +1393,8 @@ public function enrichVisibilityData(array $appointmentData): array { // Enrich visible users $enrichedUsers = []; foreach ($appointmentData['visibleUsers'] ?? [] as $userId) { - $user = $this->userManager->get($userId); - $enrichedUsers[] = [ - 'id' => $userId, - 'label' => $user ? $user->getDisplayName() : $userId, - 'type' => 'user', - 'isGuest' => $this->guestService->isGuestUser($userId), - ]; + $enrichedUsers[] = $this->enrichUserRef($userId) + + ['isGuest' => $this->guestService->isGuestUser($userId)]; } $appointmentData['visibleUsers'] = $enrichedUsers; @@ -1256,9 +1422,27 @@ public function enrichVisibilityData(array $appointmentData): array { } $appointmentData['visibleTeams'] = $enrichedTeams; + // Enrich organizers + $appointmentData['organizers'] = array_map( + fn (string $userId) => $this->enrichUserRef($userId), + $appointmentData['organizers'] ?? [], + ); + return $appointmentData; } + /** + * @return array{id: string, label: string, type: string} + */ + private function enrichUserRef(string $userId): array { + $user = $this->userManager->get($userId); + return [ + 'id' => $userId, + 'label' => $user ? $user->getDisplayName() : $userId, + 'type' => 'user', + ]; + } + /** * Strip HTML tags from markdown text to prevent stored XSS. * Preserves markdown formatting but removes raw HTML that users may embed. diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index 13841e01..9915bc6e 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -4,6 +4,7 @@ namespace OCA\Attendance\Service; +use OCA\Attendance\Db\Appointment; use OCP\IConfig; use OCP\IGroupManager; use OCP\IUserManager; @@ -23,10 +24,22 @@ class PermissionService { public const PERMISSION_SEE_RESPONSE_OVERVIEW = 'see_response_overview'; public const PERMISSION_SEE_COMMENTS = 'see_comments'; public const PERMISSION_SELF_CHECKIN = 'self_checkin'; + public const PERMISSION_CREATE_APPOINTMENTS = 'create_appointments'; private const GUEST_BLOCKED_PERMISSIONS = [ self::PERMISSION_MANAGE_APPOINTMENTS, self::PERMISSION_CHECKIN, + self::PERMISSION_CREATE_APPOINTMENTS, + ]; + + /** + * Permissions that grant NOBODY when no groups are configured. All other + * permissions default to "everyone" on empty config. Additive permissions + * (rights on top of manage_appointments) belong here — otherwise an + * upgrade would silently grant them to all users. + */ + private const CLOSED_WHEN_UNCONFIGURED = [ + self::PERMISSION_CREATE_APPOINTMENTS, ]; public function __construct( @@ -76,9 +89,8 @@ public function hasPermission(string $userId, string $permission): bool { $allowedRoles = $this->getRolesForPermission($permission); - // If no roles are configured, allow all users if (empty($allowedRoles)) { - return true; + return !in_array($permission, self::CLOSED_WHEN_UNCONFIGURED, true); } // Get user object and their groups @@ -131,6 +143,7 @@ public function getAllPermissionSettings(): array { self::PERMISSION_SEE_RESPONSE_OVERVIEW => $this->getRolesForPermission(self::PERMISSION_SEE_RESPONSE_OVERVIEW), self::PERMISSION_SEE_COMMENTS => $this->getRolesForPermission(self::PERMISSION_SEE_COMMENTS), self::PERMISSION_SELF_CHECKIN => $this->getRolesForPermission(self::PERMISSION_SELF_CHECKIN), + self::PERMISSION_CREATE_APPOINTMENTS => $this->getRolesForPermission(self::PERMISSION_CREATE_APPOINTMENTS), ]; } @@ -145,6 +158,7 @@ public function setAllPermissionSettings(array $permissions): void { 'PERMISSION_SEE_RESPONSE_OVERVIEW' => self::PERMISSION_SEE_RESPONSE_OVERVIEW, 'PERMISSION_SEE_COMMENTS' => self::PERMISSION_SEE_COMMENTS, 'PERMISSION_SELF_CHECKIN' => self::PERMISSION_SELF_CHECKIN, + 'PERMISSION_CREATE_APPOINTMENTS' => self::PERMISSION_CREATE_APPOINTMENTS, ]; foreach ($permissions as $permission => $roles) { @@ -157,6 +171,7 @@ public function setAllPermissionSettings(array $permissions): void { self::PERMISSION_SEE_RESPONSE_OVERVIEW, self::PERMISSION_SEE_COMMENTS, self::PERMISSION_SELF_CHECKIN, + self::PERMISSION_CREATE_APPOINTMENTS, ])) { $this->setRolesForPermission($permissionValue, $roles); } @@ -191,6 +206,37 @@ public function currentUserCanCheckin(): bool { return $this->currentUserHasPermission(self::PERMISSION_CHECKIN); } + /** + * Check if user can create appointments: either a full manager, or member + * of one of the create_appointments groups (delegated creation). Creators + * become organizers of their own appointments. + */ + public function canCreateAppointments(string $userId): bool { + return $this->canManageAppointments($userId) + || $this->hasPermission($userId, self::PERMISSION_CREATE_APPOINTMENTS); + } + + /** + * Check if a user is an organizer of the given appointment. Guests can + * never hold organizer rights, even if their ID ends up in the list. + */ + public function isOrganizer(Appointment $appointment, string $userId): bool { + if ($this->guestService->isGuestUser($userId)) { + return false; + } + return in_array($userId, $appointment->getOrganizersList(), true); + } + + /** + * Appointment-aware manage check: global managers plus organizers of this + * specific appointment. Organizers hold a fixed right set on their own + * appointment (edit everything, delete, insights) — see issue #73. + */ + public function canManageAppointment(string $userId, Appointment $appointment): bool { + return $this->canManageAppointments($userId) + || $this->isOrganizer($appointment, $userId); + } + /** * Check if user can see response overview */ @@ -198,6 +244,15 @@ public function canSeeResponseOverview(string $userId): bool { return $this->hasPermission($userId, self::PERMISSION_SEE_RESPONSE_OVERVIEW); } + /** + * Appointment-aware response overview check: the global permission plus + * organizers of this specific appointment (their "insights"). + */ + public function canSeeResponseOverviewFor(string $userId, Appointment $appointment): bool { + return $this->canSeeResponseOverview($userId) + || $this->isOrganizer($appointment, $userId); + } + /** * Check if user can see comments */ diff --git a/lib/Service/VisibilityService.php b/lib/Service/VisibilityService.php index c95d6571..6537492d 100644 --- a/lib/Service/VisibilityService.php +++ b/lib/Service/VisibilityService.php @@ -59,6 +59,12 @@ public function canUserSeeAppointment(Appointment $appointment, string $userId): return true; } + // Organizers always see their own appointment, even when they are not + // part of the visibility target audience. + if ($this->permissionService->isOrganizer($appointment, $userId)) { + return true; + } + return $this->isUserTargetAttendee($appointment, $userId); } diff --git a/openapi-administration.json b/openapi-administration.json index 59a7aca7..dd679a34 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -182,7 +182,8 @@ "responseToggle", "guestInvitation", "auditLog", - "selfCheckin" + "selfCheckin", + "organizers" ], "properties": { "calendarAvailable": { @@ -223,6 +224,9 @@ }, "selfCheckin": { "type": "boolean" + }, + "organizers": { + "type": "boolean" } } }, diff --git a/openapi-full.json b/openapi-full.json index c5fab92d..0f632424 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -182,6 +182,7 @@ "visibleUsers", "visibleGroups", "visibleTeams", + "organizers", "calendarUri", "calendarEventUid", "seriesId", @@ -239,6 +240,12 @@ "type": "string" } }, + "organizers": { + "type": "array", + "items": { + "type": "string" + } + }, "calendarUri": { "type": "string", "nullable": true @@ -288,6 +295,7 @@ "visibleUsers", "visibleGroups", "visibleTeams", + "organizers", "calendarUri", "calendarEventUid", "seriesId", @@ -297,7 +305,8 @@ "cancelledAt", "responseDeadline", "userResponse", - "attachments" + "attachments", + "myPermissions" ], "properties": { "id": { @@ -347,6 +356,28 @@ "type": "string" } }, + "organizers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "label", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + }, "calendarUri": { "type": "string", "nullable": true @@ -401,6 +432,9 @@ "type": "object" } } + }, + "myPermissions": { + "$ref": "#/components/schemas/MyPermissions" } } }, @@ -573,7 +607,8 @@ "responseToggle", "guestInvitation", "auditLog", - "selfCheckin" + "selfCheckin", + "organizers" ], "properties": { "calendarAvailable": { @@ -614,6 +649,9 @@ }, "selfCheckin": { "type": "boolean" + }, + "organizers": { + "type": "boolean" } } }, @@ -733,6 +771,33 @@ } } }, + "MyPermissions": { + "type": "object", + "required": [ + "isOrganizer", + "canEdit", + "canSeeResponses", + "canSeeComments", + "canSeeAuditLog" + ], + "properties": { + "isOrganizer": { + "type": "boolean" + }, + "canEdit": { + "type": "boolean" + }, + "canSeeResponses": { + "type": "boolean" + }, + "canSeeComments": { + "type": "boolean" + }, + "canSeeAuditLog": { + "type": "boolean" + } + } + }, "NavigationAppointment": { "type": "object", "required": [ @@ -1227,6 +1292,7 @@ "type": "object", "required": [ "canManageAppointments", + "canCreateAppointments", "canCheckin", "canSeeResponseOverview", "canSeeComments", @@ -1236,6 +1302,9 @@ "canManageAppointments": { "type": "boolean" }, + "canCreateAppointments": { + "type": "boolean" + }, "canCheckin": { "type": "boolean" }, @@ -1444,6 +1513,14 @@ "nullable": true, "default": null, "description": "Optional response deadline (ISO 8601). Cron auto-closes the inquiry once passed." + }, + "organizers": { + "type": "array", + "default": [], + "description": "User IDs to set as organizers; empty defaults to the creator", + "items": { + "type": "string" + } } } } @@ -1578,6 +1655,14 @@ "type": "integer", "format": "int64" } + }, + "organizers": { + "type": "array", + "default": [], + "description": "User IDs to set as organizers on all created appointments; empty defaults to the creator", + "items": { + "type": "string" + } } } } @@ -2025,6 +2110,15 @@ "nullable": true, "default": null, "description": "Optional response deadline (ISO 8601). Empty string clears it; null leaves unchanged." + }, + "organizers": { + "type": "array", + "nullable": true, + "default": null, + "description": "New organizer list, or null to leave unchanged. Organizers may add anyone but remove only themselves; managers may change freely.", + "items": { + "type": "string" + } } } } @@ -2406,7 +2500,7 @@ "/index.php/apps/attendance/api/appointments/{id}/responses": { "get": { "operationId": "appointment-get-responses", - "summary": "Get detailed responses for an appointment (requires manage appointments permission)", + "summary": "Get detailed responses for an appointment (requires manage appointments permission or being an organizer of it)", "tags": [ "appointment" ], @@ -2494,6 +2588,24 @@ } } } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } } } } @@ -4125,7 +4237,7 @@ "get": { "operationId": "appointment-get-user-settings", "summary": "Get personal user settings", - "description": "Response-change notification toggle is only included for users who can actually receive these notifications (manage_appointments + audit log enabled). Frontend uses the key's presence to decide whether to render the toggle at all.", + "description": "Response-change notification toggle is only included for users who can actually receive these notifications (managers, users allowed to create appointments, or organizers of at least one appointment — with audit log enabled). Frontend uses the key's presence to decide whether to render the toggle at all.", "tags": [ "appointment" ], diff --git a/openapi.json b/openapi.json index 64ff891c..27e4caf6 100644 --- a/openapi.json +++ b/openapi.json @@ -35,6 +35,7 @@ "visibleUsers", "visibleGroups", "visibleTeams", + "organizers", "calendarUri", "calendarEventUid", "seriesId", @@ -92,6 +93,12 @@ "type": "string" } }, + "organizers": { + "type": "array", + "items": { + "type": "string" + } + }, "calendarUri": { "type": "string", "nullable": true @@ -141,6 +148,7 @@ "visibleUsers", "visibleGroups", "visibleTeams", + "organizers", "calendarUri", "calendarEventUid", "seriesId", @@ -150,7 +158,8 @@ "cancelledAt", "responseDeadline", "userResponse", - "attachments" + "attachments", + "myPermissions" ], "properties": { "id": { @@ -200,6 +209,28 @@ "type": "string" } }, + "organizers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "label", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + }, "calendarUri": { "type": "string", "nullable": true @@ -254,6 +285,9 @@ "type": "object" } } + }, + "myPermissions": { + "$ref": "#/components/schemas/MyPermissions" } } }, @@ -426,7 +460,8 @@ "responseToggle", "guestInvitation", "auditLog", - "selfCheckin" + "selfCheckin", + "organizers" ], "properties": { "calendarAvailable": { @@ -467,6 +502,9 @@ }, "selfCheckin": { "type": "boolean" + }, + "organizers": { + "type": "boolean" } } }, @@ -552,6 +590,33 @@ } } }, + "MyPermissions": { + "type": "object", + "required": [ + "isOrganizer", + "canEdit", + "canSeeResponses", + "canSeeComments", + "canSeeAuditLog" + ], + "properties": { + "isOrganizer": { + "type": "boolean" + }, + "canEdit": { + "type": "boolean" + }, + "canSeeResponses": { + "type": "boolean" + }, + "canSeeComments": { + "type": "boolean" + }, + "canSeeAuditLog": { + "type": "boolean" + } + } + }, "NavigationAppointment": { "type": "object", "required": [ @@ -1006,6 +1071,7 @@ "type": "object", "required": [ "canManageAppointments", + "canCreateAppointments", "canCheckin", "canSeeResponseOverview", "canSeeComments", @@ -1015,6 +1081,9 @@ "canManageAppointments": { "type": "boolean" }, + "canCreateAppointments": { + "type": "boolean" + }, "canCheckin": { "type": "boolean" }, @@ -1223,6 +1292,14 @@ "nullable": true, "default": null, "description": "Optional response deadline (ISO 8601). Cron auto-closes the inquiry once passed." + }, + "organizers": { + "type": "array", + "default": [], + "description": "User IDs to set as organizers; empty defaults to the creator", + "items": { + "type": "string" + } } } } @@ -1357,6 +1434,14 @@ "type": "integer", "format": "int64" } + }, + "organizers": { + "type": "array", + "default": [], + "description": "User IDs to set as organizers on all created appointments; empty defaults to the creator", + "items": { + "type": "string" + } } } } @@ -1804,6 +1889,15 @@ "nullable": true, "default": null, "description": "Optional response deadline (ISO 8601). Empty string clears it; null leaves unchanged." + }, + "organizers": { + "type": "array", + "nullable": true, + "default": null, + "description": "New organizer list, or null to leave unchanged. Organizers may add anyone but remove only themselves; managers may change freely.", + "items": { + "type": "string" + } } } } @@ -2185,7 +2279,7 @@ "/index.php/apps/attendance/api/appointments/{id}/responses": { "get": { "operationId": "appointment-get-responses", - "summary": "Get detailed responses for an appointment (requires manage appointments permission)", + "summary": "Get detailed responses for an appointment (requires manage appointments permission or being an organizer of it)", "tags": [ "appointment" ], @@ -2273,6 +2367,24 @@ } } } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } } } } @@ -3904,7 +4016,7 @@ "get": { "operationId": "appointment-get-user-settings", "summary": "Get personal user settings", - "description": "Response-change notification toggle is only included for users who can actually receive these notifications (manage_appointments + audit log enabled). Frontend uses the key's presence to decide whether to render the toggle at all.", + "description": "Response-change notification toggle is only included for users who can actually receive these notifications (managers, users allowed to create appointments, or organizers of at least one appointment — with audit log enabled). Frontend uses the key's presence to decide whether to render the toggle at all.", "tags": [ "appointment" ], diff --git a/src/App.vue b/src/App.vue index 93793ad1..5b6e77d8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -141,7 +141,7 @@