Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions lib/Controller/AppointmentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -190,15 +190,6 @@ public function index(
$canSeeResponseCounts,
);

// Add checkin summary to each appointment the user may see at least
// the aggregate numbers for — it only ever carries counts, no names.
foreach ($page['appointments'] as &$appointment) {
if ($appointment['myPermissions']['canSeeResponseCounts']) {
$appointment['checkinSummary'] = $this->checkinService->getCheckinSummary($appointment['id']);
}
}
unset($appointment);

return new DataResponse($query->isPaged() ? $page : $page['appointments']);
}

Expand Down Expand Up @@ -799,12 +790,6 @@ public function show(int $id): DataResponse {
return new DataResponse(['error' => 'Appointment not found or not visible'], 404);
}

// Add checkin summary if user may see at least the aggregate
// numbers for this appointment — it only ever carries counts.
if ($appointment['myPermissions']['canSeeResponseCounts']) {
$appointment['checkinSummary'] = $this->checkinService->getCheckinSummary($id);
}

return new DataResponse($appointment);
} catch (\Exception $e) {
return new DataResponse(['error' => 'Appointment not found'], 404);
Expand Down
38 changes: 27 additions & 11 deletions lib/Db/AppointmentAttachmentMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;

/**
Expand Down Expand Up @@ -34,20 +35,35 @@ public function find(int $id): AppointmentAttachment {
}

/**
* @param int $appointmentId
* @return array
* @return list<AppointmentAttachment>
*/
public function findByAppointment(int $appointmentId): array {
$qb = $this->db->getQueryBuilder();

$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('appointment_id', $qb->createNamedParameter($appointmentId))
)
->orderBy('added_at', 'ASC');
return $this->findByAppointments([$appointmentId])[$appointmentId] ?? [];
}

