diff --git a/README.md b/README.md index f3a29abe..0f95a362 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,62 @@ # Attendance -A Nextcloud app for managing event attendance with advance RSVP functionality. Administrators can create appointments and track responses, while users can easily indicate their attendance status and optional comment on their attendance. +A Nextcloud app for knowing who is coming. Collect replies up front, check people in on the day, and see every response broken down by your Nextcloud groups. Free, open source, and running entirely on your own server. + +[Website](https://anwesenheit.app) · [Nextcloud App Store](https://apps.nextcloud.com/apps/attendance) · [Documentation](https://anwesenheit.app/docs) + +## Mobile companion app + +There is a companion app for iOS and Android. It talks to your own Nextcloud and adds the two things the web app cannot do: + +- **Self-check-in by QR code or NFC** — print one QR code for the whole instance or stick an NFC tag next to the door. People scan or tap as they arrive and the attendance list fills itself. Every self-check-in is recorded in the activity history with its source. +- **Push notifications** — new appointments and reminders reach the phone straight away, without anyone opening Nextcloud first. + +It also puts appointments, replies and comments on the phone, and holds several Nextcloud accounts in one app. + +[App Store](https://apps.apple.com/app/id6759988681) · [Google Play](https://play.google.com/store/apps/details?id=de.krautnerds.attendance) · [What the app does](https://anwesenheit.app/mobile) + +The mobile app is free for the first 30 days per Nextcloud instance, then one yearly subscription covers the whole instance. **This Nextcloud app stays free and open source** — everything below works without the mobile app. ## Features -### For Administrators -- **Create & Manage Appointments:** Set up events with name, description, and date/time -- **Track Responses:** View detailed attendance summaries organized by user groups -- **See Non-Responders:** Identify who hasn't responded yet to follow up -- **Flexible Views:** Toggle between upcoming and past appointments -- **Check-in Management:** Track actual attendance during events with dedicated check-in interface -- **Bulk Operations:** Mark multiple users as present/absent with bulk check-in actions -- **Admin Comments:** Add check-in specific comments for attendance tracking -- **Group Whitelisting:** Configure which user groups are included in attendance statistics and check-in lists - -### For All Users -- **Easy RSVP:** Respond with Yes/No/Maybe to any appointment -- **Add Comments:** Include optional notes with your response (especially for Maybe/No responses) -- **Update Anytime:** Change your response until the event date -- **Dashboard Widget:** Quick access to upcoming appointments directly from your dashboard -- **Check-in View:** Dedicated interface for administrators to track actual attendance during events - -### Group-Based Organization -- **Group Summaries:** Responses are automatically organized by nextcloud user groups -- **Missing Responses:** See which user group members haven't responded yet -- **Admin Overview:** Complete visibility into attendance across all groups -- **Filtered Check-in:** Search and filter users by name or group during check-in process +### Collecting replies +- **One-click RSVP:** Yes, No or Maybe, with an optional comment +- **Changed plans:** update or withdraw a reply any time before the appointment +- **Quick-reply links:** answer straight from the notification email via a signed token, no login needed +- **Dashboard widget:** reply from the Nextcloud dashboard without opening the app +- **Guests without an account:** invite people by email address through the Nextcloud Guests app (see below) +- **Reminders:** automatic nudges for everyone who has not replied yet, plus manual reminders + +### Organising appointments +- **Recurring appointments:** daily, weekly or monthly series created in one go; edit or delete this one, this and following, or all of them +- **Calendar import:** pull events out of Nextcloud Calendar, with optional automatic sync (Nextcloud 32+) +- **Closing and deadlines:** close an inquiry by hand, on a deadline, or automatically once it starts +- **Cancel and reactivate:** cancelling is a state of its own, separate from closing +- **Scheduling:** mark the yes-repliers you actually need and notify exactly those people on closing (instance-wide opt-in, off by default) +- **Attachments:** attach files from Nextcloud Files, also exposed as links in the calendar feed +- **Visibility:** restrict an appointment to individual users, groups or Nextcloud Teams (Circles) +- **Copy:** duplicate an appointment with all of its settings + +### Overview and reporting +- **Response summary by group:** Nextcloud groups and Teams as separate sections, each with its own counts +- **Check-in:** record actual attendance, with bulk actions, search, filters and a note per person +- **Unanswered:** a dedicated section showing only what still waits for a reply +- **Activity history:** every reply, change, withdrawal and check-in with who, when and from where (web, email link, admin check-in, QR or NFC) +- **Export:** all appointment data as an ODS spreadsheet +- **Calendar subscription:** a personal iCal feed for Google Calendar, Apple Calendar, Outlook or Thunderbird, with token regeneration + +### Administration +- **Six group-based permissions:** manage appointments, create own appointments, check-in access, see response and check-in summary, see comments, self-check-in +- **Response summary groups:** choose which groups and Teams appear as sections (this also scopes the check-in list) +- **Reminder settings:** days before the appointment and repeat frequency +- **Self-check-in settings:** which groups may self-check-in, how wide the check-in window is, plus the instance QR code and the NFC URL +- **Calendar sync:** keep imported appointments in step with the source event (Nextcloud 32+) + +## Requirements + +- Nextcloud 31 to 34 (32 or newer for calendar sync) +- PHP 8.1 or newer +- The Notifications app enabled, for reminders and notifications ## Installation diff --git a/appinfo/info.xml b/appinfo/info.xml index 1045016f..6e935ac0 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -4,92 +4,129 @@ attendance Attendance Anwesenheit - Event attendance management with advance RSVP functionality, real-time check-in, and group-based statistics - Veranstaltungsanwesenheitsverwaltung mit Vorab-RSVP-Funktionalität, Echtzeit-Check-in und gruppenbasierten Statistiken - - - https://raw.githubusercontent.com/luflow/attendance/refs/heads/main/appinfo/screens/screen.jpg - https://raw.githubusercontent.com/luflow/attendance/refs/heads/main/appinfo/screens/admin.jpg - https://raw.githubusercontent.com/luflow/attendance/refs/heads/main/appinfo/screens/checkin.jpg + Collect replies, check people in and see attendance by group – now with a companion app for iOS and Android + Zu- und Absagen einsammeln, Anwesenheit abhaken, nach Gruppen auswerten – jetzt mit App für iOS und Android + + 1.41.2 AGPL-3.0-or-later Florian Ludwig Attendance dashboard organization - - notification - https://anwesenheit.app https://github.com/luflow/attendance/issues https://github.com/luflow/attendance + https://raw.githubusercontent.com/luflow/attendance/refs/heads/main/appinfo/screens/screen.jpg + https://raw.githubusercontent.com/luflow/attendance/refs/heads/main/appinfo/screens/appointments.jpg + https://raw.githubusercontent.com/luflow/attendance/refs/heads/main/appinfo/screens/admin.jpg + https://raw.githubusercontent.com/luflow/attendance/refs/heads/main/appinfo/screens/checkin.jpg https://donate.stripe.com/fZu4gzd1k9AqgHgdWv7ss00 + + + OCA\Attendance\Repair\EnsureAppointmentSchema + + + + OCA\Attendance\Settings\AdminSettings + OCA\Attendance\Settings\AdminSection + OCA\Attendance\Settings\PersonalSettings + OCA\Attendance\Settings\PersonalSection + attendance @@ -100,15 +137,4 @@ Perfekt für Organisationen, Teams und Gemeinschaften, die die Anwesenheit bei M link - - OCA\Attendance\Settings\AdminSettings - OCA\Attendance\Settings\AdminSection - OCA\Attendance\Settings\PersonalSettings - OCA\Attendance\Settings\PersonalSection - - - - OCA\Attendance\Repair\EnsureAppointmentSchema - - diff --git a/appinfo/screens/admin-small.jpg b/appinfo/screens/admin-small.jpg index 521d6310..c8a4bb8e 100644 Binary files a/appinfo/screens/admin-small.jpg and b/appinfo/screens/admin-small.jpg differ diff --git a/appinfo/screens/admin.jpg b/appinfo/screens/admin.jpg index 9cf0af19..0ef4fb6a 100644 Binary files a/appinfo/screens/admin.jpg and b/appinfo/screens/admin.jpg differ diff --git a/appinfo/screens/appointments-small.jpg b/appinfo/screens/appointments-small.jpg new file mode 100644 index 00000000..09643da2 Binary files /dev/null and b/appinfo/screens/appointments-small.jpg differ diff --git a/appinfo/screens/appointments.jpg b/appinfo/screens/appointments.jpg new file mode 100644 index 00000000..1491a03b Binary files /dev/null and b/appinfo/screens/appointments.jpg differ diff --git a/appinfo/screens/checkin-small.jpg b/appinfo/screens/checkin-small.jpg index b8bc1a52..d9d51c0b 100644 Binary files a/appinfo/screens/checkin-small.jpg and b/appinfo/screens/checkin-small.jpg differ diff --git a/appinfo/screens/checkin.jpg b/appinfo/screens/checkin.jpg index 04e30447..ce4bdd25 100644 Binary files a/appinfo/screens/checkin.jpg and b/appinfo/screens/checkin.jpg differ diff --git a/appinfo/screens/screen-small.jpg b/appinfo/screens/screen-small.jpg index e03965e3..9249aadf 100644 Binary files a/appinfo/screens/screen-small.jpg and b/appinfo/screens/screen-small.jpg differ diff --git a/appinfo/screens/screen.jpg b/appinfo/screens/screen.jpg index 77a7e289..3df61a94 100644 Binary files a/appinfo/screens/screen.jpg and b/appinfo/screens/screen.jpg differ diff --git a/l10n/de.js b/l10n/de.js index 7d05bde7..bc1cf0c9 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -135,7 +135,7 @@ OC.L10N.register( "Ask each time" : "Jedes Mal fragen", "Attendees scan this code with the Attendance app to check in. One code works for all appointments." : "Teilnehmer scannen diesen Code mit der Anwesenheits-App, um einzuchecken. Ein Code gilt für alle Termine.", "Bring it to your team" : "Teile es mit deinem Team", - "Cancelled on {when}" : "Abgebrochen am {when}", + "Cancelled on {when}" : "Abgesagt am {when}", "Check in" : "Check-in", "Check-in failed" : "Check-in fehlgeschlagen", "Check-in opens at {time}" : "Der Check-in öffnet um {time}", @@ -260,7 +260,7 @@ OC.L10N.register( "Failed to export appointment" : "Termin konnte nicht exportiert werden", "Export appointment" : "Termin exportieren", "Reactivate appointment" : "Termin reaktivieren", - "Cancel appointment" : "Termin abbrechen", + "Cancel appointment" : "Termin absagen", "{count} attended" : "{count} anwesend", "{count} absent" : "{count} abwesend", "{count} pending" : "{count} ausstehend", @@ -643,7 +643,6 @@ OC.L10N.register( "Reminders before appointment" : "Erinnerungen vor einem Termin", "No reminders" : "Keine Erinnerungen", "Response notifications" : "Antwortbenachrichtigungen", - "Get notified when team members respond to appointments. Only available for users who can manage appointments." : "Benachrichtigungen erhalten, sobald Teammitglieder auf Termine reagieren. Nur für Benutzer verfügbar, die Termine verwalten können.", "Receive notifications for response changes" : "Benachrichtigungen über Antwortänderungen erhalten", "Unable to process response" : "Antwort konnte nicht verarbeitet werden", "Open Attendance app" : "Anwesenheits-App öffnen", @@ -700,6 +699,14 @@ OC.L10N.register( "{actor} cancelled the appointment" : "{actor} hat diesen Termin abgesagt", "{actor} reactivated the appointment" : "{actor} hat diesen Termin reaktiviert", "{actor} performed {verb}" : "{actor} hat {verb} durchgeführt", - "Guests" : "Gäste" + "Guests" : "Gäste", + "Create own appointments" : "Eigene Termine erstellen", + "Get notified when team members respond to appointments. Only available for users who can manage or organize appointments." : "Erhalte Benachrichtigungen, wenn Teammitglieder auf Termine antworten. Nur verfügbar für Benutzer, die Termine verwalten oder organisieren können.", + "Groups that can create appointments and manage them as organizers, in addition to the groups above. If no group is selected, only the groups above can create appointments." : "Gruppen, die zusätzlich zu den obigen Gruppen Termine erstellen und als Organisatoren verwalten können. Wenn keine Gruppe ausgewählt ist, können nur die obigen Gruppen Termine erstellen.", + "Organized by {names}" : "Organisiert von {names}", + "Organizers" : "Organisatoren", + "Organizers can edit this appointment and see all responses. Organizers can add other organizers but only remove themselves." : "Organisatoren können diesen Termin bearbeiten und alle Antworten sehen. Organisatoren können weitere Organisatoren hinzufügen, aber nur sich selbst entfernen.", + "Search users …" : "Benutzer suchen …", + "Some users may appear in the section \"Others\" in the response summary because they are not configured for tracking." : "Einige Benutzer erscheinen möglicherweise im Abschnitt \"Andere\" der Antwortübersicht, da sie nicht für die Nachverfolgung eingerichtet sind." }, "nplurals=2; plural=(n != 1);"); diff --git a/l10n/de.json b/l10n/de.json index 9ada4f24..e02882cd 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -133,7 +133,7 @@ "Ask each time" : "Jedes Mal fragen", "Attendees scan this code with the Attendance app to check in. One code works for all appointments." : "Teilnehmer scannen diesen Code mit der Anwesenheits-App, um einzuchecken. Ein Code gilt für alle Termine.", "Bring it to your team" : "Teile es mit deinem Team", - "Cancelled on {when}" : "Abgebrochen am {when}", + "Cancelled on {when}" : "Abgesagt am {when}", "Check in" : "Check-in", "Check-in failed" : "Check-in fehlgeschlagen", "Check-in opens at {time}" : "Der Check-in öffnet um {time}", @@ -258,7 +258,7 @@ "Failed to export appointment" : "Termin konnte nicht exportiert werden", "Export appointment" : "Termin exportieren", "Reactivate appointment" : "Termin reaktivieren", - "Cancel appointment" : "Termin abbrechen", + "Cancel appointment" : "Termin absagen", "{count} attended" : "{count} anwesend", "{count} absent" : "{count} abwesend", "{count} pending" : "{count} ausstehend", @@ -641,7 +641,6 @@ "Reminders before appointment" : "Erinnerungen vor einem Termin", "No reminders" : "Keine Erinnerungen", "Response notifications" : "Antwortbenachrichtigungen", - "Get notified when team members respond to appointments. Only available for users who can manage appointments." : "Benachrichtigungen erhalten, sobald Teammitglieder auf Termine reagieren. Nur für Benutzer verfügbar, die Termine verwalten können.", "Receive notifications for response changes" : "Benachrichtigungen über Antwortänderungen erhalten", "Unable to process response" : "Antwort konnte nicht verarbeitet werden", "Open Attendance app" : "Anwesenheits-App öffnen", @@ -698,6 +697,14 @@ "{actor} cancelled the appointment" : "{actor} hat diesen Termin abgesagt", "{actor} reactivated the appointment" : "{actor} hat diesen Termin reaktiviert", "{actor} performed {verb}" : "{actor} hat {verb} durchgeführt", - "Guests" : "Gäste" + "Guests" : "Gäste", + "Create own appointments" : "Eigene Termine erstellen", + "Get notified when team members respond to appointments. Only available for users who can manage or organize appointments." : "Erhalte Benachrichtigungen, wenn Teammitglieder auf Termine antworten. Nur verfügbar für Benutzer, die Termine verwalten oder organisieren können.", + "Groups that can create appointments and manage them as organizers, in addition to the groups above. If no group is selected, only the groups above can create appointments." : "Gruppen, die zusätzlich zu den obigen Gruppen Termine erstellen und als Organisatoren verwalten können. Wenn keine Gruppe ausgewählt ist, können nur die obigen Gruppen Termine erstellen.", + "Organized by {names}" : "Organisiert von {names}", + "Organizers" : "Organisatoren", + "Organizers can edit this appointment and see all responses. Organizers can add other organizers but only remove themselves." : "Organisatoren können diesen Termin bearbeiten und alle Antworten sehen. Organisatoren können weitere Organisatoren hinzufügen, aber nur sich selbst entfernen.", + "Search users …" : "Benutzer suchen …", + "Some users may appear in the section \"Others\" in the response summary because they are not configured for tracking." : "Einige Benutzer erscheinen möglicherweise im Abschnitt \"Andere\" der Antwortübersicht, da sie nicht für die Nachverfolgung eingerichtet sind." },"pluralForm" :"nplurals=2; plural=(n != 1);" } \ No newline at end of file diff --git a/l10n/de_DE.js b/l10n/de_DE.js index 978cc2bb..291fdf35 100644 --- a/l10n/de_DE.js +++ b/l10n/de_DE.js @@ -135,7 +135,7 @@ OC.L10N.register( "Ask each time" : "Jedes Mal fragen", "Attendees scan this code with the Attendance app to check in. One code works for all appointments." : "Teilnehmer scannen diesen Code mit der Anwesenheits-App, um einzuchecken. Ein Code gilt für alle Termine.", "Bring it to your team" : "Teile es mit Ihrem Team", - "Cancelled on {when}" : "Abgebrochen am {when}", + "Cancelled on {when}" : "Abgesagt am {when}", "Check in" : "Einchecken", "Check-in failed" : "Check-in fehlgeschlagen", "Check-in opens at {time}" : "Der Check-in öffnet um {time}", @@ -260,7 +260,7 @@ OC.L10N.register( "Failed to export appointment" : "Termin konnte nicht exportiert werden", "Export appointment" : "Termin exportieren", "Reactivate appointment" : "Termin reaktivieren", - "Cancel appointment" : "Termin abbrechen", + "Cancel appointment" : "Termin absagen", "{count} attended" : "{count} anwesend", "{count} absent" : "{count} abwesend", "{count} pending" : "{count} ausstehend", @@ -643,7 +643,6 @@ OC.L10N.register( "Reminders before appointment" : "Erinnerungen vor einem Termin", "No reminders" : "Keine Erinnerungen", "Response notifications" : "Antwortbenachrichtigungen", - "Get notified when team members respond to appointments. Only available for users who can manage appointments." : "Benachrichtigungen erhalten, sobald Teammitglieder auf Termine reagieren. Nur für Benutzer verfügbar, die Termine verwalten können.", "Receive notifications for response changes" : "Benachrichtigungen über Antwortänderungen erhalten", "Unable to process response" : "Antwort konnte nicht verarbeitet werden", "Open Attendance app" : "Anwesenheit-App öffnen", @@ -700,6 +699,14 @@ OC.L10N.register( "{actor} cancelled the appointment" : "{actor} hat diesen Termin abgesagt", "{actor} reactivated the appointment" : "{actor} hat diesen Termin reaktiviert", "{actor} performed {verb}" : "{actor} hat {verb} durchgeführt", - "Guests" : "Gäste" + "Guests" : "Gäste", + "Create own appointments" : "Eigene Termine erstellen", + "Get notified when team members respond to appointments. Only available for users who can manage or organize appointments." : "Erhalte Benachrichtigungen, wenn Teammitglieder auf Termine antworten. Nur verfügbar für Benutzer, die Termine verwalten oder organisieren können.", + "Groups that can create appointments and manage them as organizers, in addition to the groups above. If no group is selected, only the groups above can create appointments." : "Gruppen, die zusätzlich zu den obigen Gruppen Termine erstellen und als Organisatoren verwalten können. Wenn keine Gruppe ausgewählt ist, können nur die obigen Gruppen Termine erstellen.", + "Organized by {names}" : "Organisiert von {names}", + "Organizers" : "Organisatoren", + "Organizers can edit this appointment and see all responses. Organizers can add other organizers but only remove themselves." : "Organisatoren können diesen Termin bearbeiten und alle Antworten sehen. Organisatoren können weitere Organisatoren hinzufügen, aber nur sich selbst entfernen.", + "Search users …" : "Benutzer suchen …", + "Some users may appear in the section \"Others\" in the response summary because they are not configured for tracking." : "Einige Benutzer erscheinen möglicherweise im Abschnitt \"Andere\" der Antwortübersicht, da sie nicht für die Nachverfolgung eingerichtet sind." }, "nplurals=2; plural=(n != 1);"); diff --git a/l10n/de_DE.json b/l10n/de_DE.json index 1b8feac4..3dfd6766 100644 --- a/l10n/de_DE.json +++ b/l10n/de_DE.json @@ -133,7 +133,7 @@ "Ask each time" : "Jedes Mal fragen", "Attendees scan this code with the Attendance app to check in. One code works for all appointments." : "Teilnehmer scannen diesen Code mit der Anwesenheits-App, um einzuchecken. Ein Code gilt für alle Termine.", "Bring it to your team" : "Teile es mit Ihrem Team", - "Cancelled on {when}" : "Abgebrochen am {when}", + "Cancelled on {when}" : "Abgesagt am {when}", "Check in" : "Einchecken", "Check-in failed" : "Check-in fehlgeschlagen", "Check-in opens at {time}" : "Der Check-in öffnet um {time}", @@ -258,7 +258,7 @@ "Failed to export appointment" : "Termin konnte nicht exportiert werden", "Export appointment" : "Termin exportieren", "Reactivate appointment" : "Termin reaktivieren", - "Cancel appointment" : "Termin abbrechen", + "Cancel appointment" : "Termin absagen", "{count} attended" : "{count} anwesend", "{count} absent" : "{count} abwesend", "{count} pending" : "{count} ausstehend", @@ -641,7 +641,6 @@ "Reminders before appointment" : "Erinnerungen vor einem Termin", "No reminders" : "Keine Erinnerungen", "Response notifications" : "Antwortbenachrichtigungen", - "Get notified when team members respond to appointments. Only available for users who can manage appointments." : "Benachrichtigungen erhalten, sobald Teammitglieder auf Termine reagieren. Nur für Benutzer verfügbar, die Termine verwalten können.", "Receive notifications for response changes" : "Benachrichtigungen über Antwortänderungen erhalten", "Unable to process response" : "Antwort konnte nicht verarbeitet werden", "Open Attendance app" : "Anwesenheit-App öffnen", @@ -698,6 +697,14 @@ "{actor} cancelled the appointment" : "{actor} hat diesen Termin abgesagt", "{actor} reactivated the appointment" : "{actor} hat diesen Termin reaktiviert", "{actor} performed {verb}" : "{actor} hat {verb} durchgeführt", - "Guests" : "Gäste" + "Guests" : "Gäste", + "Create own appointments" : "Eigene Termine erstellen", + "Get notified when team members respond to appointments. Only available for users who can manage or organize appointments." : "Erhalte Benachrichtigungen, wenn Teammitglieder auf Termine antworten. Nur verfügbar für Benutzer, die Termine verwalten oder organisieren können.", + "Groups that can create appointments and manage them as organizers, in addition to the groups above. If no group is selected, only the groups above can create appointments." : "Gruppen, die zusätzlich zu den obigen Gruppen Termine erstellen und als Organisatoren verwalten können. Wenn keine Gruppe ausgewählt ist, können nur die obigen Gruppen Termine erstellen.", + "Organized by {names}" : "Organisiert von {names}", + "Organizers" : "Organisatoren", + "Organizers can edit this appointment and see all responses. Organizers can add other organizers but only remove themselves." : "Organisatoren können diesen Termin bearbeiten und alle Antworten sehen. Organisatoren können weitere Organisatoren hinzufügen, aber nur sich selbst entfernen.", + "Search users …" : "Benutzer suchen …", + "Some users may appear in the section \"Others\" in the response summary because they are not configured for tracking." : "Einige Benutzer erscheinen möglicherweise im Abschnitt \"Andere\" der Antwortübersicht, da sie nicht für die Nachverfolgung eingerichtet sind." },"pluralForm" :"nplurals=2; plural=(n != 1);" } \ No newline at end of file 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 @@