mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat: updated notifications, refactored bought -> done
This commit is contained in:
@@ -7,6 +7,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Pantry\BackgroundJob;
|
||||
|
||||
use OCA\Pantry\Db\ChecklistItem;
|
||||
use OCA\Pantry\Service\ChecklistService;
|
||||
use OCA\Pantry\Service\NotificationService;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
@@ -14,8 +15,10 @@ use OCP\BackgroundJob\TimedJob;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Periodically reopens recurring checklist items whose next_due_at has
|
||||
* passed, so they appear unchecked without waiting for a user to open the list.
|
||||
* Periodically processes recurring checklist items:
|
||||
* 1. Reopens done items whose next_due_at has passed.
|
||||
* 2. Re-notifies for undone fixed-schedule items whose next_due_at has passed
|
||||
* (the item is already visible but the schedule ticked again — nudge).
|
||||
*/
|
||||
class ReopenRecurringItemsJob extends TimedJob {
|
||||
public function __construct(
|
||||
@@ -31,23 +34,53 @@ class ReopenRecurringItemsJob extends TimedJob {
|
||||
}
|
||||
|
||||
protected function run(mixed $argument): void {
|
||||
// 1. Reopen done items whose schedule has passed.
|
||||
$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(
|
||||
$this->notifyGrouped($reopened, 'recurred');
|
||||
}
|
||||
|
||||
// 2. Nudge for undone fixed-schedule items whose schedule ticked again.
|
||||
$reminded = $this->lists->advanceDueReminders();
|
||||
if (count($reminded) > 0) {
|
||||
$this->logger->info('Pantry: sent reminders for {count} undone item(s)', ['count' => count($reminded)]);
|
||||
$this->notifyGrouped($reminded, 'reminder');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group items by list and send one notification per list.
|
||||
*
|
||||
* @param ChecklistItem[] $items
|
||||
*/
|
||||
private function notifyGrouped(array $items, string $type): void {
|
||||
$byList = [];
|
||||
foreach ($items as $item) {
|
||||
$byList[$item->getListId()][] = $item->getName();
|
||||
}
|
||||
foreach ($byList as $listId => $itemNames) {
|
||||
try {
|
||||
$list = $this->lists->getList($listId);
|
||||
if ($type === 'recurred') {
|
||||
$this->notifications->notifyItemsRecurred(
|
||||
$list->getHouseId(),
|
||||
$item->getName(),
|
||||
$itemNames,
|
||||
$list->getName(),
|
||||
);
|
||||
} else {
|
||||
$this->notifications->notifyItemsReminder(
|
||||
$list->getHouseId(),
|
||||
$itemNames,
|
||||
$list->getName(),
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Pantry: failed to notify for recurring item {id}: {msg}', [
|
||||
'id' => $item->getId(),
|
||||
'msg' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Pantry: failed to notify for {type} items in list {id}: {msg}', [
|
||||
'type' => $type,
|
||||
'id' => $listId,
|
||||
'msg' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ final class ChecklistController extends OCSController {
|
||||
* @param int|null $categoryId Optional category id (must belong to the same house).
|
||||
* @param string|null $quantity Optional quantity string.
|
||||
* @param string|null $rrule Optional RFC 5545 RRULE for recurrence.
|
||||
* @param bool $repeatFromCompletion If true, the next occurrence is measured from when the item is marked bought; if false, the schedule is anchored at item creation.
|
||||
* @param bool $repeatFromCompletion If true, the next occurrence is measured from when the item is marked done; if false, the schedule is anchored at item creation.
|
||||
* @param int|null $sortOrder Optional sort order.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
|
||||
@@ -320,7 +320,7 @@ final class ChecklistController extends OCSController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle an item's bought status
|
||||
* Toggle an item's done status
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $listId List id.
|
||||
@@ -343,6 +343,9 @@ final class ChecklistController extends OCSController {
|
||||
throw new NotFoundException('Item does not belong to this list');
|
||||
}
|
||||
$toggled = $this->lists->toggleItem($itemId, $uid);
|
||||
if ($toggled->getDone()) {
|
||||
$this->notifications->notifyItemDone($houseId, $uid, $toggled->getName(), $list->getName());
|
||||
}
|
||||
return new DataResponse($toggled->jsonSerialize());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ final class PrefsController extends OCSController {
|
||||
* @param bool|null $notifyNoteEdit Note edit notifications.
|
||||
* @param bool|null $notifyItemAdd Checklist item added notifications.
|
||||
* @param bool|null $notifyItemRecur Recurring item reappeared notifications.
|
||||
* @param bool|null $notifyItemDone Item completed notifications.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryNotificationPrefs, array{}>
|
||||
*
|
||||
@@ -160,8 +161,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, ?bool $notifyItemAdd = null, ?bool $notifyItemRecur = null): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $notifyPhoto, $notifyNoteCreate, $notifyNoteEdit, $notifyItemAdd, $notifyItemRecur): DataResponse {
|
||||
public function setNotificationPrefs(int $houseId, ?bool $notifyPhoto = null, ?bool $notifyNoteCreate = null, ?bool $notifyNoteEdit = null, ?bool $notifyItemAdd = null, ?bool $notifyItemRecur = null, ?bool $notifyItemDone = null): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $notifyPhoto, $notifyNoteCreate, $notifyNoteEdit, $notifyItemAdd, $notifyItemRecur, $notifyItemDone): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$this->auth->requireMember($houseId, $uid);
|
||||
if ($notifyPhoto !== null) {
|
||||
@@ -179,6 +180,9 @@ final class PrefsController extends OCSController {
|
||||
if ($notifyItemRecur !== null) {
|
||||
$this->prefs->setNotificationPref($uid, $houseId, 'notify_item_recur', $notifyItemRecur);
|
||||
}
|
||||
if ($notifyItemDone !== null) {
|
||||
$this->prefs->setNotificationPref($uid, $houseId, 'notify_item_done', $notifyItemDone);
|
||||
}
|
||||
return new DataResponse($this->prefs->getNotificationPrefs($uid, $houseId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setCategoryId(?int $categoryId)
|
||||
* @method string|null getQuantity()
|
||||
* @method void setQuantity(?string $quantity)
|
||||
* @method bool getBought()
|
||||
* @method void setBought(bool $bought)
|
||||
* @method int|null getBoughtAt()
|
||||
* @method void setBoughtAt(?int $boughtAt)
|
||||
* @method string|null getBoughtBy()
|
||||
* @method void setBoughtBy(?string $boughtBy)
|
||||
* @method bool getDone()
|
||||
* @method void setDone(bool $done)
|
||||
* @method int|null getDoneAt()
|
||||
* @method void setDoneAt(?int $doneAt)
|
||||
* @method string|null getDoneBy()
|
||||
* @method void setDoneBy(?string $doneBy)
|
||||
* @method string|null getRrule()
|
||||
* @method void setRrule(?string $rrule)
|
||||
* @method bool getRepeatFromCompletion()
|
||||
@@ -44,9 +44,9 @@ class ChecklistItem extends Entity implements \JsonSerializable {
|
||||
protected string $name = '';
|
||||
protected ?int $categoryId = null;
|
||||
protected ?string $quantity = null;
|
||||
protected bool $bought = false;
|
||||
protected ?int $boughtAt = null;
|
||||
protected ?string $boughtBy = null;
|
||||
protected bool $done = false;
|
||||
protected ?int $doneAt = null;
|
||||
protected ?string $doneBy = null;
|
||||
protected ?string $rrule = null;
|
||||
protected bool $repeatFromCompletion = false;
|
||||
protected ?int $nextDueAt = null;
|
||||
@@ -58,8 +58,8 @@ class ChecklistItem extends Entity implements \JsonSerializable {
|
||||
public function __construct() {
|
||||
$this->addType('listId', 'integer');
|
||||
$this->addType('categoryId', 'integer');
|
||||
$this->addType('bought', 'boolean');
|
||||
$this->addType('boughtAt', 'integer');
|
||||
$this->addType('done', 'boolean');
|
||||
$this->addType('doneAt', 'integer');
|
||||
$this->addType('repeatFromCompletion', 'boolean');
|
||||
$this->addType('nextDueAt', 'integer');
|
||||
$this->addType('imageFileId', 'integer');
|
||||
@@ -70,7 +70,7 @@ class ChecklistItem extends Entity implements \JsonSerializable {
|
||||
// match the initial value, so the magic setter would otherwise never
|
||||
// mark them dirty and the column would be omitted from the INSERT.
|
||||
// fromRow() resets updated fields after hydration, so reads are unaffected.
|
||||
$this->markFieldUpdated('bought');
|
||||
$this->markFieldUpdated('done');
|
||||
$this->markFieldUpdated('repeatFromCompletion');
|
||||
}
|
||||
|
||||
@@ -81,9 +81,9 @@ class ChecklistItem extends Entity implements \JsonSerializable {
|
||||
'name' => $this->name,
|
||||
'categoryId' => $this->categoryId,
|
||||
'quantity' => $this->quantity,
|
||||
'bought' => $this->bought,
|
||||
'boughtAt' => $this->boughtAt,
|
||||
'boughtBy' => $this->boughtBy,
|
||||
'done' => $this->done,
|
||||
'doneAt' => $this->doneAt,
|
||||
'doneBy' => $this->doneBy,
|
||||
'rrule' => $this->rrule,
|
||||
'repeatFromCompletion' => $this->repeatFromCompletion,
|
||||
'nextDueAt' => $this->nextDueAt,
|
||||
|
||||
@@ -48,7 +48,7 @@ class ChecklistItemMapper extends QBMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all bought items whose next_due_at has passed.
|
||||
* Find all done items whose next_due_at has passed.
|
||||
*
|
||||
* @return ChecklistItem[]
|
||||
*/
|
||||
@@ -56,7 +56,26 @@ class ChecklistItemMapper extends QBMapper {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('bought', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)))
|
||||
->where($qb->expr()->eq('done', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)))
|
||||
->andWhere($qb->expr()->isNotNull('next_due_at'))
|
||||
->andWhere($qb->expr()->lte('next_due_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find undone fixed-schedule items whose next_due_at has passed.
|
||||
* These are items the user never checked off but whose schedule has ticked again.
|
||||
*
|
||||
* @return ChecklistItem[]
|
||||
*/
|
||||
public function findDueFixedScheduleUndone(int $now): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('done', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
|
||||
->andWhere($qb->expr()->eq('repeat_from_completion', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
|
||||
->andWhere($qb->expr()->isNotNull('rrule'))
|
||||
->andWhere($qb->expr()->isNotNull('next_due_at'))
|
||||
->andWhere($qb->expr()->lte('next_due_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
|
||||
@@ -194,14 +194,14 @@ class Version1Date20260405000000 extends SimpleMigrationStep {
|
||||
'notnull' => false,
|
||||
'length' => 64,
|
||||
]);
|
||||
$table->addColumn('bought', Types::BOOLEAN, [
|
||||
$table->addColumn('done', Types::BOOLEAN, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
$table->addColumn('bought_at', Types::BIGINT, [
|
||||
$table->addColumn('done_at', Types::BIGINT, [
|
||||
'notnull' => false,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('bought_by', Types::STRING, [
|
||||
$table->addColumn('done_by', Types::STRING, [
|
||||
'notnull' => false,
|
||||
'length' => 64,
|
||||
]);
|
||||
|
||||
@@ -127,10 +127,15 @@ class Notifier implements INotifier {
|
||||
);
|
||||
break;
|
||||
|
||||
case 'item_recurred':
|
||||
case 'item_done':
|
||||
$notification->setRichSubject(
|
||||
$l->t('"{item}" is back on {list} in {house}'),
|
||||
$l->t('{user} completed "{item}" on {list} in {house}'),
|
||||
[
|
||||
'user' => [
|
||||
'type' => 'user',
|
||||
'id' => $params['userId'] ?? '',
|
||||
'name' => $params['userDisplayName'] ?? '',
|
||||
],
|
||||
'item' => [
|
||||
'type' => 'highlight',
|
||||
'id' => 'item',
|
||||
@@ -150,6 +155,62 @@ class Notifier implements INotifier {
|
||||
);
|
||||
break;
|
||||
|
||||
case 'item_reminder':
|
||||
$names = $params['itemNames'] ?? [];
|
||||
$count = (int)($params['itemCount'] ?? count($names));
|
||||
$reminderLabel = $count <= 3
|
||||
? implode(', ', $names)
|
||||
: $l->n('%n item', '%n items', $count);
|
||||
$notification->setRichSubject(
|
||||
$l->t('{items} still undone on {list} in {house}'),
|
||||
[
|
||||
'items' => [
|
||||
'type' => 'highlight',
|
||||
'id' => 'items',
|
||||
'name' => $reminderLabel,
|
||||
],
|
||||
'list' => [
|
||||
'type' => 'highlight',
|
||||
'id' => 'list',
|
||||
'name' => $params['listName'] ?? '',
|
||||
],
|
||||
'house' => [
|
||||
'type' => 'highlight',
|
||||
'id' => (string)($params['houseId'] ?? ''),
|
||||
'name' => $params['houseName'] ?? '',
|
||||
],
|
||||
]
|
||||
);
|
||||
break;
|
||||
|
||||
case 'item_recurred':
|
||||
$names = $params['itemNames'] ?? [];
|
||||
$count = (int)($params['itemCount'] ?? count($names));
|
||||
$itemLabel = $count <= 3
|
||||
? implode(', ', $names)
|
||||
: $l->n('%n item', '%n items', $count);
|
||||
$notification->setRichSubject(
|
||||
$l->t('{items} back on {list} in {house}'),
|
||||
[
|
||||
'items' => [
|
||||
'type' => 'highlight',
|
||||
'id' => 'items',
|
||||
'name' => $itemLabel,
|
||||
],
|
||||
'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');
|
||||
}
|
||||
|
||||
@@ -44,9 +44,9 @@ namespace OCA\Pantry;
|
||||
* name: string,
|
||||
* categoryId: int|null,
|
||||
* quantity: string|null,
|
||||
* bought: bool,
|
||||
* boughtAt: int|null,
|
||||
* boughtBy: string|null,
|
||||
* done: bool,
|
||||
* doneAt: int|null,
|
||||
* doneBy: string|null,
|
||||
* rrule: string|null,
|
||||
* repeatFromCompletion: bool,
|
||||
* nextDueAt: int|null,
|
||||
@@ -79,6 +79,7 @@ namespace OCA\Pantry;
|
||||
* notifyNoteEdit: bool,
|
||||
* notifyItemAdd: bool,
|
||||
* notifyItemRecur: bool,
|
||||
* notifyItemDone: bool,
|
||||
* }
|
||||
*
|
||||
* @psalm-type PantryPhotoFolder = array{
|
||||
|
||||
@@ -136,12 +136,18 @@ class ChecklistService {
|
||||
$item->setName($name);
|
||||
$item->setCategoryId($this->intOrNull($data['categoryId'] ?? null));
|
||||
$item->setQuantity($this->strOrNull($data['quantity'] ?? null));
|
||||
$item->setBought(false);
|
||||
$item->setBoughtAt(null);
|
||||
$item->setBoughtBy(null);
|
||||
$item->setDone(false);
|
||||
$item->setDoneAt(null);
|
||||
$item->setDoneBy(null);
|
||||
$item->setRrule($rrule);
|
||||
$item->setRepeatFromCompletion(!empty($data['repeatFromCompletion']));
|
||||
$item->setNextDueAt(null);
|
||||
$repeatFromCompletion = !empty($data['repeatFromCompletion']);
|
||||
$item->setRepeatFromCompletion($repeatFromCompletion);
|
||||
// For fixed-schedule items, compute the first due time immediately.
|
||||
if ($rrule !== null && !$repeatFromCompletion) {
|
||||
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
|
||||
} else {
|
||||
$item->setNextDueAt(null);
|
||||
}
|
||||
$item->setImageFileId($this->intOrNull($data['imageFileId'] ?? null));
|
||||
$item->setSortOrder(isset($data['sortOrder']) ? (int)$data['sortOrder'] : 0);
|
||||
$item->setCreatedAt($now);
|
||||
@@ -172,7 +178,7 @@ class ChecklistService {
|
||||
if ($rrule === null || (is_string($rrule) && trim($rrule) === '')) {
|
||||
$item->setRrule(null);
|
||||
// Clearing recurrence also clears any scheduled re-open.
|
||||
if ($item->getBought()) {
|
||||
if ($item->getDone()) {
|
||||
$item->setNextDueAt(null);
|
||||
}
|
||||
} else {
|
||||
@@ -187,8 +193,8 @@ class ChecklistService {
|
||||
if (array_key_exists('imageFileId', $patch)) {
|
||||
$item->setImageFileId($this->intOrNull($patch['imageFileId']));
|
||||
}
|
||||
// If already bought and rrule or mode changed, recompute next due.
|
||||
if ($item->getBought() && $item->getRrule() !== null
|
||||
// If already done and rrule or mode changed, recompute next due.
|
||||
if ($item->getDone() && $item->getRrule() !== null
|
||||
&& (array_key_exists('rrule', $patch) || array_key_exists('repeatFromCompletion', $patch))) {
|
||||
$item->setNextDueAt($this->computeNextDueAt($item, time())?->getTimestamp());
|
||||
}
|
||||
@@ -205,17 +211,17 @@ class ChecklistService {
|
||||
$item = $this->getItem($itemId);
|
||||
$now ??= time();
|
||||
|
||||
if (!$item->getBought()) {
|
||||
$item->setBought(true);
|
||||
$item->setBoughtAt($now);
|
||||
$item->setBoughtBy($uid);
|
||||
if (!$item->getDone()) {
|
||||
$item->setDone(true);
|
||||
$item->setDoneAt($now);
|
||||
$item->setDoneBy($uid);
|
||||
if ($item->getRrule() !== null) {
|
||||
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
|
||||
}
|
||||
} else {
|
||||
$item->setBought(false);
|
||||
$item->setBoughtAt(null);
|
||||
$item->setBoughtBy(null);
|
||||
$item->setDone(false);
|
||||
$item->setDoneAt(null);
|
||||
$item->setDoneBy(null);
|
||||
$item->setNextDueAt(null);
|
||||
}
|
||||
$item->setUpdatedAt($now);
|
||||
@@ -224,7 +230,7 @@ class ChecklistService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next due time for an item that was just marked bought.
|
||||
* Compute the next due time for an item that was just marked done.
|
||||
*
|
||||
* - "from completion" mode: interval counts from now — next occurrence = now + one step.
|
||||
* - "fixed schedule" mode: the schedule is anchored at the item's creation time; next
|
||||
@@ -254,9 +260,9 @@ class ChecklistService {
|
||||
$now ??= time();
|
||||
$items = $this->itemMapper->findDueRecurring($now);
|
||||
foreach ($items as $item) {
|
||||
$item->setBought(false);
|
||||
$item->setBoughtAt(null);
|
||||
$item->setBoughtBy(null);
|
||||
$item->setDone(false);
|
||||
$item->setDoneAt(null);
|
||||
$item->setDoneBy(null);
|
||||
if ($item->getRepeatFromCompletion()) {
|
||||
// Completion-based: next interval starts when the user checks
|
||||
// the item off again, so clear the schedule for now.
|
||||
@@ -272,6 +278,26 @@ class ChecklistService {
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance fixed-schedule undone items whose next_due_at has passed.
|
||||
*
|
||||
* Unlike reopenDueItems(), these items are already undone — nothing to
|
||||
* reopen. We just bump next_due_at to the next occurrence so the
|
||||
* background job can re-notify on the next cycle.
|
||||
*
|
||||
* @return ChecklistItem[] The items whose schedule was advanced.
|
||||
*/
|
||||
public function advanceDueReminders(?int $now = null): array {
|
||||
$now ??= time();
|
||||
$items = $this->itemMapper->findDueFixedScheduleUndone($now);
|
||||
foreach ($items as $item) {
|
||||
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
|
||||
$item->setUpdatedAt($now);
|
||||
$this->itemMapper->update($item);
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function deleteItem(int $itemId): void {
|
||||
$item = $this->getItem($itemId);
|
||||
$this->itemMapper->delete($item);
|
||||
|
||||
@@ -19,6 +19,7 @@ class NotificationService {
|
||||
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 const PREF_NOTIFY_ITEM_DONE = 'notify_item_done';
|
||||
|
||||
public function __construct(
|
||||
private INotificationManager $notificationManager,
|
||||
@@ -88,14 +89,57 @@ class NotificationService {
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
public function notifyItemDone(int $houseId, string $authorUid, string $itemName, string $listName): void {
|
||||
$this->sendToHouseMembers($houseId, $authorUid, 'item_done', 'item', self::PREF_NOTIFY_ITEM_DONE, 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,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify that undone fixed-schedule items are due again (reminder nudge).
|
||||
*
|
||||
* @param string[] $itemNames Names of the items that are still undone.
|
||||
*/
|
||||
public function notifyItemsReminder(int $houseId, array $itemNames, string $listName): void {
|
||||
if (empty($itemNames)) {
|
||||
return;
|
||||
}
|
||||
$this->sendToHouseMembers($houseId, '', 'item_reminder', 'item', self::PREF_NOTIFY_ITEM_RECUR, function () use ($houseId, $itemNames, $listName) {
|
||||
$house = $this->houseService->get($houseId);
|
||||
return [
|
||||
'houseId' => $houseId,
|
||||
'houseName' => $house->getName(),
|
||||
'itemName' => $itemName,
|
||||
'itemNames' => $itemNames,
|
||||
'itemCount' => count($itemNames),
|
||||
'listName' => $listName,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $itemNames Names of the items that recurred.
|
||||
*/
|
||||
public function notifyItemsRecurred(int $houseId, array $itemNames, string $listName): void {
|
||||
if (empty($itemNames)) {
|
||||
return;
|
||||
}
|
||||
// No author to exclude — this is a system event.
|
||||
$this->sendToHouseMembers($houseId, '', 'item_recurred', 'item', self::PREF_NOTIFY_ITEM_RECUR, function () use ($houseId, $itemNames, $listName) {
|
||||
$house = $this->houseService->get($houseId);
|
||||
return [
|
||||
'houseId' => $houseId,
|
||||
'houseName' => $house->getName(),
|
||||
'itemNames' => $itemNames,
|
||||
'itemCount' => count($itemNames),
|
||||
'listName' => $listName,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -83,6 +83,7 @@ class PrefsService {
|
||||
'notifyNoteEdit' => $this->getNotificationPref($uid, $houseId, 'notify_note_edit'),
|
||||
'notifyItemAdd' => $this->getNotificationPref($uid, $houseId, 'notify_item_add'),
|
||||
'notifyItemRecur' => $this->getNotificationPref($uid, $houseId, 'notify_item_recur'),
|
||||
'notifyItemDone' => $this->getNotificationPref($uid, $houseId, 'notify_item_done'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
28
openapi.json
28
openapi.json
@@ -180,9 +180,9 @@
|
||||
"name",
|
||||
"categoryId",
|
||||
"quantity",
|
||||
"bought",
|
||||
"boughtAt",
|
||||
"boughtBy",
|
||||
"done",
|
||||
"doneAt",
|
||||
"doneBy",
|
||||
"rrule",
|
||||
"repeatFromCompletion",
|
||||
"nextDueAt",
|
||||
@@ -212,15 +212,15 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"bought": {
|
||||
"done": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"boughtAt": {
|
||||
"doneAt": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"boughtBy": {
|
||||
"doneBy": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
@@ -346,7 +346,8 @@
|
||||
"notifyNoteCreate",
|
||||
"notifyNoteEdit",
|
||||
"notifyItemAdd",
|
||||
"notifyItemRecur"
|
||||
"notifyItemRecur",
|
||||
"notifyItemDone"
|
||||
],
|
||||
"properties": {
|
||||
"notifyPhoto": {
|
||||
@@ -363,6 +364,9 @@
|
||||
},
|
||||
"notifyItemRecur": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"notifyItemDone": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1798,7 +1802,7 @@
|
||||
"repeatFromCompletion": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "If true, the next occurrence is measured from when the item is marked bought; if false, the schedule is anchored at item creation."
|
||||
"description": "If true, the next occurrence is measured from when the item is marked done; if false, the schedule is anchored at item creation."
|
||||
},
|
||||
"sortOrder": {
|
||||
"type": "integer",
|
||||
@@ -2202,7 +2206,7 @@
|
||||
"/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}/items/{itemId}/toggle": {
|
||||
"post": {
|
||||
"operationId": "checklist-toggle-item",
|
||||
"summary": "Toggle an item's bought status",
|
||||
"summary": "Toggle an item's done status",
|
||||
"tags": [
|
||||
"checklist"
|
||||
],
|
||||
@@ -6127,6 +6131,12 @@
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Recurring item reappeared notifications."
|
||||
},
|
||||
"notifyItemDone": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Item completed notifications."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface NotificationPrefs {
|
||||
notifyNoteEdit: boolean
|
||||
notifyItemAdd: boolean
|
||||
notifyItemRecur: boolean
|
||||
notifyItemDone: boolean
|
||||
}
|
||||
|
||||
export async function getNotificationPrefs(houseId: number): Promise<NotificationPrefs> {
|
||||
@@ -38,6 +39,7 @@ export async function getNotificationPrefs(houseId: number): Promise<Notificatio
|
||||
notifyNoteEdit: true,
|
||||
notifyItemAdd: true,
|
||||
notifyItemRecur: true,
|
||||
notifyItemDone: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -54,6 +56,7 @@ export async function setNotificationPrefs(
|
||||
notifyNoteEdit: true,
|
||||
notifyItemAdd: true,
|
||||
notifyItemRecur: true,
|
||||
notifyItemDone: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ export interface ChecklistItem {
|
||||
name: string
|
||||
categoryId: number | null
|
||||
quantity: string | null
|
||||
bought: boolean
|
||||
boughtAt: number | null
|
||||
boughtBy: string | null
|
||||
done: boolean
|
||||
doneAt: number | null
|
||||
doneBy: string | null
|
||||
rrule: string | null
|
||||
repeatFromCompletion: boolean
|
||||
nextDueAt: number | null
|
||||
|
||||
@@ -221,12 +221,12 @@ describe('AccountSettingsDialog', () => {
|
||||
expect(getNotificationPrefs).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('renders five notification checkboxes', async () => {
|
||||
it('renders six notification checkboxes', async () => {
|
||||
const wrapper = mountComponent({ open: true, houseId: 1 })
|
||||
await flushPromises()
|
||||
|
||||
const checkboxes = wrapper.findAll('.nc-checkbox')
|
||||
expect(checkboxes).toHaveLength(5)
|
||||
expect(checkboxes).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('calls setNotificationPrefs when a checkbox is toggled', async () => {
|
||||
@@ -236,6 +236,7 @@ describe('AccountSettingsDialog', () => {
|
||||
notifyNoteEdit: true,
|
||||
notifyItemAdd: true,
|
||||
notifyItemRecur: true,
|
||||
notifyItemDone: true,
|
||||
})
|
||||
|
||||
const wrapper = mountComponent({ open: true, houseId: 3 })
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
>
|
||||
{{ strings.notifyItemRecur }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcCheckboxRadioSwitch
|
||||
:model-value="notifPrefs.notifyItemDone"
|
||||
@update:model-value="updateNotifPref('notifyItemDone', $event)"
|
||||
>
|
||||
{{ strings.notifyItemDone }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
</NcAppSettingsSection>
|
||||
</NcAppSettingsDialog>
|
||||
@@ -156,6 +162,7 @@ const notifPrefs = reactive<NotificationPrefs>({
|
||||
notifyNoteEdit: true,
|
||||
notifyItemAdd: true,
|
||||
notifyItemRecur: true,
|
||||
notifyItemDone: true,
|
||||
})
|
||||
|
||||
async function loadNotifPrefs() {
|
||||
@@ -203,6 +210,7 @@ const strings = {
|
||||
notifyNoteEdit: t('pantry', 'Note edits'),
|
||||
notifyItemAdd: t('pantry', 'Checklist items added'),
|
||||
notifyItemRecur: t('pantry', 'Recurring items reappearing'),
|
||||
notifyItemDone: t('pantry', 'Checklist items completed'),
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -450,7 +450,7 @@ const fromCompletionHint = computed<string>(() =>
|
||||
fromCompletionLocal.value
|
||||
? t(
|
||||
'pantry',
|
||||
'The next occurrence is counted from the moment you tick the item off, so it always comes back a full interval after it was bought.',
|
||||
'The next occurrence is counted from the moment you tick the item off, so it always comes back a full interval after it was completed.',
|
||||
)
|
||||
: t(
|
||||
'pantry',
|
||||
|
||||
@@ -77,7 +77,7 @@ export function useChecklistItems(houseId: number, listId: number) {
|
||||
// Optimistic flip.
|
||||
const prev = items.value.find((i) => i.id === itemId)
|
||||
if (prev) {
|
||||
items.value = items.value.map((i) => (i.id === itemId ? { ...i, bought: !i.bought } : i))
|
||||
items.value = items.value.map((i) => (i.id === itemId ? { ...i, done: !i.done } : i))
|
||||
}
|
||||
try {
|
||||
const updated = await api.toggleItem(houseId, listId, itemId)
|
||||
|
||||
@@ -66,10 +66,10 @@
|
||||
v-for="item in sortedItems"
|
||||
:key="item.id"
|
||||
class="pantry-item"
|
||||
:class="{ 'pantry-item--bought': item.bought }"
|
||||
:class="{ 'pantry-item--done': item.done }"
|
||||
>
|
||||
<NcCheckboxRadioSwitch
|
||||
:model-value="item.bought"
|
||||
:model-value="item.done"
|
||||
@update:model-value="handleToggle(item.id)"
|
||||
>
|
||||
<span class="pantry-item__label">
|
||||
@@ -313,7 +313,7 @@ watch(
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
return [...items.value].sort((a, b) => {
|
||||
if (a.bought !== b.bought) return a.bought ? 1 : -1
|
||||
if (a.done !== b.done) return a.done ? 1 : -1
|
||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
@@ -572,7 +572,7 @@ const strings = {
|
||||
border-radius: var(--border-radius, 8px);
|
||||
background: var(--color-main-background);
|
||||
|
||||
&--bought {
|
||||
&--done {
|
||||
opacity: 0.6;
|
||||
|
||||
.pantry-item__name {
|
||||
|
||||
@@ -142,11 +142,45 @@ class NotifierTest extends TestCase {
|
||||
$this->assertSame($n, $result);
|
||||
}
|
||||
|
||||
public function testItemDoneSetsRichSubject(): void {
|
||||
$n = $this->makeNotification('pantry', 'item_done', [
|
||||
'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 testItemReminderSetsRichSubject(): void {
|
||||
$n = $this->makeNotification('pantry', 'item_reminder', [
|
||||
'houseId' => 1,
|
||||
'houseName' => 'My House',
|
||||
'itemNames' => ['Milk'],
|
||||
'itemCount' => 1,
|
||||
'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',
|
||||
'itemNames' => ['Milk', 'Eggs'],
|
||||
'itemCount' => 2,
|
||||
'listName' => 'Groceries',
|
||||
]);
|
||||
|
||||
|
||||
@@ -39,9 +39,9 @@ class ChecklistServiceTest extends TestCase {
|
||||
$item->setName($overrides['name'] ?? 'Milk');
|
||||
$item->setCategoryId($overrides['categoryId'] ?? null);
|
||||
$item->setQuantity($overrides['quantity'] ?? null);
|
||||
$item->setBought($overrides['bought'] ?? false);
|
||||
$item->setBoughtAt($overrides['boughtAt'] ?? null);
|
||||
$item->setBoughtBy($overrides['boughtBy'] ?? null);
|
||||
$item->setDone($overrides['done'] ?? false);
|
||||
$item->setDoneAt($overrides['doneAt'] ?? null);
|
||||
$item->setDoneBy($overrides['doneBy'] ?? null);
|
||||
$item->setRrule($overrides['rrule'] ?? null);
|
||||
$item->setRepeatFromCompletion($overrides['repeatFromCompletion'] ?? false);
|
||||
$item->setNextDueAt($overrides['nextDueAt'] ?? null);
|
||||
@@ -54,17 +54,17 @@ class ChecklistServiceTest extends TestCase {
|
||||
public function testListItemsAutoUnchecksDueRecurring(): void {
|
||||
$now = 2_000_000_000;
|
||||
$dueItem = $this->makeItem([
|
||||
'bought' => true,
|
||||
'boughtAt' => $now - 86400 * 8,
|
||||
'boughtBy' => 'alice',
|
||||
'done' => true,
|
||||
'doneAt' => $now - 86400 * 8,
|
||||
'doneBy' => 'alice',
|
||||
'rrule' => 'FREQ=WEEKLY',
|
||||
'repeatFromCompletion' => true,
|
||||
'nextDueAt' => $now - 10,
|
||||
]);
|
||||
$freshItem = $this->makeItem([
|
||||
'bought' => true,
|
||||
'boughtAt' => $now - 3600,
|
||||
'boughtBy' => 'alice',
|
||||
'done' => true,
|
||||
'doneAt' => $now - 3600,
|
||||
'doneBy' => 'alice',
|
||||
'rrule' => 'FREQ=WEEKLY',
|
||||
'nextDueAt' => $now + 86400 * 3,
|
||||
]);
|
||||
@@ -74,16 +74,16 @@ class ChecklistServiceTest extends TestCase {
|
||||
$this->itemMapper->expects($this->once())
|
||||
->method('update')
|
||||
->with($this->callback(function (ChecklistItem $i) {
|
||||
return $i->getBought() === false
|
||||
&& $i->getBoughtAt() === null
|
||||
&& $i->getBoughtBy() === null
|
||||
return $i->getDone() === false
|
||||
&& $i->getDoneAt() === null
|
||||
&& $i->getDoneBy() === null
|
||||
&& $i->getNextDueAt() === null;
|
||||
}));
|
||||
|
||||
$result = $this->svc->listItems(1, $now);
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertFalse($result[0]->getBought(), 'Due item should be reopened');
|
||||
$this->assertTrue($result[1]->getBought(), 'Fresh item should stay bought');
|
||||
$this->assertFalse($result[0]->getDone(), 'Due item should be reopened');
|
||||
$this->assertTrue($result[1]->getDone(), 'Fresh item should stay done');
|
||||
}
|
||||
|
||||
public function testToggleItemOnNonRecurringDoesNotSetNextDue(): void {
|
||||
@@ -92,9 +92,9 @@ class ChecklistServiceTest extends TestCase {
|
||||
$this->itemMapper->expects($this->once())->method('update')->willReturn($item);
|
||||
|
||||
$toggled = $this->svc->toggleItem(42, 'alice', 1_000_000_000);
|
||||
$this->assertTrue($toggled->getBought());
|
||||
$this->assertSame('alice', $toggled->getBoughtBy());
|
||||
$this->assertSame(1_000_000_000, $toggled->getBoughtAt());
|
||||
$this->assertTrue($toggled->getDone());
|
||||
$this->assertSame('alice', $toggled->getDoneBy());
|
||||
$this->assertSame(1_000_000_000, $toggled->getDoneAt());
|
||||
$this->assertNull($toggled->getNextDueAt());
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ class ChecklistServiceTest extends TestCase {
|
||||
$this->itemMapper->expects($this->once())->method('update')->willReturn($item);
|
||||
|
||||
$toggled = $this->svc->toggleItem(42, 'alice', $now);
|
||||
$this->assertTrue($toggled->getBought());
|
||||
$this->assertTrue($toggled->getDone());
|
||||
$this->assertSame($now + 7 * 86400, $toggled->getNextDueAt());
|
||||
}
|
||||
|
||||
@@ -128,15 +128,15 @@ class ChecklistServiceTest extends TestCase {
|
||||
$this->itemMapper->expects($this->once())->method('update')->willReturn($item);
|
||||
|
||||
$toggled = $this->svc->toggleItem(42, 'alice', $now);
|
||||
$this->assertTrue($toggled->getBought());
|
||||
$this->assertTrue($toggled->getDone());
|
||||
$this->assertSame($expected, $toggled->getNextDueAt());
|
||||
}
|
||||
|
||||
public function testToggleItemCheckingOffClearsEverything(): void {
|
||||
$item = $this->makeItem([
|
||||
'bought' => true,
|
||||
'boughtAt' => 123,
|
||||
'boughtBy' => 'alice',
|
||||
'done' => true,
|
||||
'doneAt' => 123,
|
||||
'doneBy' => 'alice',
|
||||
'rrule' => 'FREQ=WEEKLY',
|
||||
'nextDueAt' => 456,
|
||||
]);
|
||||
@@ -144,9 +144,9 @@ class ChecklistServiceTest extends TestCase {
|
||||
$this->itemMapper->expects($this->once())->method('update')->willReturn($item);
|
||||
|
||||
$toggled = $this->svc->toggleItem(42, 'alice', 999);
|
||||
$this->assertFalse($toggled->getBought());
|
||||
$this->assertNull($toggled->getBoughtAt());
|
||||
$this->assertNull($toggled->getBoughtBy());
|
||||
$this->assertFalse($toggled->getDone());
|
||||
$this->assertNull($toggled->getDoneAt());
|
||||
$this->assertNull($toggled->getDoneBy());
|
||||
$this->assertNull($toggled->getNextDueAt());
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@ class PrefsServiceTest extends TestCase {
|
||||
if ($key === 'notify_item_recur_1') {
|
||||
return '0';
|
||||
}
|
||||
if ($key === 'notify_item_done_1') {
|
||||
return '1';
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
);
|
||||
@@ -86,6 +89,7 @@ class PrefsServiceTest extends TestCase {
|
||||
'notifyNoteEdit' => false,
|
||||
'notifyItemAdd' => true,
|
||||
'notifyItemRecur' => false,
|
||||
'notifyItemDone' => true,
|
||||
], $result);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user