return $this->findEntities($qb);
/**
* The attachments of these appointments, bucketed by appointment, oldest first.
*
* @param list<int> $appointmentIds
* @return array<int, list<AppointmentAttachment>>
*/
public function findByAppointments(array $appointmentIds): array {
$byAppointment = [];

foreach (array_chunk($appointmentIds, 1000) as $chunk) {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->in('appointment_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)))
->orderBy('added_at', 'ASC')
->addOrderBy('id', 'ASC');

foreach ($this->findEntities($qb) as $attachment) {
$byAppointment[$attachment->getAppointmentId()][] = $attachment;
}
}

return $byAppointment;
}

/**
Expand Down
31 changes: 31 additions & 0 deletions lib/Db/AppointmentMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,37 @@ public function findBySeriesId(string $seriesId): array {
return $this->findEntities($qb);
}

/**
* How many active appointments each series has, for the series a page of
* the list touches — the count is all the list shows.
*
* @param list<string> $seriesIds
* @return array<string, int> series id → count
*/
public function countBySeriesIds(array $seriesIds): array {
$counts = [];

foreach (array_chunk($seriesIds, 1000) as $chunk) {
$qb = $this->db->getQueryBuilder();
$qb->select('series_id')
->selectAlias($qb->createFunction('COUNT(*)'), 'count')
->from($this->getTableName())
->where($qb->expr()->eq('is_active', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->in('series_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY)))
->groupBy('series_id');

$result = $qb->executeQuery();
/** @var list<array<string, mixed>> $raw IResult::fetchAll() is declared mixed */
$raw = $result->fetchAll();
$result->closeCursor();
foreach ($raw as $row) {
$counts[(string)$row['series_id']] = (int)$row['count'];
}
}

return $counts;
}

/**
* Find active appointments in a series from a given position onward.
*
Expand Down
67 changes: 56 additions & 11 deletions lib/Db/AttendanceResponseMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,20 +83,65 @@ public function findByUserForAppointments(string $userId, array $appointmentIds)
}

/**
* @param int $appointmentId
* @return list<AttendanceResponse>
* Every response of these appointments, bucketed by appointment, the
* latest answer first within each bucket.
*
* @param list<int> $appointmentIds
* @return array<int, list<AttendanceResponse>>
*/
public function findByAppointment(int $appointmentId): array {
$qb = $this->db->getQueryBuilder();
public function findByAppointments(array $appointmentIds): array {
$byAppointment = [];

$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('appointment_id', $qb->createNamedParameter($appointmentId))
)
->orderBy('responded_at', 'DESC');
foreach (array_chunk($appointmentIds, 1000) as $chunk) {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->in('appointment_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)))
->orderBy('responded_at', 'DESC')
->addOrderBy('id', 'ASC');

return $this->findEntities($qb);
foreach ($this->findEntities($qb) as $response) {
$byAppointment[$response->getAppointmentId()][] = $response;
}
}

return $byAppointment;
}

/**
* Who a manager booked, per appointment — the one column the scheduling
* filters of the list read, for every appointment of the list at once.
*
* @param list<int> $appointmentIds
* @return array<int, list<string>> appointment id → booked user ids
*/
public function findBookedUserIds(array $appointmentIds): array {
$byAppointment = [];

foreach (array_chunk($appointmentIds, 1000) as $chunk) {
$qb = $this->db->getQueryBuilder();
$qb->select('appointment_id', 'user_id')
->from($this->getTableName())
->where($qb->expr()->eq('booking_status', $qb->createNamedParameter(BookingService::STATUS_BOOKED)))
->andWhere($qb->expr()->in('appointment_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)));

$result = $qb->executeQuery();
/** @var list<array<string, mixed>> $raw IResult::fetchAll() is declared mixed */
$raw = $result->fetchAll();
$result->closeCursor();
foreach ($raw as $row) {
$byAppointment[(int)$row['appointment_id']][] = (string)$row['user_id'];
}
}

return $byAppointment;
}

/**
* @return list<AttendanceResponse>
*/
public function findByAppointment(int $appointmentId): array {
return $this->findByAppointments([$appointmentId])[$appointmentId] ?? [];
}

/**
Expand Down
61 changes: 31 additions & 30 deletions lib/Notification/Notifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ class Notifier implements INotifier {
private IUserManager $userManager;
private EventMessages $eventMessages;

/** @var array<int, bool> */
private array $maybeOffered = [];
private ?string $iconUrl = null;

public function __construct(
IFactory $l10nFactory,
IURLGenerator $urlGenerator,
Expand Down Expand Up @@ -115,9 +119,7 @@ public function prepare(INotification $notification, string $languageCode): INot
// TRANSLATORS Push notification body for a reminder, shown under a "Response missing" subject that already names the appointment and its date. The app speaks as "we", the people organizing — not about them in the third person. People who cannot come tend to stay silent rather than answer, which leaves the planning guessing, hence the second sentence. Sample German: "Wir warten noch auf dich. Ein Nein hilft uns genauso wie ein Ja.".
$l->t('We are still waiting. A no helps just as much as a yes.')
);
$notification->setIcon($this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath('attendance', 'app-dark.svg')
));
$notification->setIcon($this->iconUrl());

// Add quick response action buttons
if ($appointmentId > 0) {
Expand All @@ -140,9 +142,7 @@ public function prepare(INotification $notification, string $languageCode): INot
// TRANSLATORS Push notification body for a newly created appointment, shown under a "New appointment" subject that already names it and its date. The app speaks as "we", the people organizing — not about them in the third person. "Make it" means being able to attend. Organizers only switch this notification on when they need answers fast, so keep the urgency of the first sentence: it is the whole reason the message was sent. Sample German: "Wir brauchen schnell deine Rückmeldung. Bist du dabei?".
$l->t('We need your answer quickly. Let us know whether you can make it.')
);
$notification->setIcon($this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath('attendance', 'app-dark.svg')
));
$notification->setIcon($this->iconUrl());

// Add quick response action buttons
if ($appointmentId > 0) {
Expand All @@ -154,18 +154,14 @@ public function prepare(INotification $notification, string $languageCode): INot
$rendered = $this->eventMessages->forCancelled($l, $notification->getSubjectParameters(), $notification->getUser());
$notification->setParsedSubject($rendered['subject']);
$notification->setParsedMessage($rendered['message']);
$notification->setIcon($this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath('attendance', 'app-dark.svg')
));
$notification->setIcon($this->iconUrl());

return $notification;
case 'appointment_reactivated':
$rendered = $this->eventMessages->forReactivated($l, $notification->getSubjectParameters(), $notification->getUser());
$notification->setParsedSubject($rendered['subject']);
$notification->setParsedMessage($rendered['message']);
$notification->setIcon($this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath('attendance', 'app-dark.svg')
));
$notification->setIcon($this->iconUrl());

return $notification;
case 'appointment_updated':
Expand All @@ -182,9 +178,7 @@ public function prepare(INotification $notification, string $languageCode): INot
$notification->setParsedMessage(
$this->eventMessages->describeUpdate((array)($parameters['changed'] ?? []), $l)
);
$notification->setIcon($this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath('attendance', 'app-dark.svg')
));
$notification->setIcon($this->iconUrl());

// A moved appointment is exactly when someone needs to revise their answer.
if ($appointmentId > 0) {
Expand All @@ -199,9 +193,7 @@ public function prepare(INotification $notification, string $languageCode): INot
: $this->eventMessages->forBookingDeclined($l, $notification->getSubjectParameters(), $notification->getUser());
$notification->setParsedSubject($rendered['subject']);
$notification->setParsedMessage($rendered['message']);
$notification->setIcon($this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath('attendance', 'app-dark.svg')
));
$notification->setIcon($this->iconUrl());
return $notification;
case 'waitlist_promoted':
case 'waitlist_not_promoted':
Expand All @@ -227,9 +219,7 @@ public function prepare(INotification $notification, string $languageCode): INot
$l->t('The appointment stayed full, so you no longer need to keep the date free. Thanks for answering.')
);
}
$notification->setIcon($this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath('attendance', 'app-dark.svg')
));
$notification->setIcon($this->iconUrl());
return $notification;
case 'response_submitted':
case 'response_changed':
Expand All @@ -249,18 +239,14 @@ public function prepare(INotification $notification, string $languageCode): INot
// TRANSLATORS Push notification body for several appointments added at once, shown under a subject that already gives the number and one example name. The app speaks as "we", the people organizing — not about them in the third person. The recipient answers each appointment separately, which is why this says "which ones" rather than asking for a single answer. Sample German: "Sag uns bitte, bei welchen Terminen du dabei bist.".
$l->t('Let us know which ones you can make.')
);
$notification->setIcon($this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath('attendance', 'app-dark.svg')
));
$notification->setIcon($this->iconUrl());

