From 453ec956426f3b60bc3b232b60c6f75aa0c0a384 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Tue, 19 May 2026 22:53:53 +0200 Subject: [PATCH] feat: add notifications Signed-off-by: Luka Trovic --- appinfo/info.xml | 1 + appinfo/routes.php | 4 + lib/Activity/ActivityManager.php | 600 +++++++++++++-- lib/Activity/Filter.php | 2 +- lib/Activity/SettingChanges.php | 2 +- lib/Activity/SettingSharing.php | 90 +++ lib/Activity/TablesProvider.php | 84 ++- lib/AppInfo/Application.php | 3 + lib/Controller/ConfigController.php | 50 ++ lib/Notification/NotificationHelper.php | 702 ++++++++++++++++++ lib/Notification/Notifier.php | 300 ++++++++ lib/Service/ColumnService.php | 60 +- lib/Service/ConfigService.php | 132 ++++ lib/Service/RowService.php | 24 + lib/Service/ShareService.php | 77 +- lib/Service/ViewService.php | 27 + .../e2e/tables-import-export-scheme.spec.ts | 2 +- playwright/e2e/tables-table.spec.ts | 12 +- .../e2e/view-filtering-selection.spec.ts | 2 +- playwright/support/commands.ts | 2 +- src/modules/main/sections/Dashboard.vue | 4 +- src/modules/main/sections/DataTable.vue | 4 +- src/modules/main/sections/View.vue | 2 +- src/modules/modals/EditRow.vue | 2 +- src/modules/modals/EditTable.vue | 16 +- src/modules/modals/ViewSettings.vue | 17 +- .../modals/sections/NotificationsSettings.vue | 263 +++++++ .../partials/NavigationTableItem.vue | 9 +- .../partials/NavigationViewItem.vue | 32 +- src/modules/sidebar/sections/Sidebar.vue | 6 +- .../sidebar/sections/SidebarActivity.vue | 4 +- src/shared/components/ActivityList.vue | 16 +- 32 files changed, 2441 insertions(+), 110 deletions(-) create mode 100644 lib/Activity/SettingSharing.php create mode 100644 lib/Controller/ConfigController.php create mode 100644 lib/Notification/NotificationHelper.php create mode 100644 lib/Notification/Notifier.php create mode 100644 lib/Service/ConfigService.php create mode 100644 src/modules/modals/sections/NotificationsSettings.vue diff --git a/appinfo/info.xml b/appinfo/info.xml index 4f05ced258..51e1c3b75b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -75,6 +75,7 @@ Have a good time and manage whatever you want. OCA\Tables\Activity\SettingChanges + OCA\Tables\Activity\SettingSharing OCA\Tables\Activity\Filter diff --git a/appinfo/routes.php b/appinfo/routes.php index 3578a6f8f3..2cf6c67889 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -154,5 +154,9 @@ ['name' => 'Context#updateContentOrder', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PUT'], ['name' => 'RowOCS#createRow', 'url' => '/api/2/{nodeCollection}/{nodeId}/rows', 'verb' => 'POST', 'requirements' => ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)']], + + ['name' => 'Config#getTableConfig', 'url' => '/api/2/config/table/{id}', 'verb' => 'GET'], + ['name' => 'Config#getViewConfig', 'url' => '/api/2/config/view/{id}', 'verb' => 'GET'], + ['name' => 'Config#setValue', 'url' => '/api/2/config/{key}', 'verb' => 'POST'], ] ]; diff --git a/lib/Activity/ActivityManager.php b/lib/Activity/ActivityManager.php index 7ec0070972..0c130967ee 100644 --- a/lib/Activity/ActivityManager.php +++ b/lib/Activity/ActivityManager.php @@ -9,10 +9,15 @@ namespace OCA\Tables\Activity; use OCA\Tables\AppInfo\Application; +use OCA\Tables\Constants\ShareReceiverType; +use OCA\Tables\Db\Column; use OCA\Tables\Db\ColumnMapper; use OCA\Tables\Db\Row2; +use OCA\Tables\Db\Share; use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; +use OCA\Tables\Db\View; +use OCA\Tables\Db\ViewMapper; use OCA\Tables\Service\ShareService; use OCP\Activity\IEvent; use OCP\Activity\IManager; @@ -23,7 +28,9 @@ class ActivityManager { public const TABLES_OBJECT_TABLE = 'tables_table'; + public const TABLES_OBJECT_VIEW = 'tables_view'; public const TABLES_OBJECT_ROW = 'tables_row'; + public const TABLES_OBJECT_COLUMN = 'tables_column'; public const SUBJECT_TABLE_CREATE = 'table_create'; public const SUBJECT_TABLE_UPDATE = 'table_update'; @@ -31,16 +38,32 @@ class ActivityManager { public const SUBJECT_TABLE_UPDATE_DESCRIPTION = 'table_update_description'; public const SUBJECT_TABLE_DELETE = 'table_delete'; + public const SUBJECT_VIEW_CREATE = 'view_create'; + public const SUBJECT_VIEW_UPDATE = 'view_update'; + public const SUBJECT_VIEW_UPDATE_TITLE = 'view_update_title'; + public const SUBJECT_VIEW_UPDATE_DESCRIPTION = 'view_update_description'; + public const SUBJECT_VIEW_DELETE = 'view_delete'; + public const SUBJECT_ROW_CREATE = 'row_create'; public const SUBJECT_ROW_UPDATE = 'row_update'; public const SUBJECT_ROW_DELETE = 'row_delete'; + public const SUBJECT_ROW_ASSIGN = 'row_assign'; + + public const SUBJECT_COLUMN_CREATE = 'column_create'; + public const SUBJECT_COLUMN_UPDATE = 'column_update'; + public const SUBJECT_COLUMN_DELETE = 'column_delete'; public const SUBJECT_IMPORT_FINISHED = 'import_finished'; + public const SUBJECT_SHARE_CREATE = 'share_create'; + public const SUBJECT_SHARE_UPDATE = 'share_update'; + public const SUBJECT_SHARE_DELETE = 'share_delete'; + public function __construct( private readonly IManager $manager, private readonly IFactory $l10nFactory, private readonly TableMapper $tableMapper, + private readonly ViewMapper $viewMapper, private readonly ColumnMapper $columnMapper, private readonly ShareService $shareService, private readonly ?string $userId, @@ -104,9 +127,15 @@ private function createEvent($objectType, $object, $subject, $additionalParams = if ($object instanceof Table) { $objectTitle = $object->getTitle(); $table = $object; + } elseif ($object instanceof View) { + $objectTitle = $object->getTitle(); + $table = $this->tableMapper->find($object->getTableId()); } elseif ($object instanceof Row2) { $objectTitle = '#' . $object->getId(); $table = $this->tableMapper->find($object->getTableId()); + } elseif ($object instanceof Column) { + $objectTitle = $object->getTitle(); + $table = $this->tableMapper->find($object->getTableId()); } else { Server::get(LoggerInterface::class)->error('Could not create activity entry for ' . $subject . '. Invalid object.', (array)$object); return null; @@ -119,24 +148,47 @@ private function createEvent($objectType, $object, $subject, $additionalParams = $eventType = 'tables'; $subjectParams = [ 'author' => $author === null ? $this->userId : $author, - 'table' => $table + 'table' => $table, + 'objectType' => $objectType, ]; + if ($object instanceof View) { + $subjectParams['view'] = $object; + } switch ($subject) { // No need to enhance parameters since entity already contains the required data case self::SUBJECT_TABLE_CREATE: + case self::SUBJECT_TABLE_UPDATE: case self::SUBJECT_TABLE_DELETE: + case self::SUBJECT_VIEW_CREATE: + case self::SUBJECT_VIEW_UPDATE: + case self::SUBJECT_VIEW_DELETE: break; case self::SUBJECT_TABLE_UPDATE_DESCRIPTION: + case self::SUBJECT_VIEW_UPDATE_DESCRIPTION: $subjectParams['after'] = $additionalParams['after'] ?? null; break; case self::SUBJECT_TABLE_UPDATE_TITLE: + case self::SUBJECT_VIEW_UPDATE_TITLE: $subjectParams['before'] = $additionalParams['before'] ?? null; break; case self::SUBJECT_ROW_CREATE: case self::SUBJECT_ROW_UPDATE: case self::SUBJECT_ROW_DELETE: + $eventType = 'tables_row_table'; $subjectParams['row'] = $object; break; + case self::SUBJECT_COLUMN_CREATE: + case self::SUBJECT_COLUMN_UPDATE: + case self::SUBJECT_COLUMN_DELETE: + $eventType = 'tables_column_table'; + $subjectParams['column'] = $object; + break; + case self::SUBJECT_SHARE_CREATE: + case self::SUBJECT_SHARE_UPDATE: + case self::SUBJECT_SHARE_DELETE: + $eventType = 'tables_sharing'; + $subjectParams['sharedWith'] = $this->buildSharedWithParam($additionalParams['share'] ?? []); + break; case self::SUBJECT_IMPORT_FINISHED: $subjectParams['importStats'] = $additionalParams['importStats'] ?? null; break; @@ -181,85 +233,535 @@ private function createEvent($objectType, $object, $subject, $additionalParams = return $event; } + /** + * @param mixed $share + * @return array{id: string, name: string, type: string} + */ + private function buildSharedWithParam(mixed $share): array { + if ($share instanceof Share) { + $receiverId = $share->getReceiver(); + $receiverType = $share->getReceiverType(); + $receiverName = $share->getReceiverDisplayName() ?: $receiverId; + + if ($receiverType === 'link') { + $receiverName = 'public link'; + $receiverId = 'link'; + } + + return [ + 'id' => (string)$receiverId, + 'name' => (string)$receiverName, + 'type' => (string)$receiverType, + ]; + } + + if (is_array($share)) { + $receiverType = isset($share['receiverType']) ? (string)$share['receiverType'] : 'unknown'; + $receiverId = isset($share['receiver']) ? (string)$share['receiver'] : 'unknown'; + $receiverName = isset($share['receiverDisplayName']) ? (string)$share['receiverDisplayName'] : $receiverId; + + if ($receiverType === 'link') { + $receiverName = 'public link'; + $receiverId = 'link'; + } + + return [ + 'id' => $receiverId, + 'name' => $receiverName, + 'type' => $receiverType, + ]; + } + + return [ + 'id' => 'unknown', + 'name' => 'unknown', + 'type' => 'unknown', + ]; + } + private function sendToUsers(IEvent $event, $object) { + // Handle share events with restricted recipients + $subject = $event->getSubject(); + if (in_array($subject, [self::SUBJECT_SHARE_CREATE, self::SUBJECT_SHARE_UPDATE, self::SUBJECT_SHARE_DELETE], true)) { + $this->sendShareEventToUsers($event, $object); + return; + } + if ($object instanceof Table) { $tableId = $object->getId(); $owner = $object->getOwnership(); + $sharedUserIds = $this->shareService->findSharedWithUserIds($tableId, 'table'); + } elseif ($object instanceof View) { + $tableId = $object->getTableId(); + $owner = $object->getOwnership() ?? $this->tableMapper->find($tableId)->getOwnership(); + $sharedUserIds = $this->shareService->findSharedWithUserIds($object->getId(), 'view'); } elseif ($object instanceof Row2) { $tableId = $object->getTableId(); $owner = $this->tableMapper->find($tableId)->getOwnership(); + $sharedUserIds = $this->shareService->findSharedWithUserIds($tableId, 'table'); + } elseif ($object instanceof Column) { + $tableId = $object->getTableId(); + $owner = $this->tableMapper->find($tableId)->getOwnership(); + $sharedUserIds = $this->shareService->findSharedWithUserIds($tableId, 'table'); } else { Server::get(LoggerInterface::class)->error('Could not send activity notify. Invalid object.', (array)$object); - return null; + return; } - $event->setAffectedUser($owner); - $this->manager->publish($event); + $uniqueAffectedUser = array_unique([...$sharedUserIds, $owner]); - foreach ($this->shareService->findSharedWithUserIds($tableId, 'table') as $userId) { + foreach ($uniqueAffectedUser as $userId) { $event->setAffectedUser($userId); /** @noinspection DisconnectedForeachInstructionInspection */ $this->manager->publish($event); } + + if ($object instanceof Row2) { + $this->sendRowEventToViewAccessibleUsers(clone $event, $object); + } + + if ($object instanceof Column) { + $this->sendColumnEventToViewAccessibleUsers(clone $event, $object); + } + } + + private function sendRowEventToViewAccessibleUsers(IEvent $event, Row2 $object) { + $subject = $event->getSubject(); + $subjectParams = $event->getSubjectParameters(); + $event->setType('tables_row_view'); + $subjectParams['isViewContext'] = true; + + $changedColumnIds = []; + $tableId = $object->getTableId(); + $owner = $this->tableMapper->find($tableId)->getOwnership(); + + if ($event->getSubject() === self::SUBJECT_ROW_UPDATE) { + foreach ($event->getSubjectParameters()['changeCols'] ?? [] as $changeCol) { + $changedColumnIds[] = $changeCol['id']; + } + } + + foreach ($this->viewMapper->findAll($tableId) as $view) { + $viewSharedWithUsers = $this->shareService->findSharedWithUserIds($view->getId(), 'view'); + $viewContainsChangedColumn = empty($changedColumnIds) + || !empty(array_intersect($changedColumnIds, $view->getColumnIds())); + + // Only send update events for views that contain changed columns + if (!$viewContainsChangedColumn) { + continue; + } + + $uniqueAffectedUser = array_unique([...$viewSharedWithUsers, $owner]); + + foreach ($uniqueAffectedUser as $userId) { + $subjectParams['view'] = $view; + $event->setSubject($subject, $subjectParams); + $event->setAffectedUser($userId); + $this->manager->publish($event); + } + } + } + + private function sendColumnEventToViewAccessibleUsers(IEvent $event, Column $object): void { + $subject = $event->getSubject(); + $subjectParams = $event->getSubjectParameters(); + $event->setType('tables_column_view'); + $subjectParams['isViewContext'] = true; + + $tableId = $object->getTableId(); + $owner = $this->tableMapper->find($tableId)->getOwnership(); + $affectedViews = $this->getAffectedViewsForColumn($object); + + foreach ($affectedViews as $view) { + $viewSharedWithUsers = $this->shareService->findSharedWithUserIds($view->getId(), 'view'); + $uniqueAffectedUser = array_unique([...$viewSharedWithUsers, $owner]); + + foreach ($uniqueAffectedUser as $userId) { + $subjectParams['view'] = $view; + $event->setSubject($subject, $subjectParams); + $event->setAffectedUser($userId); + $this->manager->publish($event); + } + } + } + + /** + * @return list + */ + public function getAffectedViewsForColumn(Column $column): array { + $affectedViews = []; + + foreach ($this->viewMapper->findAll($column->getTableId()) as $view) { + if (in_array($column->getId(), $view->getColumnIds(), true)) { + $affectedViews[] = $view; + } + } + + return $affectedViews; + } + + /** + * Send share events only to: event author, table owner, and shared-with receiver users. + */ + private function sendShareEventToUsers(IEvent $event, $object): void { + if (!$object instanceof Table && !$object instanceof View) { + Server::get(LoggerInterface::class)->error('Could not send share activity. Invalid object type.', (array)$object); + return; + } + + $tableId = $object instanceof Table ? $object->getId() : $object->getTableId(); + $table = $object instanceof Table ? $object : $this->tableMapper->find($tableId); + $tableOwner = $table->getOwnership(); + $eventAuthor = $event->getAuthor(); + $sharedWithParam = $event->getSubjectParameters()['sharedWith'] ?? null; + + $recipients = []; + + // Always notify table owner + $recipients[$tableOwner] = true; + + // Always notify event author (if different from table owner) + if ($eventAuthor) { + $recipients[$eventAuthor] = true; + } + + // Notify shared-with users for user/group/team/circle receivers + if ($sharedWithParam && isset($sharedWithParam['type'], $sharedWithParam['id'])) { + $receiverType = (string)$sharedWithParam['type']; + $receiverId = (string)$sharedWithParam['id']; + + if ($receiverType === ShareReceiverType::USER) { + $recipients[$receiverId] = true; + } elseif ($receiverType === ShareReceiverType::GROUP || $receiverType === ShareReceiverType::CIRCLE) { + foreach ($this->shareService->findUserIdsForShareReceiver($receiverType, $receiverId) as $userId) { + $recipients[$userId] = true; + } + } + } + + foreach (array_keys($recipients) as $userId) { + $this->setShareSubjectForRecipient($event, $userId); + $event->setAffectedUser($userId); + + /** @noinspection DisconnectedForeachInstructionInspection */ + $this->manager->publish($event); + } + } + + private function setShareSubjectForRecipient(IEvent $event, string $recipientUserId): void { + $subject = $event->getSubject(); + $subjectParams = $event->getSubjectParameters(); + + $sharedWith = $subjectParams['sharedWith'] ?? null; + $isDirectSharedWithUser = is_array($sharedWith) + && ($sharedWith['type'] ?? null) === ShareReceiverType::USER + && isset($sharedWith['id']) + && (string)$sharedWith['id'] === $recipientUserId; + + if ($isDirectSharedWithUser) { + $subjectParams['sharedWithYou'] = true; + } else { + unset($subjectParams['sharedWithYou']); + } + + $event->setSubject($subject, $subjectParams); + } + + /** + * @param array $subjectParams + * @return list + */ + private function getVisibleChangeCols(array $subjectParams): array { + $changeCols = []; + foreach ($subjectParams['changeCols'] ?? [] as $changeCol) { + if (!is_array($changeCol) + || !isset($changeCol['id'], $changeCol['name'], $changeCol['before'], $changeCol['after']) + || !is_int($changeCol['id']) + || !is_string($changeCol['name'])) { + continue; + } + + $changeCols[] = [ + 'id' => $changeCol['id'], + 'name' => $changeCol['name'], + 'before' => $changeCol['before'], + 'after' => $changeCol['after'], + ]; + } + + // @Note: old data might not have the view parameter, in that case we assume the table is accessible and show all changed columns + if (!isset($subjectParams['view'])) { + return $changeCols; + } + + $viewColumnIds = $this->getViewColumnIds($subjectParams['view']); + if (empty($viewColumnIds)) { + return $changeCols; + } + + $visibleChangeCols = []; + foreach ($changeCols as $changeCol) { + if (in_array($changeCol['id'], $viewColumnIds, true)) { + $visibleChangeCols[] = $changeCol; + } + } + + return $visibleChangeCols; + } + + /** + * @param mixed $view + * @return list|null + */ + private function getViewColumnIds(mixed $view): ?array { + if ($view instanceof View) { + return $view->getColumnIds(); + } + + if (!is_array($view) || empty($view['columnSettings']) || !is_array($view['columnSettings'])) { + return null; + } + + $columnSettings = $view['columnSettings']; + $columnIds = []; + + foreach ($columnSettings as $columnSetting) { + if (!is_array($columnSetting) || !isset($columnSetting['columnId']) || !is_int($columnSetting['columnId'])) { + continue; + } + + $columnIds[] = $columnSetting['columnId']; + } + + return $columnIds; } public function getActivitySubject($language, $subjectIdentifier, $subjectParams = [], $ownActivity = false) { - $subject = ''; $l = $this->l10nFactory->get(Application::APP_ID, $language); + $isViewContext = $subjectParams['isViewContext'] ?? false; + $isViewObject = ($subjectParams['objectType'] ?? null) === self::TABLES_OBJECT_VIEW; + $sharedWith = $subjectParams['sharedWith'] ?? null; + + $subject = $this->formatTableActivity($l, $subjectIdentifier, $ownActivity); + if ($subject !== null) { + return $subject; + } + + $subject = $this->formatViewActivity($l, $subjectIdentifier, $ownActivity); + if ($subject !== null) { + return $subject; + } + + $subject = $this->formatRowActivity($l, $subjectIdentifier, $subjectParams, $ownActivity, $isViewContext); + if ($subject !== null) { + return $subject; + } + + $subject = $this->formatColumnActivity($l, $subjectIdentifier, $ownActivity, $isViewContext); + if ($subject !== null) { + return $subject; + } + + $subject = $this->formatShareActivity($l, $subjectIdentifier, $subjectParams, $ownActivity, $isViewObject, $sharedWith); + if ($subject !== null) { + return $subject; + } + + if ($subjectIdentifier === self::SUBJECT_IMPORT_FINISHED) { + return $ownActivity ? $l->t('You have imported file to table {table}') : $l->t('{user} has imported file to table {table}'); + } + + return ''; + } + private function formatTableActivity($l, string $subjectIdentifier, bool $ownActivity): ?string { + return match ($subjectIdentifier) { + self::SUBJECT_TABLE_CREATE => $ownActivity ? $l->t('You have created a new table {table}') : $l->t('{user} has created a new table {table}'), + self::SUBJECT_TABLE_UPDATE => $ownActivity ? $l->t('You have updated the table {table}') : $l->t('{user} has updated the table {table}'), + self::SUBJECT_TABLE_DELETE => $ownActivity ? $l->t('You have deleted the table {table}') : $l->t('{user} has deleted the table {table}'), + self::SUBJECT_TABLE_UPDATE_TITLE => $ownActivity ? $l->t('You have renamed the table {before} to {table}') : $l->t('{user} has renamed the table {before} to {table}'), + self::SUBJECT_TABLE_UPDATE_DESCRIPTION => $ownActivity ? $l->t('You have updated the description of table {table} to {after}') : $l->t('{user} has updated the description of table {table} to {after}'), + default => null, + }; + } + + private function formatViewActivity($l, string $subjectIdentifier, bool $ownActivity): ?string { + return match ($subjectIdentifier) { + self::SUBJECT_VIEW_CREATE => $ownActivity ? $l->t('You have created a new view {view} in table {table}') : $l->t('{user} has created a new view {view} in table {table}'), + self::SUBJECT_VIEW_UPDATE => $ownActivity ? $l->t('You have updated the view {view} in table {table}') : $l->t('{user} has updated the view {view} in table {table}'), + self::SUBJECT_VIEW_DELETE => $ownActivity ? $l->t('You have deleted the view {view} from table {table}') : $l->t('{user} has deleted the view {view} from table {table}'), + self::SUBJECT_VIEW_UPDATE_TITLE => $ownActivity ? $l->t('You have renamed the view {before} to {view} in table {table}') : $l->t('{user} has renamed the view {before} to {view} in table {table}'), + self::SUBJECT_VIEW_UPDATE_DESCRIPTION => $ownActivity ? $l->t('You have updated the description of view {view} to {after} in table {table}') : $l->t('{user} has updated the description of view {view} to {after} in table {table}'), + default => null, + }; + } + + private function formatRowActivity($l, string $subjectIdentifier, array $subjectParams, bool $ownActivity, bool $isViewContext): ?string { switch ($subjectIdentifier) { - case self::SUBJECT_TABLE_CREATE: - $subject = $ownActivity ? $l->t('You have created a new table {table}'): $l->t('{user} has created a new table {table}'); - break; - case self::SUBJECT_TABLE_DELETE: - $subject = $ownActivity ? $l->t('You have deleted the table {table}') : $l->t('{user} has deleted the table {table}'); - break; - case self::SUBJECT_TABLE_UPDATE_TITLE: - $subject = $ownActivity ? $l->t('You have renamed the table {before} to {table}') : $l->t('{user} has renamed the table {before} to {table}'); - break; - case self::SUBJECT_TABLE_UPDATE_DESCRIPTION: - $subject = $ownActivity ? $l->t('You have updated the description of table {table} to {after}') : $l->t('{user} has updated the description of table {table} to {after}'); - break; case self::SUBJECT_ROW_CREATE: - $subject = $ownActivity ? $l->t('You have created a new row {row} in table {table}') : $l->t('{user} has created a new row {row} in table {table}'); - break; + if ($isViewContext) { + return $ownActivity ? $l->t('You have created a new row {row} in view {view}') : $l->t('{user} has created a new row {row} in view {view}'); + } + + return $ownActivity ? $l->t('You have created a new row {row} in table {table}') : $l->t('{user} has created a new row {row} in table {table}'); case self::SUBJECT_ROW_UPDATE: - $columns = ''; - $count = 1; - foreach ($subjectParams['changeCols'] as $index => $changeCol) { - $columns .= '{col-' . $changeCol['id'] . '}'; - if ($index < count($subjectParams['changeCols']) - 1) { - $count++; - $columns .= ', '; + return $this->formatRowUpdateActivity($l, $subjectParams, $ownActivity, $isViewContext); + case self::SUBJECT_ROW_DELETE: + if ($isViewContext) { + return $ownActivity ? $l->t('You have deleted the row {row} in view {view}') : $l->t('{user} has deleted the row {row} in view {view}'); + } + + return $ownActivity ? $l->t('You have deleted the row {row} in table {table}') : $l->t('{user} has deleted the row {row} in table {table}'); + default: + return null; + } + } + + private function formatRowUpdateActivity($l, array $subjectParams, bool $ownActivity, bool $isViewContext): string { + $visibleChangeCols = $this->getVisibleChangeCols($subjectParams); + $columns = ''; + $count = 1; + + foreach ($visibleChangeCols as $index => $changeCol) { + $columns .= '{col-' . $changeCol['id'] . '}'; + if ($index < count($visibleChangeCols) - 1) { + $count++; + $columns .= ', '; + } + } + + if ($isViewContext) { + return $ownActivity + ? $l->n( + 'You have updated cell %1$s on row {row} in view {view}', + 'You have updated cells %1$s on row {row} in view {view}', + $count, + [$columns], + ) + : $l->n( + '{user} has updated cell %1$s on row {row} in view {view}', + '{user} has updated cells %1$s on row {row} in view {view}', + $count, + [$columns], + ); + } + + return $ownActivity + ? $l->n( + 'You have updated cell %1$s on row {row} in table {table}', + 'You have updated cells %1$s on row {row} in table {table}', + $count, + [$columns], + ) + : $l->n( + '{user} has updated cell %1$s on row {row} in table {table}', + '{user} has updated cells %1$s on row {row} in table {table}', + $count, + [$columns], + ); + } + + private function formatColumnActivity($l, string $subjectIdentifier, bool $ownActivity, bool $isViewContext): ?string { + if ($isViewContext) { + return match ($subjectIdentifier) { + self::SUBJECT_COLUMN_CREATE => $ownActivity ? $l->t('You have created a new column {column} in view {view}') : $l->t('{user} has created a new column {column} in view {view}'), + self::SUBJECT_COLUMN_UPDATE => $ownActivity ? $l->t('You have updated the column {column} in view {view}') : $l->t('{user} has updated the column {column} in view {view}'), + self::SUBJECT_COLUMN_DELETE => $ownActivity ? $l->t('You have deleted the column {column} from view {view}') : $l->t('{user} has deleted the column {column} from view {view}'), + default => null, + }; + } + + return match ($subjectIdentifier) { + self::SUBJECT_COLUMN_CREATE => $ownActivity ? $l->t('You have created a new column {column} in table {table}') : $l->t('{user} has created a new column {column} in table {table}'), + self::SUBJECT_COLUMN_UPDATE => $ownActivity ? $l->t('You have updated the column {column} in table {table}') : $l->t('{user} has updated the column {column} in table {table}'), + self::SUBJECT_COLUMN_DELETE => $ownActivity ? $l->t('You have deleted the column {column} from table {table}') : $l->t('{user} has deleted the column {column} from table {table}'), + default => null, + }; + } + + private function formatShareActivity($l, string $subjectIdentifier, array $subjectParams, bool $ownActivity, bool $isViewObject, mixed $sharedWith): ?string { + $sharedWithYou = $this->isSharedWithYou($subjectParams, $ownActivity); + $isLinkShare = $this->isLinkShare($sharedWith); + + switch ($subjectIdentifier) { + case self::SUBJECT_SHARE_CREATE: + if ($isViewObject) { + if ($sharedWithYou) { + return $l->t('{user} has shared view {view} with you'); } + + if ($isLinkShare) { + return $ownActivity ? $l->t('You have shared the view {view} as public link') : $l->t('{user} has shared the view {view} as public link'); + } + + return $ownActivity ? $l->t('You have shared the view {view} with {sharedWith}') : $l->t('{user} has shared the view {view} with {sharedWith}'); } - $subject = $ownActivity - ? $l->n( - 'You have updated cell %1$s on row {row} in table {table}', - 'You have updated cells %1$s on row {row} in table {table}', - $count, - [$columns], - ) - : $l->n( - '{user} has updated cell %1$s on row {row} in table {table}', - '{user} has updated cells %1$s on row {row} in table {table}', - $count, - [$columns], - ); - break; - case self::SUBJECT_ROW_DELETE: - $subject = $ownActivity ? $l->t('You have deleted the row {row} in table {table}') : $l->t('{user} has deleted the row {row} in table {table}'); - break; - case self::SUBJECT_IMPORT_FINISHED: - $subject = $ownActivity ? $l->t('You have imported file to table {table}') : $l->t('{user} has imported file to table {table}'); - break; + if ($sharedWithYou) { + return $l->t('{user} has shared table {table} with you'); + } + + if ($isLinkShare) { + return $ownActivity ? $l->t('You have shared the table {table} as public link') : $l->t('{user} has shared the table {table} as public link'); + } + + return $ownActivity ? $l->t('You have shared the table {table} with {sharedWith}') : $l->t('{user} has shared the table {table} with {sharedWith}'); + case self::SUBJECT_SHARE_UPDATE: + if ($isViewObject) { + if ($sharedWithYou) { + return $l->t('{user} has updated sharing for the view {view} with you'); + } + + if ($isLinkShare) { + return $ownActivity ? $l->t('You have updated public link sharing for the view {view}') : $l->t('{user} has updated public link sharing for the view {view}'); + } + + return $ownActivity ? $l->t('You have updated sharing for the view {view} with {sharedWith}') : $l->t('{user} has updated sharing for the view {view} with {sharedWith}'); + } + + if ($sharedWithYou) { + return $l->t('{user} has updated sharing for the table {table} with you'); + } + + if ($isLinkShare) { + return $ownActivity ? $l->t('You have updated public link sharing for the table {table}') : $l->t('{user} has updated public link sharing for the table {table}'); + } + + return $ownActivity ? $l->t('You have updated sharing for the table {table} with {sharedWith}') : $l->t('{user} has updated sharing for the table {table} with {sharedWith}'); + case self::SUBJECT_SHARE_DELETE: + if ($isViewObject) { + if ($sharedWithYou) { + return $l->t('{user} has removed sharing for the view {view} with you'); + } + + if ($isLinkShare) { + return $ownActivity ? $l->t('You have removed public link sharing for the view {view}') : $l->t('{user} has removed public link sharing for the view {view}'); + } + + return $ownActivity ? $l->t('You have removed sharing for the view {view} with {sharedWith}') : $l->t('{user} has removed sharing for the view {view} with {sharedWith}'); + } + + if ($sharedWithYou) { + return $l->t('{user} has removed sharing for the table {table} with you'); + } + + if ($isLinkShare) { + return $ownActivity ? $l->t('You have removed public link sharing for the table {table}') : $l->t('{user} has removed public link sharing for the table {table}'); + } + + return $ownActivity ? $l->t('You have removed sharing for the table {table} with {sharedWith}') : $l->t('{user} has removed sharing for the table {table} with {sharedWith}'); default: - break; + return null; } + } + + private function isSharedWithYou(array $subjectParams, bool $ownActivity): bool { + return !$ownActivity && ($subjectParams['sharedWithYou'] ?? false) === true; + } - return $subject; + private function isLinkShare(mixed $sharedWith): bool { + return is_array($sharedWith) && ($sharedWith['type'] ?? null) === ShareReceiverType::LINK; } public function getActivityMessage($language, $subjectIdentifier) { diff --git a/lib/Activity/Filter.php b/lib/Activity/Filter.php index 25a0b5c840..9d337a7f5d 100644 --- a/lib/Activity/Filter.php +++ b/lib/Activity/Filter.php @@ -60,7 +60,7 @@ public function getIcon(): string { * @since 11.0.0 */ public function filterTypes(array $types): array { - return $types; + return array_merge($types, ['tables_sharing', 'tables_row_table', 'tables_row_view', 'tables_column_table', 'tables_column_view']); } /** diff --git a/lib/Activity/SettingChanges.php b/lib/Activity/SettingChanges.php index d7711c3da4..1251515dbb 100644 --- a/lib/Activity/SettingChanges.php +++ b/lib/Activity/SettingChanges.php @@ -39,7 +39,7 @@ public function getIdentifier(): string { * @since 20.0.0 */ public function getName(): string { - return $this->l->t('A table or row was changed'); + return $this->l->t('A table or view was changed'); } /** diff --git a/lib/Activity/SettingSharing.php b/lib/Activity/SettingSharing.php new file mode 100644 index 0000000000..fa4d9957b7 --- /dev/null +++ b/lib/Activity/SettingSharing.php @@ -0,0 +1,90 @@ +l->t('Tables'); + } + + /** + * @return string Lowercase a-z and underscore only identifier + * @since 20.0.0 + */ + public function getIdentifier(): string { + return 'tables_sharing'; + } + + /** + * @return string A translated string + * @since 20.0.0 + */ + public function getName(): string { + return $this->l->t('A table or view was shared with you'); + } + + /** + * @return int whether the filter should be rather on the top or bottom of + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * @since 20.0.0 + */ + public function getPriority(): int { + return 90; + } + + /** + * Left in for backwards compatibility + * + * @return bool + * @since 20.0.0 + */ + public function canChangeStream(): bool { + return true; + } + + /** + * Left in for backwards compatibility + * + * @return bool + * @since 20.0.0 + */ + public function isDefaultEnabledStream(): bool { + return true; + } + + /** + * @return bool True when the option can be changed for the mail + * @since 20.0.0 + */ + public function canChangeMail(): bool { + return true; + } + + /** + * @return bool Whether or not an activity email should be send by default + * @since 20.0.0 + */ + public function isDefaultEnabledMail(): bool { + return false; + } +} diff --git a/lib/Activity/TablesProvider.php b/lib/Activity/TablesProvider.php index fe23c4f55d..af0abf7bc7 100644 --- a/lib/Activity/TablesProvider.php +++ b/lib/Activity/TablesProvider.php @@ -75,22 +75,54 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null): $event->setLink($this->tablesUrl('/table/' . $event->getObjectId())); } + if ($event->getObjectType() === ActivityManager::TABLES_OBJECT_VIEW) { + if (isset($subjectParams['table']['id'], $subjectParams['table']['title'])) { + $subjectParameters['table'] = [ + 'type' => 'highlight', + 'id' => (string)$subjectParams['table']['id'], + 'name' => (string)$subjectParams['table']['title'], + 'link' => $this->tablesUrl('/table/' . $subjectParams['table']['id']), + ]; + } + + $subjectParameters['view'] = [ + 'type' => 'highlight', + 'id' => (string)$event->getObjectId(), + 'name' => $event->getObjectName(), + 'link' => $this->tablesUrl('/view/' . $event->getObjectId()), + ]; + $event->setLink($this->tablesUrl('/view/' . $event->getObjectId())); + } + if ($event->getObjectType() === ActivityManager::TABLES_OBJECT_ROW) { - $table = [ + $subjectParameters['table'] = [ 'type' => 'highlight', 'id' => (string)$subjectParams['table']['id'], 'name' => (string)$subjectParams['table']['title'], 'link' => $this->tablesUrl('/table/' . $subjectParams['table']['id']), ]; - $subjectParameters['table'] = $table; + + $rowLink = $this->tablesUrl('/table/' . $subjectParams['table']['id'] . '/row/' . $event->getObjectId()); + $isViewContext = $subjectParams['isViewContext'] ?? false; + + if ($isViewContext && isset($subjectParams['view']['id'], $subjectParams['view']['title'])) { + $subjectParameters['view'] = [ + 'type' => 'highlight', + 'id' => (string)$subjectParams['view']['id'], + 'name' => (string)$subjectParams['view']['title'], + 'link' => $this->tablesUrl('/view/' . $subjectParams['view']['id']), + ]; + $rowLink = $this->tablesUrl('/view/' . $subjectParams['view']['id'] . '/row/' . $event->getObjectId()); + } + $row = [ 'type' => 'highlight', 'id' => (string)$event->getObjectId(), 'name' => '#' . $event->getObjectId(), - 'link' => $this->tablesUrl('/table/' . $subjectParams['table']['id'] . '/row/' . $event->getObjectId()), + 'link' => $rowLink, ]; $subjectParameters['row'] = $row; - $event->setLink($this->tablesUrl('/table/' . $subjectParams['table']['id'] . '/row/' . $event->getObjectId())); + $event->setLink($rowLink); if ($event->getSubject() === ActivityManager::SUBJECT_ROW_UPDATE) { foreach ($subjectParams['changeCols'] as $changeCol) { @@ -103,6 +135,46 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null): } } + if ($event->getObjectType() === ActivityManager::TABLES_OBJECT_COLUMN) { + $isViewContext = $subjectParams['isViewContext'] ?? false; + + if (isset($subjectParams['table']['id'], $subjectParams['table']['title'])) { + $subjectParameters['table'] = [ + 'type' => 'highlight', + 'id' => (string)$subjectParams['table']['id'], + 'name' => (string)$subjectParams['table']['title'], + 'link' => $this->tablesUrl('/table/' . $subjectParams['table']['id']), + ]; + } + + $subjectParameters['column'] = [ + 'type' => 'highlight', + 'id' => (string)$event->getObjectId(), + 'name' => $event->getObjectName(), + ]; + + if ($isViewContext && isset($subjectParams['view']['id'], $subjectParams['view']['title'])) { + $subjectParameters['view'] = [ + 'type' => 'highlight', + 'id' => (string)$subjectParams['view']['id'], + 'name' => (string)$subjectParams['view']['title'], + 'link' => $this->tablesUrl('/view/' . $subjectParams['view']['id']), + ]; + $event->setLink($this->tablesUrl('/view/' . $subjectParams['view']['id'])); + } elseif (isset($subjectParams['table']['id'])) { + $event->setLink($this->tablesUrl('/table/' . $subjectParams['table']['id'])); + } + } + + if (isset($subjectParams['sharedWith']) && is_array($subjectParams['sharedWith'])) { + $sharedWith = $subjectParams['sharedWith']; + $subjectParameters['sharedWith'] = [ + 'type' => 'highlight', + 'id' => isset($sharedWith['id']) ? (string)$sharedWith['id'] : 'unknown', + 'name' => isset($sharedWith['name']) ? (string)$sharedWith['name'] : 'unknown', + ]; + } + if (array_key_exists('before', $subjectParams) && is_string($subjectParams['before'])) { $subjectParameters['before'] = [ 'type' => 'highlight', @@ -156,6 +228,10 @@ private function setIcon(IEvent $event) { $event->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('files', 'delete-color.svg'))); } + if (str_contains($event->getSubject(), 'share_')) { + $event->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/share.svg'))); + } + return $event; } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 4c00c491df..1bd5cec9f6 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -26,6 +26,7 @@ use OCA\Tables\Listener\WhenViewDeletedAuditLogListener; use OCA\Tables\Middleware\PermissionMiddleware; use OCA\Tables\Middleware\ShareControlMiddleware; +use OCA\Tables\Notification\Notifier; use OCA\Tables\Reference\ContentReferenceProvider; use OCA\Tables\Reference\ReferenceProvider; use OCA\Tables\Search\SearchTablesProvider; @@ -90,6 +91,8 @@ public function register(IRegistrationContext $context): void { $context->registerSearchProvider(SearchTablesProvider::class); + $context->registerNotifierService(Notifier::class); + $context->registerReferenceProvider(ReferenceProvider::class); $context->registerReferenceProvider(ContentReferenceProvider::class); diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php new file mode 100644 index 0000000000..0c59525276 --- /dev/null +++ b/lib/Controller/ConfigController.php @@ -0,0 +1,50 @@ +configService->getTableConfig($id); + return new DataResponse($config); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + public function getViewConfig(int $id): DataResponse { + $config = $this->configService->getViewConfig($id); + return new DataResponse($config); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + public function setValue(string $key, mixed $value): DataResponse|NotFoundResponse { + $result = $this->configService->set($key, $value); + if ($result === null) { + return new NotFoundResponse(); + } + return new DataResponse($result); + } +} diff --git a/lib/Notification/NotificationHelper.php b/lib/Notification/NotificationHelper.php new file mode 100644 index 0000000000..269b9cca0d --- /dev/null +++ b/lib/Notification/NotificationHelper.php @@ -0,0 +1,702 @@ + */ + private array $columnCache = []; + + public function __construct( + protected readonly IManager $notificationManager, + private readonly ConfigService $configService, + private readonly ShareService $shareService, + private readonly TableMapper $tableMapper, + private readonly ViewMapper $viewMapper, + private readonly Row2Mapper $row2Mapper, + private readonly ColumnMapper $columnMapper, + protected readonly ?string $userId, + ) { + } + + /** + * @param mixed $object + * @param mixed $subject + * @param array $additionalParams + * @param mixed $author + */ + public function sendNotification($objectType, $object, $subject, $additionalParams = [], $author = null): void { + try { + switch ($objectType) { + case ActivityManager::TABLES_OBJECT_ROW: + if ($object instanceof Row2) { + $this->sendRowNotification($object, $subject, $additionalParams, $author); + } + break; + case ActivityManager::TABLES_OBJECT_COLUMN: + if ($object instanceof Column) { + $this->sendColumnNotification($object, $subject, $author); + } + break; + } + } catch (\Throwable $e) { + // Notifications are best effort and must not block write operations. + Server::get(LoggerInterface::class)->error('Failed to send notification for object type ' . $objectType, [ + 'objectId' => is_object($object) && method_exists($object, 'getId') ? $object->getId() : null, + 'subject' => $subject, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * @param mixed $author + */ + private function sendColumnNotification(Column $column, string $subject, $author): void { + if (!in_array($subject, [ + ActivityManager::SUBJECT_COLUMN_CREATE, + ActivityManager::SUBJECT_COLUMN_UPDATE, + ActivityManager::SUBJECT_COLUMN_DELETE, + ], true)) { + return; + } + + $columnId = $column->getId(); + $tableId = $column->getTableId(); + if ($columnId === null || $tableId === null) { + return; + } + + $table = $this->tableMapper->find($tableId); + $authorId = is_string($author) && $author !== '' ? $author : $this->userId; + $ownerId = $table->getOwnership(); + + $subjectParams = [ + 'author' => $authorId, + 'objectType' => ActivityManager::TABLES_OBJECT_COLUMN, + 'table' => [ + 'id' => $tableId, + 'title' => $table->getTitle(), + ], + 'column' => [ + 'id' => $columnId, + 'title' => $column->getTitle(), + ], + ]; + + $tableRecipients = $this->shareService->findSharedWithUserIds($tableId, 'table'); + if (is_string($ownerId) && $ownerId !== '') { + $tableRecipients[] = $ownerId; + } + + foreach (array_unique($tableRecipients) as $receiverId) { + if (!is_string($receiverId) || $receiverId === '' || $receiverId === $authorId) { + continue; + } + if (!$this->configService->isNotifyEnabledForScope($receiverId, 'table', $tableId, 'notify-column')) { + continue; + } + + $params = array_merge($subjectParams, ['isViewContext' => false]); + $notification = $this->generateNotification( + subject: $subject, + subjectParams: $params, + objectType: ActivityManager::TABLES_OBJECT_COLUMN, + objectId: (string)$columnId, + receiver: $receiverId, + ); + $this->notificationManager->notify($notification); + } + + foreach ($this->viewMapper->findAll($tableId) as $view) { + if (!in_array($columnId, $view->getColumnIds(), true)) { + continue; + } + + $viewRecipients = $this->shareService->findSharedWithUserIds($view->getId(), 'view'); + if (is_string($ownerId) && $ownerId !== '') { + $viewRecipients[] = $ownerId; + } + + foreach (array_unique($viewRecipients) as $receiverId) { + if (!is_string($receiverId) || $receiverId === '' || $receiverId === $authorId) { + continue; + } + if (!$this->configService->isNotifyEnabledForScope($receiverId, 'view', $view->getId(), 'notify-column')) { + continue; + } + + $params = array_merge($subjectParams, [ + 'isViewContext' => true, + 'view' => [ + 'id' => $view->getId(), + 'title' => $view->getTitle(), + ], + ]); + $notification = $this->generateNotification( + subject: $subject, + subjectParams: $params, + objectType: ActivityManager::TABLES_OBJECT_COLUMN, + objectId: (string)$columnId, + receiver: $receiverId, + ); + $this->notificationManager->notify($notification); + } + } + } + + /** + * @param array $additionalParams + * @param mixed $author + */ + private function sendRowNotification(Row2 $row, string $subject, array $additionalParams, $author): void { + if (!in_array($subject, [ + ActivityManager::SUBJECT_ROW_CREATE, + ActivityManager::SUBJECT_ROW_UPDATE, + ActivityManager::SUBJECT_ROW_DELETE, + ], true)) { + return; + } + + $rowId = $row->getId(); + $tableId = $row->getTableId(); + if ($rowId === null || $tableId === null) { + return; + } + + $table = $this->tableMapper->find($tableId); + $authorId = is_string($author) && $author !== '' ? $author : $this->userId; + $ownerId = $table->getOwnership(); + + $subjectParams = [ + 'author' => $authorId, + 'objectType' => ActivityManager::TABLES_OBJECT_ROW, + 'table' => [ + 'id' => $tableId, + 'title' => $table->getTitle(), + ], + 'row' => [ + 'id' => $rowId, + ], + ]; + + if ($subject === ActivityManager::SUBJECT_ROW_UPDATE) { + $subjectParams['changeCols'] = $this->resolveChangedColumns($additionalParams); + } + + $tableRecipients = $this->shareService->findSharedWithUserIds($tableId, 'table'); + if (is_string($ownerId) && $ownerId !== '') { + $tableRecipients[] = $ownerId; + } + + foreach (array_unique($tableRecipients) as $receiverId) { + if (!is_string($receiverId) || $receiverId === '' || $receiverId === $authorId) { + continue; + } + if (!$this->configService->isNotifyEnabledForScope($receiverId, 'table', $tableId, 'notify-row')) { + continue; + } + + $params = array_merge($subjectParams, ['isViewContext' => false]); + $notification = $this->generateNotification( + subject: $subject, + subjectParams: $params, + objectType: ActivityManager::TABLES_OBJECT_ROW, + objectId: (string)$rowId, + receiver: $receiverId, + ); + $this->notificationManager->notify($notification); + } + + foreach ($this->viewMapper->findAll($tableId) as $view) { + if ($subject === ActivityManager::SUBJECT_ROW_UPDATE && !$this->viewContainsAnyChangedColumn($view, $subjectParams['changeCols'] ?? [])) { + continue; + } + + $viewRecipients = $this->shareService->findSharedWithUserIds($view->getId(), 'view'); + if (is_string($ownerId) && $ownerId !== '') { + $viewRecipients[] = $ownerId; + } + + foreach (array_unique($viewRecipients) as $receiverId) { + if (!is_string($receiverId) || $receiverId === '' || $receiverId === $authorId) { + continue; + } + if (!$this->configService->isNotifyEnabledForScope($receiverId, 'view', $view->getId(), 'notify-row')) { + continue; + } + if (!$this->row2Mapper->isRowInViewPresent($rowId, $view, $receiverId)) { + continue; + } + + $params = array_merge($subjectParams, [ + 'isViewContext' => true, + 'view' => [ + 'id' => $view->getId(), + 'title' => $view->getTitle(), + ], + ]); + $notification = $this->generateNotification( + subject: $subject, + subjectParams: $params, + objectType: ActivityManager::TABLES_OBJECT_ROW, + objectId: (string)$rowId, + receiver: $receiverId, + ); + $this->notificationManager->notify($notification); + } + } + + $assignedTargets = $subject === ActivityManager::SUBJECT_ROW_CREATE + ? $this->extractAssignedTargetsFromRowData($row->getData()) + : $this->extractAssignedTargetsFromChangedColumns($subjectParams['changeCols'] ?? []); + + if ($assignedTargets === []) { + return; + } + + $this->sendAssignedRowNotificationsToTableRecipients( + rowId: $rowId, + tableId: $tableId, + authorId: $authorId, + subjectParams: $subjectParams, + recipients: $tableRecipients, + assignedTargets: $assignedTargets, + ); + + foreach ($this->viewMapper->findAll($tableId) as $view) { + if ($subject === ActivityManager::SUBJECT_ROW_UPDATE && !$this->viewContainsAnyChangedColumn($view, $subjectParams['changeCols'] ?? [])) { + continue; + } + + $viewRecipients = $this->shareService->findSharedWithUserIds($view->getId(), 'view'); + if (is_string($ownerId) && $ownerId !== '') { + $viewRecipients[] = $ownerId; + } + + $this->sendAssignedRowNotificationsToViewRecipients( + rowId: $rowId, + authorId: $authorId, + subjectParams: $subjectParams, + view: $view, + recipients: $viewRecipients, + assignedTargets: $assignedTargets, + ); + } + } + + private function resolveChangedColumns(array $additionalParams): array { + $changedColumns = []; + + if (!isset($additionalParams['before'], $additionalParams['after']) + || !is_array($additionalParams['before']) + || !is_array($additionalParams['after'])) { + return $changedColumns; + } + + $columnsCount = max(count($additionalParams['before']), count($additionalParams['after'])); + + for ($i = 0; $i < $columnsCount; $i++) { + $before = $additionalParams['before'][$i] ?? null; + $after = $additionalParams['after'][$i] ?? null; + $columnId = $before['columnId'] ?? $after['columnId'] ?? null; + + if ($before === $after) { + continue; // No change, skip + } else { + try { + $column = $this->findColumnById((int)$columnId); + $changedColumns[] = [ + 'id' => $column->getId(), + 'name' => $column->getTitle(), + 'type' => $column->getType(), + 'before' => $before, + 'after' => $after + ]; + } catch (\Exception $e) { + Server::get(LoggerInterface::class)->error('Could not find column for activity entry.', [ + 'columnId' => $columnId, + 'exception' => $e->getMessage() + ]); + continue; // Skip if column not found + } + } + } + + return $changedColumns; + } + + /** + * @param list $changedColumns + */ + private function viewContainsAnyChangedColumn(View $view, array $changedColumns): bool { + if ($changedColumns === []) { + return true; + } + + $changedColumnIds = array_map(static fn (array $column): int => (int)$column['id'], $changedColumns); + return !empty(array_intersect($changedColumnIds, $view->getColumnIds())); + } + + private function generateNotification(string $subject, array $subjectParams, string $objectType, string $objectId, string $receiver): INotification { + $notification = $this->notificationManager->createNotification(); + $notification + ->setApp('tables') + ->setUser($receiver) + ->setDateTime(new DateTime()) + ->setObject($objectType, $objectId) + ->setSubject($subject, $subjectParams); + return $notification; + } + + /** + * @param list> $rowData + * @return list}> + */ + private function extractAssignedTargetsFromRowData(array $rowData): array { + $assignedTargets = []; + foreach ($rowData as $cell) { + if (!is_array($cell) || !isset($cell['columnId'])) { + continue; + } + + $columnId = (int)$cell['columnId']; + $column = $this->findColumnById($columnId); + if (!$column instanceof Column || $column->getType() !== Column::TYPE_USERGROUP) { + continue; + } + + $assignedTargets = array_merge($assignedTargets, $this->resolveAssignedTargetsFromValue($columnId, $cell['value'] ?? null)); + } + + return $assignedTargets; + } + + /** + * @param list> $changedColumns + * @return list}> + */ + private function extractAssignedTargetsFromChangedColumns(array $changedColumns): array { + $assignedTargets = []; + foreach ($changedColumns as $change) { + if (!is_array($change) || ($change['type'] ?? null) !== Column::TYPE_USERGROUP) { + continue; + } + + $columnId = (int)($change['id'] ?? 0); + if ($columnId <= 0) { + continue; + } + + $beforeEntries = $this->normalizeUsergroupEntries($change['before']['value'] ?? null); + $afterEntries = $this->normalizeUsergroupEntries($change['after']['value'] ?? null); + $newAssignments = $this->subtractUsergroupEntries($afterEntries, $beforeEntries); + + foreach ($newAssignments as $entry) { + $target = $this->resolveAssignedTargetFromUsergroupEntry($columnId, $entry); + if ($target !== null) { + $assignedTargets[] = $target; + } + } + } + + return $assignedTargets; + } + + /** + * @return list}> + */ + private function resolveAssignedTargetsFromValue(int $columnId, mixed $value): array { + $targets = []; + foreach ($this->normalizeUsergroupEntries($value) as $entry) { + $target = $this->resolveAssignedTargetFromUsergroupEntry($columnId, $entry); + if ($target !== null) { + $targets[] = $target; + } + } + return $targets; + } + + /** + * @param array{id: string, type: int} $entry + * @return array{columnId: int, targetType: string, targetId: string, targetName: string, userIds: list}|null + */ + private function resolveAssignedTargetFromUsergroupEntry(int $columnId, array $entry): ?array { + $targetId = $entry['id']; + if ($targetId === '') { + return null; + } + + if ($entry['type'] === UsergroupType::USER) { + return [ + 'columnId' => $columnId, + 'targetType' => 'user', + 'targetId' => $targetId, + 'targetName' => $targetId, + 'userIds' => [$targetId], + ]; + } + + if ($entry['type'] === UsergroupType::GROUP) { + return [ + 'columnId' => $columnId, + 'targetType' => 'group', + 'targetId' => $targetId, + 'targetName' => $targetId, + 'userIds' => $this->shareService->findUserIdsForShareReceiver(ShareReceiverType::GROUP, $targetId), + ]; + } + + if ($entry['type'] === UsergroupType::CIRCLE) { + return [ + 'columnId' => $columnId, + 'targetType' => 'team', + 'targetId' => $targetId, + 'targetName' => $targetId, + 'userIds' => $this->shareService->findUserIdsForShareReceiver(ShareReceiverType::CIRCLE, $targetId), + ]; + } + + return null; + } + + /** + * @param mixed $value + * @return list + */ + private function normalizeUsergroupEntries(mixed $value): array { + if (is_string($value)) { + $value = json_decode($value, true); + } + + if (!is_array($value)) { + return []; + } + + $entries = []; + foreach ($value as $entry) { + if (!is_array($entry) || !isset($entry['id'], $entry['type'])) { + continue; + } + + if (!is_string($entry['id']) || $entry['id'] === '') { + continue; + } + + if (!is_int($entry['type'])) { + continue; + } + + $entries[] = [ + 'id' => $entry['id'], + 'type' => $entry['type'], + ]; + } + + return $entries; + } + + /** + * @param list $entries + * @return array + */ + private function buildUsergroupEntryKeySet(array $entries): array { + $set = []; + foreach ($entries as $entry) { + $set[$entry['type'] . ':' . $entry['id']] = true; + } + return $set; + } + + /** + * @param list $baseEntries + * @param list $entriesToRemove + * @return list + */ + private function subtractUsergroupEntries(array $baseEntries, array $entriesToRemove): array { + $removeSet = $this->buildUsergroupEntryKeySet($entriesToRemove); + $remaining = []; + + foreach ($baseEntries as $entry) { + $key = $entry['type'] . ':' . $entry['id']; + if (!isset($removeSet[$key])) { + $remaining[] = $entry; + } + } + + return $remaining; + } + + private function findColumnById(int $columnId): ?Column { + if ($columnId <= 0) { + return null; + } + + if (isset($this->columnCache[$columnId])) { + return $this->columnCache[$columnId]; + } + + try { + $column = $this->columnMapper->find($columnId); + $this->columnCache[$columnId] = $column; + return $column; + } catch (\Throwable $e) { + Server::get(LoggerInterface::class)->warning('Could not resolve column for assigned notification.', [ + 'columnId' => $columnId, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * @param list $recipients + * @param list}> $assignedTargets + */ + private function sendAssignedRowNotificationsToTableRecipients( + int $rowId, + int $tableId, + ?string $authorId, + array $subjectParams, + array $recipients, + array $assignedTargets, + ): void { + $recipientSet = array_fill_keys(array_filter($recipients, static fn (mixed $id): bool => is_string($id) && $id !== ''), true); + $sent = []; + + foreach ($assignedTargets as $target) { + foreach (array_unique($target['userIds']) as $receiverId) { + if ($receiverId === '' || $receiverId === $authorId || !isset($recipientSet[$receiverId])) { + continue; + } + if (!$this->configService->isNotifyEnabledForScope($receiverId, 'table', $tableId, 'notify-assigned')) { + continue; + } + + $dedupeKey = $receiverId . ':table:' . $target['targetType'] . ':' . $target['targetId']; + if (isset($sent[$dedupeKey])) { + continue; + } + $sent[$dedupeKey] = true; + + $params = $this->buildAssignedSubjectParams($subjectParams, $target, false); + $notification = $this->generateNotification( + subject: ActivityManager::SUBJECT_ROW_ASSIGN, + subjectParams: $params, + objectType: ActivityManager::TABLES_OBJECT_ROW, + objectId: (string)$rowId, + receiver: $receiverId, + ); + $this->notificationManager->notify($notification); + } + } + } + + /** + * @param list $recipients + * @param list}> $assignedTargets + */ + private function sendAssignedRowNotificationsToViewRecipients( + int $rowId, + ?string $authorId, + array $subjectParams, + View $view, + array $recipients, + array $assignedTargets, + ): void { + $recipientSet = array_fill_keys(array_filter($recipients, static fn (mixed $id): bool => is_string($id) && $id !== ''), true); + $sent = []; + + foreach ($assignedTargets as $target) { + if (!in_array($target['columnId'], $view->getColumnIds(), true)) { + continue; + } + + foreach (array_unique($target['userIds']) as $receiverId) { + if ($receiverId === '' || $receiverId === $authorId || !isset($recipientSet[$receiverId])) { + continue; + } + if (!$this->configService->isNotifyEnabledForScope($receiverId, 'view', $view->getId(), 'notify-assigned')) { + continue; + } + if (!$this->row2Mapper->isRowInViewPresent($rowId, $view, $receiverId)) { + continue; + } + + $dedupeKey = $receiverId . ':view:' . $view->getId() . ':' . $target['targetType'] . ':' . $target['targetId']; + if (isset($sent[$dedupeKey])) { + continue; + } + $sent[$dedupeKey] = true; + + $params = $this->buildAssignedSubjectParams($subjectParams, $target, true, $view); + $notification = $this->generateNotification( + subject: ActivityManager::SUBJECT_ROW_ASSIGN, + subjectParams: $params, + objectType: ActivityManager::TABLES_OBJECT_ROW, + objectId: (string)$rowId, + receiver: $receiverId, + ); + $this->notificationManager->notify($notification); + } + } + } + + /** + * @param array{columnId: int, targetType: string, targetId: string, targetName: string, userIds: list} $target + */ + private function buildAssignedSubjectParams(array $subjectParams, array $target, bool $isViewContext, ?View $view = null): array { + $params = array_merge($subjectParams, [ + 'isViewContext' => $isViewContext, + 'assignedTargetType' => $target['targetType'], + 'assignedTargetId' => $target['targetId'], + 'assignedTargetName' => $target['targetName'], + ]); + + if ($isViewContext && $view !== null) { + $params['view'] = [ + 'id' => $view->getId(), + 'title' => $view->getTitle(), + ]; + } + + if ($target['targetType'] === 'group') { + $params['group'] = [ + 'id' => $target['targetId'], + 'name' => $target['targetName'], + ]; + } + + if ($target['targetType'] === 'team') { + $params['team'] = [ + 'id' => $target['targetId'], + 'name' => $target['targetName'], + ]; + } + + return $params; + } +} diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php new file mode 100644 index 0000000000..977ccda946 --- /dev/null +++ b/lib/Notification/Notifier.php @@ -0,0 +1,300 @@ +l10nFactory->get('tables')->t('Tables'); + } + + /** + * @param INotification $notification + * @param string $languageCode The code of the language that should be used to prepare the notification + * @return INotification + * @throws \InvalidArgumentException When the notification was not prepared by a notifier + * @since 9.0.0 + */ + public function prepare(INotification $notification, string $languageCode): INotification { + if ($notification->getApp() !== 'tables' || $notification->getObjectType() === 'activity_notification') { + throw new UnknownNotificationException(); + } + + $l = $this->l10nFactory->get('tables', $languageCode); + $notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath('tables', 'app-dark.svg'))); + $params = $notification->getSubjectParameters(); + $hasViewContext = ($params['isViewContext'] ?? false) === true; + $parsedSubject = ''; + $subject = $notification->getSubject(); + $richParams = []; + $link = ''; + + if (isset($params['author'])) { + $authorId = (string)$params['author']; + $authorUser = $authorId !== '' ? $this->userManager->get($authorId) : null; + $authorName = $authorUser?->getDisplayName() ?? $authorId; + $richParams['user'] = [ + 'type' => 'user', + 'id' => $authorId, + 'name' => (string)$authorName, + ]; + } + + if (isset($params['table']['id'], $params['table']['title'])) { + $richParams['table'] = [ + 'type' => 'highlight', + 'id' => (string)$params['table']['id'], + 'name' => (string)$params['table']['title'], + 'link' => $this->tablesUrl('/table/' . $params['table']['id']), + ]; + } + + if ($hasViewContext && isset($params['view']['id'], $params['view']['title'])) { + $richParams['view'] = [ + 'type' => 'highlight', + 'id' => (string)$params['view']['id'], + 'name' => (string)$params['view']['title'], + 'link' => $this->tablesUrl('/view/' . $params['view']['id']), + ]; + } + + if (isset($params['row']['id'])) { + $richParams['row'] = [ + 'type' => 'highlight', + 'id' => (string)$params['row']['id'], + 'name' => '#' . $params['row']['id'], + 'link' => $hasViewContext && isset($params['view']['id']) + ? $this->tablesUrl('/view/' . $params['view']['id'] . '/row/' . $params['row']['id']) + : $this->tablesUrl('/table/' . $params['table']['id'] . '/row/' . $params['row']['id']), + ]; + } + + if (isset($params['column']['id'])) { + $columnTitle = $params['column']['title'] ?? $params['column']['name'] ?? ('#' . $params['column']['id']); + $richParams['column'] = [ + 'type' => 'highlight', + 'id' => (string)$params['column']['id'], + 'name' => (string)$columnTitle, + ]; + } + + if (isset($params['group']['id'])) { + $richParams['group'] = [ + 'type' => 'highlight', + 'id' => (string)$params['group']['id'], + 'name' => (string)($params['group']['name'] ?? $params['group']['id']), + ]; + } + + if (isset($params['team']['id'])) { + $richParams['team'] = [ + 'type' => 'highlight', + 'id' => (string)$params['team']['id'], + 'name' => (string)($params['team']['name'] ?? $params['team']['id']), + ]; + } + + switch ($notification->getSubject()) { + case ActivityManager::SUBJECT_ROW_CREATE: + $link = $richParams['row']['link']; + $parsedSubject = $hasViewContext + ? $l->t('A new table row has been created in view %2$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['view']['name'] ?? '', + ]) + : $l->t('A new table row has been created in table %2$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['table']['name'] ?? '', + ]); + $subject = $hasViewContext + ? $l->t('{user} has created a new row {row} in view {view}') + : $l->t('{user} has created a new row {row} in table {table}'); + break; + + case ActivityManager::SUBJECT_ROW_UPDATE: + $link = $richParams['row']['link']; + $parsedSubject = $hasViewContext + ? $l->t('A table row has been updated in view %2$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['view']['name'] ?? '', + ]) + : $l->t('A table row has been updated in table %2$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['table']['name'] ?? '', + ]); + $subject = $hasViewContext + ? $l->t('{user} has updated row {row} in view {view}') + : $l->t('{user} has updated row {row} in table {table}'); + break; + + case ActivityManager::SUBJECT_ROW_DELETE: + $link = $richParams['row']['link']; + $parsedSubject = $hasViewContext + ? $l->t('A table row has been deleted from view %2$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['view']['name'] ?? '', + ]) + : $l->t('A table row has been deleted from table %2$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['table']['name'] ?? '', + ]); + $subject = $hasViewContext + ? $l->t('{user} has deleted the row {row} in view {view}') + : $l->t('{user} has deleted the row {row} in table {table}'); + break; + + case ActivityManager::SUBJECT_ROW_ASSIGN: + $link = $richParams['row']['link']; + $assignedTargetType = (string)($params['assignedTargetType'] ?? 'user'); + if ($assignedTargetType === 'group') { + $parsedSubject = $hasViewContext + ? $l->t('Group %2$s has been assigned in row %3$s of view %4$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['group']['name'] ?? '', + $richParams['row']['name'] ?? '', + $richParams['view']['name'] ?? '', + ]) + : $l->t('Group %2$s has been assigned in row %3$s of table %4$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['group']['name'] ?? '', + $richParams['row']['name'] ?? '', + $richParams['table']['name'] ?? '', + ]); + $subject = $hasViewContext + ? $l->t('{user} has assigned group {group} in row {row} in view {view}') + : $l->t('{user} has assigned group {group} in row {row} in table {table}'); + } elseif ($assignedTargetType === 'team') { + $parsedSubject = $hasViewContext + ? $l->t('Team %2$s has been assigned in row %3$s of view %4$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['team']['name'] ?? '', + $richParams['row']['name'] ?? '', + $richParams['view']['name'] ?? '', + ]) + : $l->t('Team %2$s has been assigned in row %3$s of table %4$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['team']['name'] ?? '', + $richParams['row']['name'] ?? '', + $richParams['table']['name'] ?? '', + ]); + $subject = $hasViewContext + ? $l->t('{user} has assigned team {team} in row {row} in view {view}') + : $l->t('{user} has assigned team {team} in row {row} in table {table}'); + } else { + $parsedSubject = $hasViewContext + ? $l->t('You have been assigned in row %2$s of view %3$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['row']['name'] ?? '', + $richParams['view']['name'] ?? '', + ]) + : $l->t('You have been assigned in row %2$s of table %3$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['row']['name'] ?? '', + $richParams['table']['name'] ?? '', + ]); + $subject = $hasViewContext + ? $l->t('{user} has assigned you in row {row} in view {view}') + : $l->t('{user} has assigned you in row {row} in table {table}'); + } + break; + + case ActivityManager::SUBJECT_COLUMN_CREATE: + $link = $hasViewContext ? $richParams['view']['link'] : $richParams['table']['link']; + $parsedSubject = $hasViewContext + ? $l->t('A new column has been created in view %2$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['view']['name'] ?? '', + ]) + : $l->t('A new column has been created in table %2$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['table']['name'] ?? '', + ]); + $subject = $hasViewContext + ? $l->t('{user} has created a new column {column} in view {view}') + : $l->t('{user} has created a new column {column} in table {table}'); + break; + + case ActivityManager::SUBJECT_COLUMN_UPDATE: + $link = $hasViewContext ? $richParams['view']['link'] : $richParams['table']['link']; + $parsedSubject = $hasViewContext + ? $l->t('A column has been updated in view %2$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['view']['name'] ?? '', + ]) + : $l->t('A column has been updated in table %2$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['table']['name'] ?? '', + ]); + $subject = $hasViewContext + ? $l->t('{user} has updated the column {column} in view {view}') + : $l->t('{user} has updated the column {column} in table {table}'); + break; + + case ActivityManager::SUBJECT_COLUMN_DELETE: + $link = $hasViewContext ? $richParams['view']['link'] : $richParams['table']['link']; + $parsedSubject = $hasViewContext + ? $l->t('A column has been deleted from view %2$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['view']['name'] ?? '', + ]) + : $l->t('A column has been deleted from table %2$s by %1$s.', [ + $richParams['user']['name'] ?? '', + $richParams['table']['name'] ?? '', + ]); + $subject = $hasViewContext + ? $l->t('{user} has deleted the column {column} from view {view}') + : $l->t('{user} has deleted the column {column} from table {table}'); + break; + + default: + throw new UnknownNotificationException(); + } + + $notification->setParsedSubject($parsedSubject) + ->setRichSubject($subject, $richParams) + ->setLink($link); + + return $notification; + } + + private function tablesUrl(string $endpoint): string { + return $this->urlGenerator->linkToRouteAbsolute('tables.page.index') . '#/' . trim($endpoint, '/'); + } +} diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 1f7322e8f1..6a32dfb4d5 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -9,6 +9,7 @@ use DateTime; use Exception; +use OCA\Tables\Activity\ActivityManager; use OCA\Tables\Constants\ColumnType; use OCA\Tables\Db\Column; use OCA\Tables\Db\ColumnMapper; @@ -21,6 +22,7 @@ use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; use OCA\Tables\Helper\UserHelper; +use OCA\Tables\Notification\NotificationHelper; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\Title; use OCA\Tables\Service\ValueObject\ViewColumnInformation; @@ -48,6 +50,10 @@ class ColumnService extends SuperService { private ColumnDtoValidator $columnDtoValidator; + private ActivityManager $activityManager; + + private NotificationHelper $notificationHelper; + /** @var array Per-request cache of sorted column-id order, keyed by tableId. */ private array $columnOrderCache = []; @@ -59,6 +65,8 @@ public function __construct( TableMapper $tableMapper, ViewService $viewService, RowService $rowService, + ActivityManager $activityManager, + NotificationHelper $notificationHelper, IL10N $l, UserHelper $userHelper, ColumnDtoValidator $columnDtoValidator, @@ -68,6 +76,8 @@ public function __construct( $this->tableMapper = $tableMapper; $this->viewService = $viewService; $this->rowService = $rowService; + $this->activityManager = $activityManager; + $this->notificationHelper = $notificationHelper; $this->l = $l; $this->userHelper = $userHelper; $this->columnDtoValidator = $columnDtoValidator; @@ -335,6 +345,20 @@ public function create( $this->viewService->addColumnToView($view, $entity, $userId); } + + $this->activityManager->triggerEvent( + objectType: ActivityManager::TABLES_OBJECT_COLUMN, + object: $entity, + subject: ActivityManager::SUBJECT_COLUMN_CREATE, + author: $userId ?? $this->userId, + ); + $this->notificationHelper->sendNotification( + objectType: ActivityManager::TABLES_OBJECT_COLUMN, + object: $entity, + subject: ActivityManager::SUBJECT_COLUMN_CREATE, + author: $userId ?? $this->userId, + ); + return $this->enhanceColumn($entity); } @@ -411,7 +435,22 @@ public function update( $item->setCustomSettings($columnDto->getCustomSettings()); $this->updateMetadata($item, $userId); - return $this->enhanceColumn($this->mapper->update($item)); + $updatedColumn = $this->mapper->update($item); + + $this->activityManager->triggerEvent( + objectType: ActivityManager::TABLES_OBJECT_COLUMN, + object: $updatedColumn, + subject: ActivityManager::SUBJECT_COLUMN_UPDATE, + author: $userId ?? $this->userId, + ); + $this->notificationHelper->sendNotification( + objectType: ActivityManager::TABLES_OBJECT_COLUMN, + object: $updatedColumn, + subject: ActivityManager::SUBJECT_COLUMN_UPDATE, + author: $userId ?? $this->userId, + ); + + return $this->enhanceColumn($updatedColumn); } catch (BadRequestError $e) { throw $e; } catch (Exception $e) { @@ -533,7 +572,6 @@ public function delete(int $id, bool $skipRowCleanup = false, ?string $userId = $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } - $this->viewService->deleteColumnDataFromViews($id, $table); } try { @@ -542,6 +580,24 @@ public function delete(int $id, bool $skipRowCleanup = false, ?string $userId = $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + + $this->activityManager->triggerEvent( + objectType: ActivityManager::TABLES_OBJECT_COLUMN, + object: $item, + subject: ActivityManager::SUBJECT_COLUMN_DELETE, + author: $userId ?? $this->userId, + ); + $this->notificationHelper->sendNotification( + objectType: ActivityManager::TABLES_OBJECT_COLUMN, + object: $item, + subject: ActivityManager::SUBJECT_COLUMN_DELETE, + author: $userId ?? $this->userId, + ); + + if (!$skipRowCleanup) { + $this->viewService->deleteColumnDataFromViews($id, $table); + } + return $this->enhanceColumn($item); } diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php new file mode 100644 index 0000000000..5d31b734b4 --- /dev/null +++ b/lib/Service/ConfigService.php @@ -0,0 +1,132 @@ +userId) { + $user = \OCP\Server::get(IUserSession::class)->getUser(); + $this->userId = $user ? $user->getUID() : null; + } + + return $this->userId; + } + + /** + * @return string|bool + */ + public function get(string $key) { + $userId = $this->getUserId(); + if ($userId === null) { + return false; + } + + [$scope, $configKey] = explode(':', $key, 2); + + switch ($scope) { + case 'table': + case 'view': + [$tableId, $configKey] = explode(':', $configKey, 2); + if (in_array($configKey, self::NOTIFY_CONFIG_KEYS)) { + return $this->userConfig->getValueBool($userId, Application::APP_ID, $key); + } + } + + return false; + } + + public function set($key, $value) { + $userId = $this->getUserId(); + if ($userId === null) { + throw new PermissionError('Must be logged in to set user config'); + } + + $result = null; + [$scope, $configKey] = explode(':', $key, 2); + switch ($scope) { + case 'table': + case 'view': + [$tableId, $configKey] = explode(':', $configKey, 2); + if (!in_array($configKey, self::NOTIFY_CONFIG_KEYS)) { + throw new BadRequestError('Unknown configuration key: ' . $configKey); + } + if (!is_bool($value)) { + throw new BadRequestError('Invalid value for ' . $configKey . ' config, must be boolean'); + } + $this->userConfig->setValueBool($userId, Application::APP_ID, $key, $value); + $result = $value; + } + return $result; + } + + /** + * Get all configuration values for a specific table + * + * @param int $tableId + * @return array + */ + public function getTableConfig(int $tableId): array { + $userId = $this->getUserId(); + if ($userId === null) { + return []; + } + + $config = []; + + foreach (self::NOTIFY_CONFIG_KEYS as $key) { + $fullKey = 'table:' . $tableId . ':' . $key; + $config[$key] = $this->get($fullKey); + } + + return $config; + } + + /** + * Get all configuration values for a specific view + * + * @param int $tableId + * @return array + */ + public function getViewConfig(int $tableId): array { + $userId = $this->getUserId(); + if ($userId === null) { + return []; + } + + $config = []; + + foreach (self::NOTIFY_CONFIG_KEYS as $key) { + $fullKey = 'view:' . $tableId . ':' . $key; + $config[$key] = $this->get($fullKey); + } + + return $config; + } + + public function isNotifyEnabledForScope(string $userId, string $scope, int $scopeId, string $key): bool { + if (in_array($key, self::NOTIFY_CONFIG_KEYS)) { + return $this->userConfig->getValueBool($userId, Application::APP_ID, $scope . ':' . $scopeId . ':' . $key); + } + return false; + } +} diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 2c37ca335a..d25f20daaf 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -25,6 +25,7 @@ use OCA\Tables\Event\RowUpdatedEvent; use OCA\Tables\Helper\ColumnsHelper; use OCA\Tables\Model\RowDataInput; +use OCA\Tables\Notification\NotificationHelper; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ColumnTypes\IColumnTypeBusiness; use OCA\Tables\Service\ValueObject\ViewColumnInformation; @@ -56,6 +57,7 @@ public function __construct( private IEventDispatcher $eventDispatcher, private ColumnsHelper $columnsHelper, private ActivityManager $activityManager, + private NotificationHelper $notificationHelper, private IDBConnection $connection, ) { parent::__construct($logger, $userId, $permissionsService); @@ -269,6 +271,12 @@ public function create(?int $tableId, ?int $viewId, RowDataInput|array $data, ?s subject: ActivityManager::SUBJECT_ROW_CREATE, author: $this->userId, ); + $this->notificationHelper->sendNotification( + objectType: ActivityManager::TABLES_OBJECT_ROW, + object: $insertedRow, + subject: ActivityManager::SUBJECT_ROW_CREATE, + author: $this->userId + ); return $this->filterRowResult($view, $insertedRow); } catch (InternalError|Exception $e) { @@ -701,6 +709,16 @@ public function updateSet( 'after' => $updatedRow->getData(), ] ); + $this->notificationHelper->sendNotification( + objectType: ActivityManager::TABLES_OBJECT_ROW, + object: $updatedRow, + subject: ActivityManager::SUBJECT_ROW_UPDATE, + author: $this->userId, + additionalParams: [ + 'before' => $previousData, + 'after' => $updatedRow->getData(), + ] + ); } return $this->filterRowResult($view ?? null, $updatedRow); @@ -783,6 +801,12 @@ public function delete(int $id, ?int $viewId, string $userId, ?int $tableId = nu subject: ActivityManager::SUBJECT_ROW_DELETE, author: $this->userId, ); + $this->notificationHelper->sendNotification( + objectType: ActivityManager::TABLES_OBJECT_ROW, + object: $deletedRow, + subject: ActivityManager::SUBJECT_ROW_DELETE, + author: $this->userId, + ); return $this->filterRowResult($view ?? null, $deletedRow); } catch (Exception $e) { diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index 72f8a09524..fb11cee7dd 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -12,6 +12,7 @@ use DateTime; use InvalidArgumentException; use OCA\Circles\Model\Circle; +use OCA\Tables\Activity\ActivityManager; use OCA\Tables\AppInfo\Application; use OCA\Tables\Constants\ShareReceiverType; use OCA\Tables\Db\Context; @@ -43,6 +44,7 @@ use OCP\IUserManager; use OCP\Security\IHasher; use OCP\Security\ISecureRandom; +use OCP\Server; use OCP\Share\IManager as IShareManager; use Psr\Log\LoggerInterface; use Throwable; @@ -403,7 +405,10 @@ private function createNodeShare( throw new InternalError($e->getMessage()); } - return $this->addReceiverDisplayName($newShare); + $newShare = $this->addReceiverDisplayName($newShare); + $this->triggerShareActivity($newShare, ActivityManager::SUBJECT_SHARE_CREATE); + + return $newShare; } /** @@ -586,7 +591,10 @@ public function updatePermission(int $id, array $permissions): Share { } $share = $this->applyPermissions($item, $permissions); - return $this->addReceiverDisplayName($share); + $share = $this->addReceiverDisplayName($share); + $this->triggerShareActivity($share, ActivityManager::SUBJECT_SHARE_UPDATE); + + return $share; } /** @@ -641,11 +649,15 @@ public function delete(int $id): Share { throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + $item = $this->addReceiverDisplayName($item); + // security if (!$this->permissionsService->canManageElementById($item->getNodeId(), $item->getNodeType())) { throw new PermissionError('PermissionError: can not delete share with id ' . $id); } + $this->triggerShareActivity($item, ActivityManager::SUBJECT_SHARE_DELETE); + try { $this->mapper->delete($item); if ($item->getNodeType() === 'context') { @@ -655,7 +667,33 @@ public function delete(int $id): Share { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } - return $this->addReceiverDisplayName($item); + return $item; + } + + private function triggerShareActivity(Share $share, string $subject): void { + if (!in_array($share->getNodeType(), ['table', 'view'], true)) { + return; + } + + try { + if ($share->getNodeType() === 'table') { + $object = $this->tableMapper->find($share->getNodeId()); + $objectType = ActivityManager::TABLES_OBJECT_TABLE; + } else { + $object = $this->viewMapper->find($share->getNodeId()); + $objectType = ActivityManager::TABLES_OBJECT_VIEW; + } + + Server::get(ActivityManager::class)->triggerEvent( + $objectType, + $object, + $subject, + ['share' => $share], + $this->userId, + ); + } catch (Throwable $e) { + $this->logger->warning('Could not trigger share activity event: ' . $e->getMessage(), ['exception' => $e]); + } } /** @@ -817,6 +855,39 @@ public function findSharedWithUserIds(int $elementId, string $elementType): arra } } + /** + * Resolve a share receiver into concrete user ids. + * + * @return string[] + */ + public function findUserIdsForShareReceiver(string $receiverType, string $receiverId): array { + try { + if ($receiverType === ShareReceiverType::USER) { + return [$receiverId]; + } + + if ($receiverType === ShareReceiverType::GROUP) { + return $this->groupHelper->getUserIdsInGroup($receiverId); + } + + if ($receiverType === ShareReceiverType::CIRCLE) { + if (!$this->circleHelper->isCirclesEnabled()) { + return []; + } + + return $this->circleHelper->getUserIdsInCircle($receiverId); + } + } catch (Throwable $e) { + $this->logger->warning('Could not resolve users for share receiver: ' . $e->getMessage(), [ + 'receiverType' => $receiverType, + 'receiverId' => $receiverId, + 'exception' => $e, + ]); + } + + return []; + } + /** * @param int $nodeId * @param array $share diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index c79da495a9..8262c05f09 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -13,6 +13,8 @@ use Exception; use InvalidArgumentException; use JsonSerializable; +use OCA\Tables\Activity\ActivityManager; +use OCA\Tables\Activity\ChangeSet; use OCA\Tables\AppInfo\Application; use OCA\Tables\Constants\ViewUpdatableParameters; use OCA\Tables\Db\Column; @@ -55,6 +57,7 @@ class ViewService extends SuperService { private ContextService $contextService; protected IEventDispatcher $eventDispatcher; + private ActivityManager $activityManager; public function __construct( PermissionsService $permissionsService, @@ -68,6 +71,7 @@ public function __construct( IEventDispatcher $eventDispatcher, ContextService $contextService, IL10N $l, + ActivityManager $activityManager, ) { parent::__construct($logger, $userId, $permissionsService); $this->l = $l; @@ -78,6 +82,7 @@ public function __construct( $this->favoritesService = $favoritesService; $this->eventDispatcher = $eventDispatcher; $this->contextService = $contextService; + $this->activityManager = $activityManager; } /** @@ -224,6 +229,14 @@ public function create(string $title, ?string $emoji, Table $table, ?string $use throw new InternalError($e->getMessage()); } + $this->activityManager->triggerEvent( + objectType: ActivityManager::TABLES_OBJECT_VIEW, + object: $newItem, + subject: ActivityManager::SUBJECT_VIEW_CREATE, + additionalParams: [], + author: $userId, + ); + return $newItem; } @@ -237,6 +250,7 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b try { $view = $this->mapper->find($id); + $changes = new ChangeSet($view); // security if (!$this->permissionsService->canManageView($view, $userId)) { @@ -265,6 +279,12 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b if (!$skipTableEnhancement) { $this->enhanceView($view, $userId); } + $changes->setAfter($view); + $this->activityManager->triggerUpdateEvents( + objectType: ActivityManager::TABLES_OBJECT_VIEW, + changeSet: $changes, + subject: ActivityManager::SUBJECT_VIEW_UPDATE, + ); return $view; } catch (InvalidArgumentException $e) { throw $e; @@ -328,6 +348,13 @@ public function delete(int $id, ?string $userId = null): View { $event = new ViewDeletedEvent(view: $view); $this->eventDispatcher->dispatchTyped($event); + $this->activityManager->triggerEvent( + objectType: ActivityManager::TABLES_OBJECT_VIEW, + object: $view, + subject: ActivityManager::SUBJECT_VIEW_DELETE, + additionalParams: [], + author: $userId, + ); return $deletedView; } catch (\OCP\DB\Exception $e) { diff --git a/playwright/e2e/tables-import-export-scheme.spec.ts b/playwright/e2e/tables-import-export-scheme.spec.ts index 47165445a5..f03db4bc4c 100644 --- a/playwright/e2e/tables-import-export-scheme.spec.ts +++ b/playwright/e2e/tables-import-export-scheme.spec.ts @@ -222,7 +222,7 @@ test.describe('Import Export Scheme', () => { await importedViewRow.click() await page.locator('[data-cy="customTableAction"] button').first().click() - const editViewBtn = page.getByText('Edit view', { exact: true }) + const editViewBtn = page.getByText('View settings', { exact: true }) await editViewBtn.waitFor({ state: 'visible' }) await editViewBtn.click() diff --git a/playwright/e2e/tables-table.spec.ts b/playwright/e2e/tables-table.spec.ts index 7cbbb73dde..09623ae772 100644 --- a/playwright/e2e/tables-table.spec.ts +++ b/playwright/e2e/tables-table.spec.ts @@ -37,7 +37,7 @@ test.describe('Manage a table', () => { await createTable(page, 'to do list update desc') await loadTable(page, 'to do list update desc') await expect(page.locator('.icon-loading').first()).toBeHidden() - await clickOnNavigationTableMenu(page, 'to do list update desc', 'Edit table') + await clickOnNavigationTableMenu(page, 'to do list update desc', 'Table settings') await expect(page.locator('[data-cy="editTableModal"]')).toBeVisible() await page.locator('#description-editor .tiptap.ProseMirror').fill('Updated ToDo List description') @@ -84,7 +84,7 @@ test.describe('Manage a table', () => { await page.locator('[data-cy="createTableSubmitBtn"]').click() await loadTable(page, 'test table') - await clickOnTableThreeDotMenu(page, 'Edit table') + await clickOnTableThreeDotMenu(page, 'Table settings') await expect(page.locator('[data-cy="editTableModal"]')).toBeVisible() await page.locator('[data-cy="editTableModal"] button').filter({ hasText: 'Change owner' }).click() @@ -108,13 +108,13 @@ test.describe('Manage a table', () => { await expect(page.locator('.app-navigation__list').filter({ hasText: 'test table' })).toBeVisible() }) - test('Set column order in Edit Table modal', async ({ userPage: { page } }) => { + test('Set column order in Table settings modal', async ({ userPage: { page } }) => { await page.goto('/index.php/apps/tables') await createTable(page, 'Column order test table') await createTextLineColumn(page, 'colFirst', '', '', true) await createTextLineColumn(page, 'colSecond', '', '', false) - await clickOnTableThreeDotMenu(page, 'Edit table') + await clickOnTableThreeDotMenu(page, 'Table settings') await expect(page.locator('[data-cy="editTableModal"]')).toBeVisible() @@ -145,12 +145,12 @@ test.describe('Manage a table', () => { await expect(page.locator('.toastify.toast-success').first()).toBeVisible() }) - test('Set default sort in Edit Table modal', async ({ userPage: { page } }) => { + test('Set default sort in Table settings modal', async ({ userPage: { page } }) => { await page.goto('/index.php/apps/tables') await createTable(page, 'Default sort test table') await createTextLineColumn(page, 'name', '', '', true) - await clickOnTableThreeDotMenu(page, 'Edit table') + await clickOnTableThreeDotMenu(page, 'Table settings') await expect(page.locator('[data-cy="editTableModal"]')).toBeVisible() diff --git a/playwright/e2e/view-filtering-selection.spec.ts b/playwright/e2e/view-filtering-selection.spec.ts index a39d3144d9..8cd4ec0ef6 100644 --- a/playwright/e2e/view-filtering-selection.spec.ts +++ b/playwright/e2e/view-filtering-selection.spec.ts @@ -131,7 +131,7 @@ async function createFilteredView( async function openCurrentViewForEditing(page: Page) { await page.locator('[data-cy="customTableAction"] button').first().click() - const editViewBtn = page.getByText('Edit view', { exact: true }) + const editViewBtn = page.getByText('View settings', { exact: true }) await editViewBtn.waitFor({ state: 'visible' }) await editViewBtn.click() await expect(page.locator('[data-cy="viewSettingsDialog"]')).toBeVisible() diff --git a/playwright/support/commands.ts b/playwright/support/commands.ts index d5bd65db45..e0a9ede7a7 100644 --- a/playwright/support/commands.ts +++ b/playwright/support/commands.ts @@ -96,7 +96,7 @@ async function openTableActionsMenu(page: Page) { function getTableActionLocator(page: Page, optionName: string) { switch (optionName) { - case 'Edit table': + case 'Table settings': return page.locator('[data-cy="dataTableEditTableBtn"]') case 'Create view': return page.locator('[data-cy="dataTableCreateViewBtn"]') diff --git a/src/modules/main/sections/Dashboard.vue b/src/modules/main/sections/Dashboard.vue index 6361aa8f35..8960910aa1 100644 --- a/src/modules/main/sections/Dashboard.vue +++ b/src/modules/main/sections/Dashboard.vue @@ -61,13 +61,13 @@ - {{ t('tables', 'Edit view') }} + {{ t('tables', 'View settings') }} - {{ t('tables', 'Edit table') }} + {{ t('tables', 'Table settings') }} @@ -99,7 +99,7 @@ - {{ t('tables', 'Edit table') }} + {{ t('tables', 'Table settings') }} - {{ t('tables', 'Edit view') }} + {{ t('tables', 'View settings') }}