From f9dc3d75dade340ccafc359dfea3c94b9d1e2e9d Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Tue, 7 Apr 2026 01:53:14 +0300 Subject: [PATCH] feat: checklist notifications --- lib/BackgroundJob/ReopenRecurringItemsJob.php | 23 +++++++-- lib/Controller/ChecklistController.php | 3 ++ lib/Controller/PrefsController.php | 12 ++++- lib/Notification/Notifier.php | 51 +++++++++++++++++++ lib/ResponseDefinitions.php | 2 + lib/Service/ChecklistService.php | 6 ++- lib/Service/NotificationService.php | 30 +++++++++++ lib/Service/PrefsService.php | 2 + openapi.json | 22 +++++++- src/api/prefs.ts | 22 +++++++- .../AccountSettingsDialog.test.ts | 6 ++- .../AccountSettingsDialog.vue | 16 ++++++ tests/unit/Notification/NotifierTest.php | 32 ++++++++++++ tests/unit/Service/PrefsServiceTest.php | 10 +++- 14 files changed, 224 insertions(+), 13 deletions(-) diff --git a/lib/BackgroundJob/ReopenRecurringItemsJob.php b/lib/BackgroundJob/ReopenRecurringItemsJob.php index 4a42bc0..2bfd341 100644 --- a/lib/BackgroundJob/ReopenRecurringItemsJob.php +++ b/lib/BackgroundJob/ReopenRecurringItemsJob.php @@ -8,6 +8,7 @@ declare(strict_types=1); namespace OCA\Pantry\BackgroundJob; use OCA\Pantry\Service\ChecklistService; +use OCA\Pantry\Service\NotificationService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; use Psr\Log\LoggerInterface; @@ -20,6 +21,7 @@ class ReopenRecurringItemsJob extends TimedJob { public function __construct( ITimeFactory $time, private ChecklistService $lists, + private NotificationService $notifications, private LoggerInterface $logger, ) { parent::__construct($time); @@ -29,9 +31,24 @@ class ReopenRecurringItemsJob extends TimedJob { } protected function run(mixed $argument): void { - $count = $this->lists->reopenDueItems(); - if ($count > 0) { - $this->logger->info('Pantry: reopened {count} recurring item(s)', ['count' => $count]); + $reopened = $this->lists->reopenDueItems(); + if (count($reopened) > 0) { + $this->logger->info('Pantry: reopened {count} recurring item(s)', ['count' => count($reopened)]); + foreach ($reopened as $item) { + try { + $list = $this->lists->getList($item->getListId()); + $this->notifications->notifyItemRecurred( + $list->getHouseId(), + $item->getName(), + $list->getName(), + ); + } catch (\Throwable $e) { + $this->logger->warning('Pantry: failed to notify for recurring item {id}: {msg}', [ + 'id' => $item->getId(), + 'msg' => $e->getMessage(), + ]); + } + } } } } diff --git a/lib/Controller/ChecklistController.php b/lib/Controller/ChecklistController.php index 3e1d445..26bfeb0 100644 --- a/lib/Controller/ChecklistController.php +++ b/lib/Controller/ChecklistController.php @@ -14,6 +14,7 @@ use OCA\Pantry\Service\CategoryService; use OCA\Pantry\Service\ChecklistService; use OCA\Pantry\Service\HouseAuthService; use OCA\Pantry\Service\ImageService; +use OCA\Pantry\Service\NotificationService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -37,6 +38,7 @@ final class ChecklistController extends OCSController { private CategoryService $categories, private HouseAuthService $auth, private ImageService $images, + private NotificationService $notifications, private IUserSession $userSession, ) { parent::__construct($appName, $request); @@ -240,6 +242,7 @@ final class ChecklistController extends OCSController { 'repeatFromCompletion' => $repeatFromCompletion, 'sortOrder' => $sortOrder ?? 0, ]); + $this->notifications->notifyItemAdded($houseId, $this->requireUid(), $item->getName(), $list->getName()); return new DataResponse($item->jsonSerialize()); }); } diff --git a/lib/Controller/PrefsController.php b/lib/Controller/PrefsController.php index f0e589f..56b9935 100644 --- a/lib/Controller/PrefsController.php +++ b/lib/Controller/PrefsController.php @@ -151,6 +151,8 @@ final class PrefsController extends OCSController { * @param bool|null $notifyPhoto Photo upload notifications. * @param bool|null $notifyNoteCreate Note creation notifications. * @param bool|null $notifyNoteEdit Note edit notifications. + * @param bool|null $notifyItemAdd Checklist item added notifications. + * @param bool|null $notifyItemRecur Recurring item reappeared notifications. * * @return DataResponse * @@ -158,8 +160,8 @@ final class PrefsController extends OCSController { */ #[ApiRoute(verb: 'PUT', url: '/api/houses/{houseId}/prefs/notifications')] #[NoAdminRequired] - public function setNotificationPrefs(int $houseId, ?bool $notifyPhoto = null, ?bool $notifyNoteCreate = null, ?bool $notifyNoteEdit = null): DataResponse { - return $this->runAction(function () use ($houseId, $notifyPhoto, $notifyNoteCreate, $notifyNoteEdit): DataResponse { + public function setNotificationPrefs(int $houseId, ?bool $notifyPhoto = null, ?bool $notifyNoteCreate = null, ?bool $notifyNoteEdit = null, ?bool $notifyItemAdd = null, ?bool $notifyItemRecur = null): DataResponse { + return $this->runAction(function () use ($houseId, $notifyPhoto, $notifyNoteCreate, $notifyNoteEdit, $notifyItemAdd, $notifyItemRecur): DataResponse { $uid = $this->requireUid(); $this->auth->requireMember($houseId, $uid); if ($notifyPhoto !== null) { @@ -171,6 +173,12 @@ final class PrefsController extends OCSController { if ($notifyNoteEdit !== null) { $this->prefs->setNotificationPref($uid, $houseId, 'notify_note_edit', $notifyNoteEdit); } + if ($notifyItemAdd !== null) { + $this->prefs->setNotificationPref($uid, $houseId, 'notify_item_add', $notifyItemAdd); + } + if ($notifyItemRecur !== null) { + $this->prefs->setNotificationPref($uid, $houseId, 'notify_item_recur', $notifyItemRecur); + } return new DataResponse($this->prefs->getNotificationPrefs($uid, $houseId)); }); } diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index c9b7574..6c1eb8d 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -99,6 +99,57 @@ class Notifier implements INotifier { ); break; + case 'item_added': + $notification->setRichSubject( + $l->t('{user} added "{item}" to {list} in {house}'), + [ + 'user' => [ + 'type' => 'user', + 'id' => $params['userId'] ?? '', + 'name' => $params['userDisplayName'] ?? '', + ], + 'item' => [ + 'type' => 'highlight', + 'id' => 'item', + 'name' => $params['itemName'] ?? '', + ], + 'list' => [ + 'type' => 'highlight', + 'id' => 'list', + 'name' => $params['listName'] ?? '', + ], + 'house' => [ + 'type' => 'highlight', + 'id' => (string)($params['houseId'] ?? ''), + 'name' => $params['houseName'] ?? '', + ], + ] + ); + break; + + case 'item_recurred': + $notification->setRichSubject( + $l->t('"{item}" is back on {list} in {house}'), + [ + 'item' => [ + 'type' => 'highlight', + 'id' => 'item', + 'name' => $params['itemName'] ?? '', + ], + 'list' => [ + 'type' => 'highlight', + 'id' => 'list', + 'name' => $params['listName'] ?? '', + ], + 'house' => [ + 'type' => 'highlight', + 'id' => (string)($params['houseId'] ?? ''), + 'name' => $params['houseName'] ?? '', + ], + ] + ); + break; + default: throw new \InvalidArgumentException('Unknown notification'); } diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 1aa75b2..431efef 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -77,6 +77,8 @@ namespace OCA\Pantry; * notifyPhoto: bool, * notifyNoteCreate: bool, * notifyNoteEdit: bool, + * notifyItemAdd: bool, + * notifyItemRecur: bool, * } * * @psalm-type PantryPhotoFolder = array{ diff --git a/lib/Service/ChecklistService.php b/lib/Service/ChecklistService.php index a40438d..d7f4d46 100644 --- a/lib/Service/ChecklistService.php +++ b/lib/Service/ChecklistService.php @@ -247,8 +247,10 @@ class ChecklistService { * Reopen all recurring items whose next_due_at has passed. * * Called both lazily from listItems() and periodically by the background job. + * + * @return ChecklistItem[] The items that were reopened. */ - public function reopenDueItems(?int $now = null): int { + public function reopenDueItems(?int $now = null): array { $now ??= time(); $items = $this->itemMapper->findDueRecurring($now); foreach ($items as $item) { @@ -267,7 +269,7 @@ class ChecklistService { $item->setUpdatedAt($now); $this->itemMapper->update($item); } - return count($items); + return $items; } public function deleteItem(int $itemId): void { diff --git a/lib/Service/NotificationService.php b/lib/Service/NotificationService.php index dc6560e..5d095ad 100644 --- a/lib/Service/NotificationService.php +++ b/lib/Service/NotificationService.php @@ -17,6 +17,8 @@ class NotificationService { public const PREF_NOTIFY_PHOTO = 'notify_photo'; public const PREF_NOTIFY_NOTE_CREATE = 'notify_note_create'; public const PREF_NOTIFY_NOTE_EDIT = 'notify_note_edit'; + public const PREF_NOTIFY_ITEM_ADD = 'notify_item_add'; + public const PREF_NOTIFY_ITEM_RECUR = 'notify_item_recur'; public function __construct( private INotificationManager $notificationManager, @@ -71,6 +73,34 @@ class NotificationService { }); } + public function notifyItemAdded(int $houseId, string $authorUid, string $itemName, string $listName): void { + $this->sendToHouseMembers($houseId, $authorUid, 'item_added', 'item', self::PREF_NOTIFY_ITEM_ADD, function () use ($houseId, $authorUid, $itemName, $listName) { + $house = $this->houseService->get($houseId); + $author = $this->userManager->get($authorUid); + return [ + 'userId' => $authorUid, + 'userDisplayName' => $author ? $author->getDisplayName() : $authorUid, + 'houseId' => $houseId, + 'houseName' => $house->getName(), + 'itemName' => $itemName, + 'listName' => $listName, + ]; + }); + } + + public function notifyItemRecurred(int $houseId, string $itemName, string $listName): void { + // No author to exclude — this is a system event. + $this->sendToHouseMembers($houseId, '', 'item_recurred', 'item', self::PREF_NOTIFY_ITEM_RECUR, function () use ($houseId, $itemName, $listName) { + $house = $this->houseService->get($houseId); + return [ + 'houseId' => $houseId, + 'houseName' => $house->getName(), + 'itemName' => $itemName, + 'listName' => $listName, + ]; + }); + } + /** * @param callable():array $paramsFn Lazy parameter builder (only called if at least one member needs notification) */ diff --git a/lib/Service/PrefsService.php b/lib/Service/PrefsService.php index a952650..f46ddd0 100644 --- a/lib/Service/PrefsService.php +++ b/lib/Service/PrefsService.php @@ -81,6 +81,8 @@ class PrefsService { 'notifyPhoto' => $this->getNotificationPref($uid, $houseId, 'notify_photo'), 'notifyNoteCreate' => $this->getNotificationPref($uid, $houseId, 'notify_note_create'), 'notifyNoteEdit' => $this->getNotificationPref($uid, $houseId, 'notify_note_edit'), + 'notifyItemAdd' => $this->getNotificationPref($uid, $houseId, 'notify_item_add'), + 'notifyItemRecur' => $this->getNotificationPref($uid, $houseId, 'notify_item_recur'), ]; } diff --git a/openapi.json b/openapi.json index 14a6200..d59d17b 100644 --- a/openapi.json +++ b/openapi.json @@ -344,7 +344,9 @@ "required": [ "notifyPhoto", "notifyNoteCreate", - "notifyNoteEdit" + "notifyNoteEdit", + "notifyItemAdd", + "notifyItemRecur" ], "properties": { "notifyPhoto": { @@ -355,6 +357,12 @@ }, "notifyNoteEdit": { "type": "boolean" + }, + "notifyItemAdd": { + "type": "boolean" + }, + "notifyItemRecur": { + "type": "boolean" } } }, @@ -6107,6 +6115,18 @@ "nullable": true, "default": null, "description": "Note edit notifications." + }, + "notifyItemAdd": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Checklist item added notifications." + }, + "notifyItemRecur": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Recurring item reappeared notifications." } } } diff --git a/src/api/prefs.ts b/src/api/prefs.ts index 6f94c50..b9fa2a1 100644 --- a/src/api/prefs.ts +++ b/src/api/prefs.ts @@ -25,11 +25,21 @@ export interface NotificationPrefs { notifyPhoto: boolean notifyNoteCreate: boolean notifyNoteEdit: boolean + notifyItemAdd: boolean + notifyItemRecur: boolean } export async function getNotificationPrefs(houseId: number): Promise { const resp = await ocs.get(`/houses/${houseId}/prefs/notifications`) - return resp.data ?? { notifyPhoto: true, notifyNoteCreate: true, notifyNoteEdit: true } + return ( + resp.data ?? { + notifyPhoto: true, + notifyNoteCreate: true, + notifyNoteEdit: true, + notifyItemAdd: true, + notifyItemRecur: true, + } + ) } export async function setNotificationPrefs( @@ -37,5 +47,13 @@ export async function setNotificationPrefs( prefs: Partial, ): Promise { const resp = await ocs.put(`/houses/${houseId}/prefs/notifications`, prefs) - return resp.data ?? { notifyPhoto: true, notifyNoteCreate: true, notifyNoteEdit: true } + return ( + resp.data ?? { + notifyPhoto: true, + notifyNoteCreate: true, + notifyNoteEdit: true, + notifyItemAdd: true, + notifyItemRecur: true, + } + ) } diff --git a/src/components/AccountSettingsDialog/AccountSettingsDialog.test.ts b/src/components/AccountSettingsDialog/AccountSettingsDialog.test.ts index 0ee3221..74c6b85 100644 --- a/src/components/AccountSettingsDialog/AccountSettingsDialog.test.ts +++ b/src/components/AccountSettingsDialog/AccountSettingsDialog.test.ts @@ -221,12 +221,12 @@ describe('AccountSettingsDialog', () => { expect(getNotificationPrefs).toHaveBeenCalledWith(7) }) - it('renders three notification checkboxes', async () => { + it('renders five notification checkboxes', async () => { const wrapper = mountComponent({ open: true, houseId: 1 }) await flushPromises() const checkboxes = wrapper.findAll('.nc-checkbox') - expect(checkboxes).toHaveLength(3) + expect(checkboxes).toHaveLength(5) }) it('calls setNotificationPrefs when a checkbox is toggled', async () => { @@ -234,6 +234,8 @@ describe('AccountSettingsDialog', () => { notifyPhoto: false, notifyNoteCreate: true, notifyNoteEdit: true, + notifyItemAdd: true, + notifyItemRecur: true, }) const wrapper = mountComponent({ open: true, houseId: 3 }) diff --git a/src/components/AccountSettingsDialog/AccountSettingsDialog.vue b/src/components/AccountSettingsDialog/AccountSettingsDialog.vue index 588d4a3..dbc3a2a 100644 --- a/src/components/AccountSettingsDialog/AccountSettingsDialog.vue +++ b/src/components/AccountSettingsDialog/AccountSettingsDialog.vue @@ -52,6 +52,18 @@ > {{ strings.notifyNoteEdit }} + + {{ strings.notifyItemAdd }} + + + {{ strings.notifyItemRecur }} + @@ -142,6 +154,8 @@ const notifPrefs = reactive({ notifyPhoto: true, notifyNoteCreate: true, notifyNoteEdit: true, + notifyItemAdd: true, + notifyItemRecur: true, }) async function loadNotifPrefs() { @@ -187,6 +201,8 @@ const strings = { notifyPhoto: t('pantry', 'Photo uploads'), notifyNoteCreate: t('pantry', 'New notes'), notifyNoteEdit: t('pantry', 'Note edits'), + notifyItemAdd: t('pantry', 'Checklist items added'), + notifyItemRecur: t('pantry', 'Recurring items reappearing'), } diff --git a/tests/unit/Notification/NotifierTest.php b/tests/unit/Notification/NotifierTest.php index 7dadf89..20f8ac7 100644 --- a/tests/unit/Notification/NotifierTest.php +++ b/tests/unit/Notification/NotifierTest.php @@ -125,6 +125,38 @@ class NotifierTest extends TestCase { $this->assertSame($n, $result); } + public function testItemAddedSetsRichSubject(): void { + $n = $this->makeNotification('pantry', 'item_added', [ + 'userId' => 'alice', + 'userDisplayName' => 'Alice', + 'houseId' => 1, + 'houseName' => 'My House', + 'itemName' => 'Milk', + 'listName' => 'Groceries', + ]); + + $n->expects($this->once())->method('setRichSubject'); + $n->expects($this->once())->method('setParsedSubject'); + + $result = $this->notifier->prepare($n, 'en'); + $this->assertSame($n, $result); + } + + public function testItemRecurredSetsRichSubject(): void { + $n = $this->makeNotification('pantry', 'item_recurred', [ + 'houseId' => 1, + 'houseName' => 'My House', + 'itemName' => 'Milk', + 'listName' => 'Groceries', + ]); + + $n->expects($this->once())->method('setRichSubject'); + $n->expects($this->once())->method('setParsedSubject'); + + $result = $this->notifier->prepare($n, 'en'); + $this->assertSame($n, $result); + } + public function testParsedSubjectReplacesPlaceholders(): void { $parsedSubject = ''; $n = $this->makeNotification('pantry', 'photo_uploaded', [ diff --git a/tests/unit/Service/PrefsServiceTest.php b/tests/unit/Service/PrefsServiceTest.php index 147036f..d22260f 100644 --- a/tests/unit/Service/PrefsServiceTest.php +++ b/tests/unit/Service/PrefsServiceTest.php @@ -57,7 +57,7 @@ class PrefsServiceTest extends TestCase { $this->svc->setNotificationPref('bob', 5, 'notify_note_create', true); } - public function testGetNotificationPrefsReturnsAllThree(): void { + public function testGetNotificationPrefsReturnsAll(): void { $this->config->method('getUserValue')->willReturnCallback( function (string $uid, string $app, string $key, string $default): string { if ($key === 'notify_photo_1') { @@ -69,6 +69,12 @@ class PrefsServiceTest extends TestCase { if ($key === 'notify_note_edit_1') { return '0'; } + if ($key === 'notify_item_add_1') { + return '1'; + } + if ($key === 'notify_item_recur_1') { + return '0'; + } return $default; } ); @@ -78,6 +84,8 @@ class PrefsServiceTest extends TestCase { 'notifyPhoto' => false, 'notifyNoteCreate' => true, 'notifyNoteEdit' => false, + 'notifyItemAdd' => true, + 'notifyItemRecur' => false, ], $result); }