return $notification;
case 'appointments_series_updated':
$rendered = $this->eventMessages->forSeriesUpdated($l, $notification->getSubjectParameters());
$notification->setParsedSubject($rendered['subject']);
$notification->setParsedMessage($rendered['message']);
$notification->setIcon($this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath('attendance', 'app-dark.svg')
));
$notification->setIcon($this->iconUrl());

return $notification;
default:
Expand Down Expand Up @@ -345,9 +331,7 @@ private function prepareResponseChangeNotification(INotification $notification,
}

$notification->setParsedSubject($subject);
$notification->setIcon($this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath('attendance', 'app-dark.svg')
));
$notification->setIcon($this->iconUrl());
return $notification;
}

Expand All @@ -370,6 +354,16 @@ private function restoreObjectForClientCompatibility(INotification $notification
}
}

/**
* The same for every notification of the app, while imagePath() walks the
* theme folders (or asks the memcache) on every call.
*/
private function iconUrl(): string {
return $this->iconUrl ??= $this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath('attendance', 'app-dark.svg'),
);
}

/**
* Notifications carry raw user IDs; resolving them here rather than at send
* time keeps renamed accounts correct. Unknown accounts keep the ID.
Expand Down Expand Up @@ -403,8 +397,15 @@ private function translateResponseValue(string $value, \OCP\IL10N $l): string {
* Whether this appointment still offers "Maybe". A notification can outlive
* the appointment it points at, and an unknown one keeps all three buttons
* rather than silently dropping one.
*
* Remembered per appointment: a wave prepares one notification per
* recipient, and all of them ask about the same appointment.
*/
private function isMaybeOffered(int $appointmentId): bool {
return $this->maybeOffered[$appointmentId] ??= $this->lookupMaybeOffered($appointmentId);
}

private function lookupMaybeOffered(int $appointmentId): bool {
try {
return $this->responsePolicyService->isMaybeAllowed(
$this->appointmentMapper->find($appointmentId),
Expand Down
11 changes: 5 additions & 6 deletions lib/Service/AppointmentSerializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,11 @@ public function serialize(Appointment $appointment): array {
/** @var array<string, mixed> $data */
$data = $appointment->jsonSerialize();
$data['allowMaybe'] = $this->responsePolicyService->isMaybeAllowed($appointment);
// Only an appointment with a limit pays for the queue query, which is
// why every reader asks limitOf() first. Without one there is nothing to
// count and nothing that can be full.
$hasLimit = $this->capacityService->limitOf($appointment) !== null;
$data['occupancy'] = $hasLimit ? $this->capacityService->occupancy($appointment) : 0;
$data['isFull'] = $hasLimit && $this->capacityService->isFull($appointment);
// Handed on to isFull() so the queue is counted once; without a limit
// occupancy() answers 0 without a query.
$occupancy = $this->capacityService->occupancy($appointment);
$data['occupancy'] = $occupancy;
$data['isFull'] = $this->capacityService->isFull($appointment, $occupancy);
return $data;
}
}
Loading
Loading