feat(latch): expose pantry as a Latch hook source for cross-app integration

This commit is contained in:
2026-05-15 21:24:42 +03:00
parent 17cbff1b55
commit 688396fab5
38 changed files with 1489 additions and 54 deletions

View File

@@ -29,11 +29,20 @@
"test:integration": "phpunit tests -c tests/phpunit.integration.xml --colors=always --fail-on-warning --fail-on-risky",
"openapi": "generate-spec"
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/chenasraf/latch"
}
],
"require": {
"php": "^8.2",
"chenasraf/latch": "dev-master",
"sabre/vobject": "^4.5",
"sabre/xml": "^2.1"
},
"minimum-stability": "dev",
"prefer-stable": true,
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8",
"nextcloud/ocp": "dev-stable32",

63
composer.lock generated
View File

@@ -4,8 +4,64 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "fda0932386893b8cdb0f2a8c13dbc75e",
"content-hash": "54b87b0a09287376ee8e267409bd11b1",
"packages": [
{
"name": "chenasraf/latch",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://github.com/chenasraf/latch.git",
"reference": "b153959e1bb26511b49ae04ee80d090e9c267819"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/chenasraf/latch/zipball/b153959e1bb26511b49ae04ee80d090e9c267819",
"reference": "b153959e1bb26511b49ae04ee80d090e9c267819",
"shasum": ""
},
"require": {
"php": "^8.2"
},
"require-dev": {
"laravel/pint": "^1.0",
"pestphp/pest": "^2.0",
"phpstan/phpstan": "^1.10"
},
"default-branch": true,
"type": "library",
"extra": {
"laravel": {
"providers": [
"Latch\\Integration\\Laravel\\LatchServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Latch\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Latch\\Tests\\": "tests/"
}
},
"license": [
"MIT"
],
"authors": [
{
"name": "Chen Asraf"
}
],
"description": "Cross-package hook/filter registry system for PHP",
"support": {
"source": "https://github.com/chenasraf/latch/tree/master",
"issues": "https://github.com/chenasraf/latch/issues"
},
"time": "2026-04-26T20:23:59+00:00"
},
{
"name": "sabre/uri",
"version": "2.3.4",
@@ -2330,11 +2386,12 @@
}
],
"aliases": [],
"minimum-stability": "stable",
"minimum-stability": "dev",
"stability-flags": {
"chenasraf/latch": 20,
"nextcloud/ocp": 20
},
"prefer-stable": false,
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.2"

View File

@@ -7,6 +7,7 @@ declare(strict_types=1);
namespace OCA\Pantry\AppInfo;
use OCA\Pantry\Latch\PantryLatch;
use OCA\Pantry\Notification\Notifier;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
@@ -29,6 +30,7 @@ class Application extends App implements IBootstrap {
}
public function boot(IBootContext $context): void {
$context->getServerContainer()->get(PantryLatch::class);
}
/**

View File

@@ -84,7 +84,7 @@ final class ChecklistController extends OCSController {
public function createList(int $houseId, string $name, ?string $description = null, ?string $icon = null): DataResponse {
return $this->runAction(function () use ($houseId, $name, $description, $icon): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$list = $this->lists->createList($houseId, $name, $description, $icon);
$list = $this->lists->createList($houseId, $name, $description, $icon, $this->requireUid());
return new DataResponse($list->jsonSerialize());
});
}
@@ -144,7 +144,7 @@ final class ChecklistController extends OCSController {
if ($sortOrder !== null) {
$patch['sortOrder'] = $sortOrder;
}
$list = $this->lists->updateList($listId, $patch);
$list = $this->lists->updateList($listId, $patch, $this->requireUid());
return new DataResponse($list->jsonSerialize());
});
}
@@ -166,7 +166,7 @@ final class ChecklistController extends OCSController {
$this->auth->requireMember($houseId, $this->requireUid());
$existing = $this->lists->getList($listId);
$this->assertListInHouse($existing->getHouseId(), $houseId);
$this->lists->deleteList($listId);
$this->lists->deleteList($listId, $this->requireUid());
return new DataResponse(['success' => true]);
});
}
@@ -195,7 +195,7 @@ final class ChecklistController extends OCSController {
$this->assertListInHouse($list->getHouseId(), $houseId);
$all = $this->lists->listItems($listId, $sortBy);
$sliced = array_slice($all, max(0, $offset), max(0, $limit));
$items = array_map(fn ($i) => $i->jsonSerialize(), $sliced);
$items = $this->lists->serializeListItems($listId, $list->getHouseId(), $sliced, $this->requireUid());
return new DataResponse($items);
});
}
@@ -223,7 +223,7 @@ final class ChecklistController extends OCSController {
$this->assertListInHouse($list->getHouseId(), $houseId);
$all = $this->lists->listDeletedItems($listId);
$sliced = array_slice($all, max(0, $offset), max(0, $limit));
$items = array_map(fn ($i) => $i->jsonSerialize(), $sliced);
$items = array_map(fn ($i) => $this->lists->serializeItem($i, $this->requireUid()), $sliced);
return new DataResponse($items);
});
}
@@ -267,6 +267,7 @@ final class ChecklistController extends OCSController {
if ($categoryId !== null) {
$this->categories->assertInHouse($categoryId, $houseId);
}
$uid = $this->requireUid();
$item = $this->lists->addItem($listId, [
'name' => $name,
'description' => $description,
@@ -276,9 +277,9 @@ final class ChecklistController extends OCSController {
'repeatFromCompletion' => $repeatFromCompletion,
'deleteOnDone' => $deleteOnDone,
'sortOrder' => $sortOrder ?? 0,
]);
$this->notifications->notifyItemAdded($houseId, $this->requireUid(), $item->getName(), $list->getName());
return new DataResponse($item->jsonSerialize());
], $uid);
$this->notifications->notifyItemAdded($houseId, $uid, $item->getName(), $list->getName());
return new DataResponse($this->lists->serializeItem($item, $uid));
});
}
@@ -366,8 +367,9 @@ final class ChecklistController extends OCSController {
$this->assertListInHouse($targetList->getHouseId(), $houseId);
$patch['listId'] = $targetListId;
}
$updated = $this->lists->updateItem($itemId, $patch);
return new DataResponse($updated->jsonSerialize());
$uid = $this->requireUid();
$updated = $this->lists->updateItem($itemId, $patch, $uid);
return new DataResponse($this->lists->serializeItem($updated, $uid));
});
}
@@ -398,7 +400,7 @@ final class ChecklistController extends OCSController {
if ($toggled->getDone()) {
$this->notifications->notifyItemDone($houseId, $uid, $toggled->getName(), $list->getName());
}
return new DataResponse($toggled->jsonSerialize());
return new DataResponse($this->lists->serializeItem($toggled, $uid));
});
}
@@ -424,7 +426,7 @@ final class ChecklistController extends OCSController {
if ($item->getListId() !== $listId) {
throw new NotFoundException('Item does not belong to this list');
}
$this->lists->deleteItem($itemId);
$this->lists->deleteItem($itemId, $this->requireUid());
return new DataResponse(['success' => true]);
});
}
@@ -451,8 +453,9 @@ final class ChecklistController extends OCSController {
if ($item->getListId() !== $listId) {
throw new NotFoundException('Item does not belong to this list');
}
$restored = $this->lists->restoreItem($itemId);
return new DataResponse($restored->jsonSerialize());
$uid = $this->requireUid();
$restored = $this->lists->restoreItem($itemId, $uid);
return new DataResponse($this->lists->serializeItem($restored, $uid));
});
}
@@ -480,7 +483,7 @@ final class ChecklistController extends OCSController {
if ($item->getListId() !== $listId) {
throw new NotFoundException('Item does not belong to this list');
}
$this->lists->permanentlyDeleteItem($itemId);
$this->lists->permanentlyDeleteItem($itemId, $this->requireUid());
return new DataResponse(['success' => true]);
});
}
@@ -572,8 +575,8 @@ final class ChecklistController extends OCSController {
$original = (string)($data['name'] ?? 'image.jpg');
$fileId = $this->images->uploadForUser($uid, $houseId, $original, $bytes);
$updated = $this->lists->updateItem($itemId, ['imageFileId' => $fileId, 'imageUploadedBy' => $uid]);
return new DataResponse($updated->jsonSerialize());
$updated = $this->lists->updateItem($itemId, ['imageFileId' => $fileId, 'imageUploadedBy' => $uid], $uid);
return new DataResponse($this->lists->serializeItem($updated, $uid));
});
}
@@ -600,8 +603,8 @@ final class ChecklistController extends OCSController {
if ($item->getListId() !== $listId) {
throw new NotFoundException('Item does not belong to this list');
}
$updated = $this->lists->updateItem($itemId, ['imageFileId' => null, 'imageUploadedBy' => null]);
return new DataResponse($updated->jsonSerialize());
$updated = $this->lists->updateItem($itemId, ['imageFileId' => null, 'imageUploadedBy' => null], $uid);
return new DataResponse($this->lists->serializeItem($updated, $uid));
});
}

View File

@@ -158,7 +158,7 @@ final class HouseController extends OCSController {
return $this->runAction(function () use ($houseId): DataResponse {
$uid = $this->requireUid();
$this->auth->requireOwner($houseId, $uid);
$this->houseService->delete($houseId);
$this->houseService->delete($houseId, $uid);
return new DataResponse(['success' => true]);
});
}
@@ -209,7 +209,7 @@ final class HouseController extends OCSController {
return $this->runAction(function () use ($houseId, $userId, $role): DataResponse {
$uid = $this->requireUid();
$this->auth->requireAdmin($houseId, $uid);
$member = $this->houseService->addMember($houseId, $userId, $role);
$member = $this->houseService->addMember($houseId, $userId, $role, $uid);
return new DataResponse($this->serializeMember($member));
});
}
@@ -233,7 +233,7 @@ final class HouseController extends OCSController {
return $this->runAction(function () use ($houseId, $memberId, $role): DataResponse {
$uid = $this->requireUid();
$this->auth->requireAdmin($houseId, $uid);
$member = $this->houseService->updateMemberRole($houseId, $memberId, $role);
$member = $this->houseService->updateMemberRole($houseId, $memberId, $role, $uid);
return new DataResponse($this->serializeMember($member));
});
}
@@ -256,7 +256,7 @@ final class HouseController extends OCSController {
return $this->runAction(function () use ($houseId, $memberId): DataResponse {
$uid = $this->requireUid();
$this->auth->requireAdmin($houseId, $uid);
$this->houseService->removeMember($houseId, $memberId);
$this->houseService->removeMember($houseId, $memberId, $uid);
return new DataResponse(['success' => true]);
});
}

55
lib/Latch/HookPoints.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch;
/**
* Names of the Latch source and every hook point Pantry declares.
*
* Kept here so producers and external handlers reference the same strings
* without typos. Capability tags identify Pantry to discovery queries like
* `$registry->sourcesByTag('todo-list')`.
*/
final class HookPoints {
public const SOURCE = 'pantry';
/** @var list<string> */
public const SOURCE_TAGS = ['todo-list', 'shopping-list', 'household'];
// Filters — chained transforms, ordered by priority().
public const FILTER_ITEM_BEFORE_CREATE = 'item.before-create';
public const FILTER_ITEM_BEFORE_UPDATE = 'item.before-update';
public const FILTER_ITEM_RENDER_NAME = 'item.render-name';
public const FILTER_ITEM_NEXT_DUE_AT = 'item.next-due-at';
public const FILTER_LIST_BEFORE_CREATE = 'list.before-create';
public const FILTER_LIST_BEFORE_UPDATE = 'list.before-update';
// Actions — fire-and-forget events, return values ignored.
public const ACTION_ITEM_CREATED = 'item.created';
public const ACTION_ITEM_UPDATED = 'item.updated';
public const ACTION_ITEM_COMPLETED = 'item.completed';
public const ACTION_ITEM_REOPENED = 'item.reopened';
public const ACTION_ITEM_DELETED = 'item.deleted';
public const ACTION_ITEM_RESTORED = 'item.restored';
public const ACTION_ITEM_PERMANENTLY_DELETED = 'item.permanently-deleted';
public const ACTION_LIST_CREATED = 'list.created';
public const ACTION_LIST_UPDATED = 'list.updated';
public const ACTION_LIST_DELETED = 'list.deleted';
public const ACTION_HOUSE_CREATED = 'house.created';
public const ACTION_HOUSE_DELETED = 'house.deleted';
public const ACTION_HOUSE_MEMBER_ADDED = 'house.member-added';
public const ACTION_HOUSE_MEMBER_REMOVED = 'house.member-removed';
public const ACTION_HOUSE_MEMBER_ROLE_CHANGED = 'house.member-role-changed';
// Collectors — gather an array of contributions from all handlers.
public const COLLECT_LIST_CONTRIBUTED_ITEMS = 'list.contributed-items';
public const COLLECT_ITEM_EXTRA_ACTIONS = 'item.extra-actions';
public const COLLECT_ITEM_METADATA_BADGES = 'item.metadata-badges';
public const COLLECT_CATEGORY_SUGGESTIONS = 'category.suggestions';
public const COLLECT_HOUSE_LIST = 'house.list';
public const COLLECT_HOUSE_MEMBER_LIST = 'house.member.list';
}

33
lib/Latch/PantryLatch.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch;
/**
* Bootstrap orchestrator for everything Latch-related in Pantry.
*
* Application.php instantiates this class once (via the DI container) during
* `boot()`. The act of constructing it eagerly resolves PantrySource (which
* declares the `pantry` source) and PantryProvider (which registers Pantry's
* own handlers on its provider points).
*
* Future handler-side code — Pantry consuming hooks from other apps — gets
* added as another constructor dependency here. Application.php never has to
* change again.
*/
class PantryLatch {
public function __construct(
PantrySource $source,
PantryProvider $provider,
) {
// PantrySource and PantryProvider both perform their work in their own
// constructors. Receiving them here forces DI to instantiate both
// during boot, in the right order: source first (declares the hook
// points), then provider (registers handlers against them).
unset($source, $provider);
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch;
use Latch\Integration\Nextcloud\LatchBootstrap;
use OCA\Pantry\Db\HouseMember;
use OCA\Pantry\Db\HouseMemberMapper;
use OCA\Pantry\Latch\Payload\HouseListContext;
use OCA\Pantry\Latch\Payload\HouseMemberListContext;
use OCA\Pantry\Latch\Payload\HouseSummary;
use OCA\Pantry\Latch\Payload\MemberSummary;
use OCA\Pantry\Service\HouseService;
use OCP\IUserManager;
/**
* The only handler-side code Pantry ships in v1.
*
* Latch is asymmetric — only the source owner can `collectFromHandlers`.
* So for the read-style "ask Pantry for households" capability to work,
* Pantry itself registers default handlers on its own `house.list` and
* `house.member.list` collect points. External apps can still attach
* additional handlers (e.g. a federated-households bridge) and Pantry's
* handlers will merge with theirs.
*
* Depending on PantrySource (not declaring our own source) guarantees the
* source is registered before these handlers attach.
*/
class PantryProvider {
public const HANDLER_NAME = 'pantry-self';
public function __construct(
PantrySource $source,
private HouseService $houseService,
private HouseMemberMapper $memberMapper,
private IUserManager $userManager,
) {
// `$source` is intentionally a constructor dependency we don't reference
// after construction — it exists to force source registration before we
// attach handlers below. Without it the registry would throw
// SourceNotFoundException.
unset($source);
$handler = LatchBootstrap::registry()->registerHandler(self::HANDLER_NAME);
$handler->hook(HookPoints::SOURCE, HookPoints::COLLECT_HOUSE_LIST)
->handle(fn (HouseListContext $ctx) => $this->buildHouseList($ctx));
$handler->hook(HookPoints::SOURCE, HookPoints::COLLECT_HOUSE_MEMBER_LIST)
->handle(fn (HouseMemberListContext $ctx) => $this->buildMemberList($ctx));
}
/**
* @return list<HouseSummary>
*/
private function buildHouseList(HouseListContext $ctx): array {
$houses = $this->houseService->listForUser($ctx->userId);
$out = [];
foreach ($houses as $house) {
$houseId = (int)$house->getId();
$role = $this->memberMapper->findForUserAndHouse($ctx->userId, $houseId);
$out[] = new HouseSummary(
$houseId,
$house->getName(),
$house->getDescription(),
$house->getOwnerUid(),
$role?->getRole() ?? HouseMember::ROLE_MEMBER,
);
}
return $out;
}
/**
* @return list<MemberSummary>
*/
private function buildMemberList(HouseMemberListContext $ctx): array {
// Only members of the house may enumerate other members.
if ($this->memberMapper->findForUserAndHouse($ctx->viewerUid, $ctx->houseId) === null) {
return [];
}
$members = $this->houseService->listMembers($ctx->houseId);
$out = [];
foreach ($members as $m) {
$user = $this->userManager->get($m->getUserId());
$display = $user !== null ? $user->getDisplayName() : $m->getUserId();
$out[] = new MemberSummary(
(int)$m->getId(),
$m->getHouseId(),
$m->getUserId(),
$display,
$m->getRole(),
);
}
return $out;
}
}

289
lib/Latch/PantrySource.php Normal file
View File

@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch;
use Latch\Integration\Nextcloud\BridgedSource;
use Latch\Integration\Nextcloud\LatchBootstrap;
use OCA\Pantry\Db\ChecklistItem;
use OCA\Pantry\Latch\Payload\CategorySuggestionContext;
use OCA\Pantry\Latch\Payload\ChecklistItemEventPayload;
use OCA\Pantry\Latch\Payload\ChecklistItemPayload;
use OCA\Pantry\Latch\Payload\ChecklistListEventPayload;
use OCA\Pantry\Latch\Payload\ChecklistListPayload;
use OCA\Pantry\Latch\Payload\HouseEventPayload;
use OCA\Pantry\Latch\Payload\HouseListContext;
use OCA\Pantry\Latch\Payload\HouseMemberEventPayload;
use OCA\Pantry\Latch\Payload\HouseMemberListContext;
use OCA\Pantry\Latch\Payload\HouseSummary;
use OCA\Pantry\Latch\Payload\ItemActionCollectContext;
use OCA\Pantry\Latch\Payload\ItemBadgeCollectContext;
use OCA\Pantry\Latch\Payload\ItemNameRenderPayload;
use OCA\Pantry\Latch\Payload\ItemNextDueAtPayload;
use OCA\Pantry\Latch\Payload\ListItemsCollectContext;
use OCA\Pantry\Latch\Payload\MemberSummary;
/**
* Owns the bridged `pantry` Latch source. The constructor declares every
* filter/action/collect point so the source is fully described as soon as
* the DI container instantiates this class.
*
* Services inject this class and call the typed emit helpers — they never
* reference raw point names or payload classes.
*
* Each emit accepts the underlying typed inputs and constructs the payload
* internally so callers stay terse. Latch's bridged dispatcher always
* routes to remote handlers, so we don't `hasHandlers()`-guard here —
* `hasHandlers()` only sees local handlers and would skip remote ones.
*/
class PantrySource {
protected BridgedSource $source;
public function __construct() {
$this->source = $this->buildSource();
}
protected function buildSource(): BridgedSource {
return LatchBootstrap::registry()
->registerSource(HookPoints::SOURCE, null, HookPoints::SOURCE_TAGS)
// Filters
->filter(HookPoints::FILTER_ITEM_BEFORE_CREATE, ChecklistItemPayload::class)
->filter(HookPoints::FILTER_ITEM_BEFORE_UPDATE, ChecklistItemPayload::class)
->filter(HookPoints::FILTER_ITEM_RENDER_NAME, ItemNameRenderPayload::class)
->filter(HookPoints::FILTER_ITEM_NEXT_DUE_AT, ItemNextDueAtPayload::class)
->filter(HookPoints::FILTER_LIST_BEFORE_CREATE, ChecklistListPayload::class)
->filter(HookPoints::FILTER_LIST_BEFORE_UPDATE, ChecklistListPayload::class)
// Actions
->action(HookPoints::ACTION_ITEM_CREATED, ChecklistItemEventPayload::class)
->action(HookPoints::ACTION_ITEM_UPDATED, ChecklistItemEventPayload::class)
->action(HookPoints::ACTION_ITEM_COMPLETED, ChecklistItemEventPayload::class)
->action(HookPoints::ACTION_ITEM_REOPENED, ChecklistItemEventPayload::class)
->action(HookPoints::ACTION_ITEM_DELETED, ChecklistItemEventPayload::class)
->action(HookPoints::ACTION_ITEM_RESTORED, ChecklistItemEventPayload::class)
->action(HookPoints::ACTION_ITEM_PERMANENTLY_DELETED, ChecklistItemEventPayload::class)
->action(HookPoints::ACTION_LIST_CREATED, ChecklistListEventPayload::class)
->action(HookPoints::ACTION_LIST_UPDATED, ChecklistListEventPayload::class)
->action(HookPoints::ACTION_LIST_DELETED, ChecklistListEventPayload::class)
->action(HookPoints::ACTION_HOUSE_CREATED, HouseEventPayload::class)
->action(HookPoints::ACTION_HOUSE_DELETED, HouseEventPayload::class)
->action(HookPoints::ACTION_HOUSE_MEMBER_ADDED, HouseMemberEventPayload::class)
->action(HookPoints::ACTION_HOUSE_MEMBER_REMOVED, HouseMemberEventPayload::class)
->action(HookPoints::ACTION_HOUSE_MEMBER_ROLE_CHANGED, HouseMemberEventPayload::class)
// Collectors
->collect(HookPoints::COLLECT_LIST_CONTRIBUTED_ITEMS, ListItemsCollectContext::class)
->collect(HookPoints::COLLECT_ITEM_EXTRA_ACTIONS, ItemActionCollectContext::class)
->collect(HookPoints::COLLECT_ITEM_METADATA_BADGES, ItemBadgeCollectContext::class)
->collect(HookPoints::COLLECT_CATEGORY_SUGGESTIONS, CategorySuggestionContext::class)
->collect(HookPoints::COLLECT_HOUSE_LIST, HouseListContext::class)
->collect(HookPoints::COLLECT_HOUSE_MEMBER_LIST, HouseMemberListContext::class);
}
public function bridgedSource(): BridgedSource {
return $this->source;
}
// ---------- Filters ----------
public function filterItemBeforeCreate(int $listId, array $data, ?string $actorUid): ChecklistItemPayload {
$payload = new ChecklistItemPayload($listId, $data, $actorUid);
/** @var ChecklistItemPayload $result */
$result = $this->source->apply(HookPoints::FILTER_ITEM_BEFORE_CREATE, $payload);
return $result;
}
public function filterItemBeforeUpdate(
int $listId,
array $patch,
array $existing,
?string $actorUid,
): ChecklistItemPayload {
$payload = new ChecklistItemPayload($listId, $patch, $actorUid, $existing);
/** @var ChecklistItemPayload $result */
$result = $this->source->apply(HookPoints::FILTER_ITEM_BEFORE_UPDATE, $payload);
return $result;
}
public function filterItemRenderName(ChecklistItem $item, ?string $viewerUid): string {
$payload = new ItemNameRenderPayload($item, $item->getName(), $viewerUid);
/** @var ItemNameRenderPayload $result */
$result = $this->source->apply(HookPoints::FILTER_ITEM_RENDER_NAME, $payload);
return $result->name;
}
public function filterItemNextDueAt(ChecklistItem $item, ?int $nextDueAt, int $now): ?int {
$payload = new ItemNextDueAtPayload($item, $nextDueAt, $now);
/** @var ItemNextDueAtPayload $result */
$result = $this->source->apply(HookPoints::FILTER_ITEM_NEXT_DUE_AT, $payload);
return $result->nextDueAt;
}
public function filterListBeforeCreate(int $houseId, array $data, ?string $actorUid): ChecklistListPayload {
$payload = new ChecklistListPayload($houseId, $data, $actorUid);
/** @var ChecklistListPayload $result */
$result = $this->source->apply(HookPoints::FILTER_LIST_BEFORE_CREATE, $payload);
return $result;
}
public function filterListBeforeUpdate(
int $houseId,
array $patch,
array $existing,
?string $actorUid,
): ChecklistListPayload {
$payload = new ChecklistListPayload($houseId, $patch, $actorUid, $existing);
/** @var ChecklistListPayload $result */
$result = $this->source->apply(HookPoints::FILTER_LIST_BEFORE_UPDATE, $payload);
return $result;
}
// ---------- Actions ----------
public function dispatchItemCreated(ChecklistItem $item, ?string $actorUid): void {
$this->source->dispatch(HookPoints::ACTION_ITEM_CREATED, new ChecklistItemEventPayload($item, $actorUid));
}
public function dispatchItemUpdated(ChecklistItem $item, ?array $previous, ?string $actorUid): void {
$this->source->dispatch(
HookPoints::ACTION_ITEM_UPDATED,
new ChecklistItemEventPayload($item, $actorUid, $previous),
);
}
public function dispatchItemCompleted(ChecklistItem $item, ?string $actorUid): void {
$this->source->dispatch(HookPoints::ACTION_ITEM_COMPLETED, new ChecklistItemEventPayload($item, $actorUid));
}
public function dispatchItemReopened(ChecklistItem $item, ?string $actorUid): void {
$this->source->dispatch(HookPoints::ACTION_ITEM_REOPENED, new ChecklistItemEventPayload($item, $actorUid));
}
public function dispatchItemDeleted(ChecklistItem $item, ?string $actorUid): void {
$this->source->dispatch(HookPoints::ACTION_ITEM_DELETED, new ChecklistItemEventPayload($item, $actorUid));
}
public function dispatchItemRestored(ChecklistItem $item, ?string $actorUid): void {
$this->source->dispatch(HookPoints::ACTION_ITEM_RESTORED, new ChecklistItemEventPayload($item, $actorUid));
}
public function dispatchItemPermanentlyDeleted(ChecklistItem $item, ?string $actorUid): void {
$this->source->dispatch(
HookPoints::ACTION_ITEM_PERMANENTLY_DELETED,
new ChecklistItemEventPayload($item, $actorUid),
);
}
public function dispatchListCreated(\OCA\Pantry\Db\Checklist $list, ?string $actorUid): void {
$this->source->dispatch(HookPoints::ACTION_LIST_CREATED, new ChecklistListEventPayload($list, $actorUid));
}
public function dispatchListUpdated(\OCA\Pantry\Db\Checklist $list, ?array $previous, ?string $actorUid): void {
$this->source->dispatch(
HookPoints::ACTION_LIST_UPDATED,
new ChecklistListEventPayload($list, $actorUid, $previous),
);
}
public function dispatchListDeleted(\OCA\Pantry\Db\Checklist $list, ?string $actorUid): void {
$this->source->dispatch(HookPoints::ACTION_LIST_DELETED, new ChecklistListEventPayload($list, $actorUid));
}
public function dispatchHouseCreated(\OCA\Pantry\Db\House $house, ?string $actorUid): void {
$this->source->dispatch(HookPoints::ACTION_HOUSE_CREATED, new HouseEventPayload($house, $actorUid));
}
public function dispatchHouseDeleted(\OCA\Pantry\Db\House $house, ?string $actorUid): void {
$this->source->dispatch(HookPoints::ACTION_HOUSE_DELETED, new HouseEventPayload($house, $actorUid));
}
public function dispatchHouseMemberAdded(\OCA\Pantry\Db\HouseMember $member, ?string $actorUid): void {
$this->source->dispatch(
HookPoints::ACTION_HOUSE_MEMBER_ADDED,
new HouseMemberEventPayload($member, $actorUid),
);
}
public function dispatchHouseMemberRemoved(\OCA\Pantry\Db\HouseMember $member, ?string $actorUid): void {
$this->source->dispatch(
HookPoints::ACTION_HOUSE_MEMBER_REMOVED,
new HouseMemberEventPayload($member, $actorUid),
);
}
public function dispatchHouseMemberRoleChanged(
\OCA\Pantry\Db\HouseMember $member,
?string $previousRole,
?string $actorUid,
): void {
$this->source->dispatch(
HookPoints::ACTION_HOUSE_MEMBER_ROLE_CHANGED,
new HouseMemberEventPayload($member, $actorUid, $previousRole),
);
}
// ---------- Collectors ----------
/**
* @return list<array<string,mixed>>
*/
public function collectContributedItems(int $listId, int $houseId, ?string $viewerUid): array {
$ctx = new ListItemsCollectContext($listId, $houseId, $viewerUid);
$raw = $this->source->collectFromHandlers(HookPoints::COLLECT_LIST_CONTRIBUTED_ITEMS, $ctx);
// Latch flattens handler returns; we accept either a single item array
// per handler or a list of them. Normalize to a list<array>.
$out = [];
foreach ($raw as $entry) {
if (is_array($entry)) {
$out[] = $entry;
}
}
return $out;
}
/**
* @return list<array<string,mixed>>
*/
public function collectItemExtraActions(ChecklistItem $item, ?string $viewerUid): array {
$ctx = new ItemActionCollectContext($item, $viewerUid);
$raw = $this->source->collectFromHandlers(HookPoints::COLLECT_ITEM_EXTRA_ACTIONS, $ctx);
return array_values(array_filter($raw, 'is_array'));
}
/**
* @return list<array<string,mixed>>
*/
public function collectItemBadges(ChecklistItem $item, ?string $viewerUid): array {
$ctx = new ItemBadgeCollectContext($item, $viewerUid);
$raw = $this->source->collectFromHandlers(HookPoints::COLLECT_ITEM_METADATA_BADGES, $ctx);
return array_values(array_filter($raw, 'is_array'));
}
public function collectCategorySuggestion(CategorySuggestionContext $ctx): ?int {
$raw = $this->source->collectFromHandlers(HookPoints::COLLECT_CATEGORY_SUGGESTIONS, $ctx);
foreach ($raw as $entry) {
if (is_int($entry) && $entry > 0) {
return $entry;
}
}
return null;
}
/**
* @return list<HouseSummary|array<string,mixed>>
*/
public function collectHouseList(string $userId): array {
$ctx = new HouseListContext($userId);
return $this->source->collectFromHandlers(HookPoints::COLLECT_HOUSE_LIST, $ctx);
}
/**
* @return list<MemberSummary|array<string,mixed>>
*/
public function collectHouseMemberList(int $houseId, string $viewerUid): array {
$ctx = new HouseMemberListContext($houseId, $viewerUid);
return $this->source->collectFromHandlers(HookPoints::COLLECT_HOUSE_MEMBER_LIST, $ctx);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
/**
* Collector context for `category.suggestions`.
*
* Handlers return a list of suggested category ids (int) for the given
* item draft — most handlers will return either an empty list or a list
* with a single id. The collector takes the first int in the merged
* result, so handlers that want strong precedence should set a low
* `priority()` value.
*/
final class CategorySuggestionContext {
public function __construct(
public readonly int $houseId,
public readonly int $listId,
public readonly string $itemName,
public readonly ?string $quantity = null,
public readonly ?string $description = null,
) {
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
use OCA\Pantry\Db\ChecklistItem;
/**
* Action payload broadcast after a checklist item lifecycle change.
*
* `$previous` is set on update events (snapshot before the change as an array)
* so handlers can diff without re-querying.
*/
final class ChecklistItemEventPayload {
/**
* @param array<string,mixed>|null $previous Pre-change snapshot, or null for create-style events.
*/
public function __construct(
public readonly ChecklistItem $item,
public readonly ?string $actorUid = null,
public readonly ?array $previous = null,
) {
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
/**
* Filter payload representing a checklist item draft before it is persisted.
*
* Used by `item.before-create` (the entire input array is the draft) and
* `item.before-update` (`$data` is the patch, `$existing` is the loaded item
* as an associative array).
*
* Handlers mutate via `with*` helpers (immutable chaining, Latch convention).
*
* @psalm-import-type PantryListItem from \OCA\Pantry\ResponseDefinitions
*/
final class ChecklistItemPayload {
/**
* @param int $listId
* @param array<string,mixed> $data Mutable draft fields (name, description, categoryId, …).
* @param string|null $actorUid User performing the operation, if known.
* @param array<string,mixed>|null $existing Existing item as array, for update flows; null on create.
*/
public function __construct(
public readonly int $listId,
public readonly array $data,
public readonly ?string $actorUid = null,
public readonly ?array $existing = null,
) {
}
public function withData(array $data): self {
return new self($this->listId, $data, $this->actorUid, $this->existing);
}
public function withField(string $key, mixed $value): self {
$data = $this->data;
$data[$key] = $value;
return new self($this->listId, $data, $this->actorUid, $this->existing);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
use OCA\Pantry\Db\Checklist;
/**
* Action payload broadcast after a checklist list lifecycle change.
*/
final class ChecklistListEventPayload {
/**
* @param array<string,mixed>|null $previous Pre-change snapshot for updates; null on create/delete.
*/
public function __construct(
public readonly Checklist $list,
public readonly ?string $actorUid = null,
public readonly ?array $previous = null,
) {
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
/**
* Filter payload representing a checklist (list) draft before persistence.
*
* On create, $data holds the full draft (name, description, icon, …) and
* $existing is null. On update, $data is the patch and $existing is the
* current list serialized as an array.
*/
final class ChecklistListPayload {
/**
* @param int $houseId
* @param array<string,mixed> $data
* @param string|null $actorUid
* @param array<string,mixed>|null $existing
*/
public function __construct(
public readonly int $houseId,
public readonly array $data,
public readonly ?string $actorUid = null,
public readonly ?array $existing = null,
) {
}
public function withData(array $data): self {
return new self($this->houseId, $data, $this->actorUid, $this->existing);
}
public function withField(string $key, mixed $value): self {
$data = $this->data;
$data[$key] = $value;
return new self($this->houseId, $data, $this->actorUid, $this->existing);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
use OCA\Pantry\Db\House;
/**
* Action payload broadcast after a house lifecycle change.
*/
final class HouseEventPayload {
public function __construct(
public readonly House $house,
public readonly ?string $actorUid = null,
) {
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
/**
* Collector context for `house.list`.
*
* External apps invoke this collect point to ask Pantry "which households
* does this user belong to?" so they can present a household picker.
* Pantry's built-in `PantryProvider` handler responds with HouseSummary[]
* sourced from HouseService::listForUser().
*/
final class HouseListContext {
public function __construct(
public readonly string $userId,
) {
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
use OCA\Pantry\Db\HouseMember;
/**
* Action payload broadcast on house membership changes (add, remove, role change).
*
* For role changes, $previousRole holds the role before the update.
*/
final class HouseMemberEventPayload {
public function __construct(
public readonly HouseMember $member,
public readonly ?string $actorUid = null,
public readonly ?string $previousRole = null,
) {
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
/**
* Collector context for `house.member.list`.
*
* External apps invoke this to enumerate members of a household — for
* assignment pickers, share targets, etc. Pantry's built-in handler
* responds with MemberSummary[] sourced from HouseService::listMembers(),
* gated by the viewerUid's membership in the same house.
*/
final class HouseMemberListContext {
public function __construct(
public readonly int $houseId,
public readonly string $viewerUid,
) {
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
/**
* Compact household DTO returned from the `house.list` collector.
*
* Decoupled from the DB entity so external apps don't need to depend on
* `OCA\Pantry\Db\House` classes.
*/
final class HouseSummary implements \JsonSerializable {
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly ?string $description,
public readonly string $ownerUid,
public readonly string $viewerRole,
) {
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'ownerUid' => $this->ownerUid,
'viewerRole' => $this->viewerRole,
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
use OCA\Pantry\Db\ChecklistItem;
/**
* Collector context for `item.extra-actions`.
*
* Handlers return arrays of `{id, label, icon?, url?}` rendered as extra
* row actions for the item in the API response.
*/
final class ItemActionCollectContext {
public function __construct(
public readonly ChecklistItem $item,
public readonly ?string $viewerUid,
) {
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
use OCA\Pantry\Db\ChecklistItem;
/**
* Collector context for `item.metadata-badges`.
*
* Handlers return arrays of `{label, color?, icon?}` rendered as badges on
* the item in the API response.
*/
final class ItemBadgeCollectContext {
public function __construct(
public readonly ChecklistItem $item,
public readonly ?string $viewerUid,
) {
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
use OCA\Pantry\Db\ChecklistItem;
/**
* Filter payload for item name display post-processing.
*
* Handlers transform `$name` and return a new payload via `withName()`.
* The original $item is read-only context so handlers can branch on item
* state (category, qty, …) without re-querying.
*/
final class ItemNameRenderPayload {
public function __construct(
public readonly ChecklistItem $item,
public readonly string $name,
public readonly ?string $viewerUid = null,
) {
}
public function withName(string $name): self {
return new self($this->item, $name, $this->viewerUid);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
use OCA\Pantry\Db\ChecklistItem;
/**
* Filter payload for recurring-item next-due-at computation.
*
* Pantry computes the next due timestamp from the item's rrule and either
* "now" (repeat-from-completion) or the creation anchor (fixed schedule).
* Handlers can override this — useful when an external scheduler owns the
* actual cadence (calendar app, supply-chain replenishment integration, …).
*
* `$nextDueAt` is the unix timestamp Pantry would have written; null means
* Pantry intends to clear the schedule. Handlers return a new payload via
* `withNextDueAt()`.
*/
final class ItemNextDueAtPayload {
public function __construct(
public readonly ChecklistItem $item,
public readonly ?int $nextDueAt,
public readonly int $now,
) {
}
public function withNextDueAt(?int $ts): self {
return new self($this->item, $ts, $this->now);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
/**
* Collector context for `list.contributed-items`.
*
* Handlers return a list of associative arrays matching the PantryListItem
* shape (without an `id` — contributed items are not stored). Each item
* should set `contributedBy` to the producing handler's name (Latch does
* not pass handler identity to the source); contributions without it
* default to `'external'` on serialization.
*/
final class ListItemsCollectContext {
public function __construct(
public readonly int $listId,
public readonly int $houseId,
public readonly ?string $viewerUid,
) {
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Latch\Payload;
/**
* Compact house-member DTO returned from the `house.member.list` collector.
*/
final class MemberSummary implements \JsonSerializable {
public function __construct(
public readonly int $id,
public readonly int $houseId,
public readonly string $userId,
public readonly string $displayName,
public readonly string $role,
) {
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'houseId' => $this->houseId,
'userId' => $this->userId,
'displayName' => $this->displayName,
'role' => $this->role,
];
}
}

View File

@@ -38,6 +38,19 @@ namespace OCA\Pantry;
* updatedAt: int,
* }
*
* @psalm-type PantryItemExtraAction = array{
* id: string,
* label: string,
* icon?: string,
* url?: string,
* }
*
* @psalm-type PantryItemBadge = array{
* label: string,
* color?: string,
* icon?: string,
* }
*
* @psalm-type PantryListItem = array{
* id: int,
* listId: int,
@@ -58,6 +71,9 @@ namespace OCA\Pantry;
* createdAt: int,
* updatedAt: int,
* deletedAt: int|null,
* extraActions: list<PantryItemExtraAction>,
* badges: list<PantryItemBadge>,
* contributedBy: string|null,
* }
*
* @psalm-type PantryCategoryIcon = 'tag'|'food'|'fruit'|'vegetable'|'bakery'|'dairy'|'meat'|'fish'|'snacks'|'cookie'|'drinks'|'coffee'|'frozen'|'household'|'pets'|'baby'|'home'|'leaf'|'pizza'|'clipboard-check'|'clipboard-list'|'format-list-checks'|'cart'|'basket'|'star'|'heart'|'calendar'|'bell'|'flag'|'bookmark'|'pin'|'map-marker'|'briefcase'|'wrench'|'silverware'|'gift'|'book'|'school'|'palette'|'camera'|'music'|'gamepad'|'run'|'dumbbell'|'pill'|'paw'|'flower'|'tree'|'broom'|'lightbulb'|'package'|'car'|'bike'|'beach'

View File

@@ -12,6 +12,8 @@ use OCA\Pantry\Db\ChecklistItem;
use OCA\Pantry\Db\ChecklistItemMapper;
use OCA\Pantry\Db\ChecklistMapper;
use OCA\Pantry\Exception\NotFoundException;
use OCA\Pantry\Latch\PantrySource;
use OCA\Pantry\Latch\Payload\CategorySuggestionContext;
use OCP\AppFramework\Db\DoesNotExistException;
class ChecklistService {
@@ -19,6 +21,7 @@ class ChecklistService {
private ChecklistMapper $listMapper,
private ChecklistItemMapper $itemMapper,
private RecurrenceService $recurrence,
private PantrySource $hooks,
) {
}
@@ -39,11 +42,19 @@ class ChecklistService {
}
}
public function createList(int $houseId, string $name, ?string $description, ?string $icon = null): Checklist {
$name = trim($name);
public function createList(int $houseId, string $name, ?string $description, ?string $icon = null, ?string $actorUid = null): Checklist {
$filtered = $this->hooks->filterListBeforeCreate($houseId, [
'name' => $name,
'description' => $description,
'icon' => $icon,
], $actorUid);
$data = $filtered->data;
$name = trim((string)($data['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('List name cannot be empty');
}
$description = isset($data['description']) && is_string($data['description']) ? $data['description'] : null;
$icon = isset($data['icon']) && is_string($data['icon']) ? $data['icon'] : null;
$now = time();
$list = new Checklist();
$list->setHouseId($houseId);
@@ -57,11 +68,15 @@ class ChecklistService {
$list->setUpdatedAt($now);
/** @var Checklist $saved */
$saved = $this->listMapper->insert($list);
$this->hooks->dispatchListCreated($saved, $actorUid);
return $saved;
}
public function updateList(int $listId, array $patch): Checklist {
public function updateList(int $listId, array $patch, ?string $actorUid = null): Checklist {
$list = $this->getList($listId);
$previous = $list->jsonSerialize();
$filtered = $this->hooks->filterListBeforeUpdate($list->getHouseId(), $patch, $previous, $actorUid);
$patch = $filtered->data;
if (isset($patch['name'])) {
$name = trim((string)$patch['name']);
if ($name === '') {
@@ -84,13 +99,15 @@ class ChecklistService {
}
$list->setUpdatedAt(time());
$this->listMapper->update($list);
$this->hooks->dispatchListUpdated($list, $previous, $actorUid);
return $list;
}
public function deleteList(int $listId): void {
public function deleteList(int $listId, ?string $actorUid = null): void {
$list = $this->getList($listId);
$this->itemMapper->deleteByList((int)$list->getId());
$this->listMapper->delete($list);
$this->hooks->dispatchListDeleted($list, $actorUid);
}
// ----- Items -----
@@ -123,9 +140,12 @@ class ChecklistService {
}
}
public function addItem(int $listId, array $data): ChecklistItem {
public function addItem(int $listId, array $data, ?string $actorUid = null): ChecklistItem {
// Ensure the list exists.
$this->getList($listId);
$list = $this->getList($listId);
$filtered = $this->hooks->filterItemBeforeCreate($listId, $data, $actorUid);
$data = $filtered->data;
$name = trim((string)($data['name'] ?? ''));
if ($name === '') {
@@ -139,12 +159,24 @@ class ChecklistService {
$this->recurrence->validate($rrule);
}
// If no category was supplied, ask category.suggestions handlers.
$categoryId = $this->intOrNull($data['categoryId'] ?? null);
if ($categoryId === null) {
$categoryId = $this->hooks->collectCategorySuggestion(new CategorySuggestionContext(
$list->getHouseId(),
$listId,
$name,
$this->strOrNull($data['quantity'] ?? null),
$this->strOrNull($data['description'] ?? null),
));
}
$now = time();
$item = new ChecklistItem();
$item->setListId($listId);
$item->setName($name);
$item->setDescription($this->strOrNull($data['description'] ?? null));
$item->setCategoryId($this->intOrNull($data['categoryId'] ?? null));
$item->setCategoryId($categoryId);
$item->setQuantity($this->strOrNull($data['quantity'] ?? null));
$item->setDone(false);
$item->setDoneAt(null);
@@ -155,7 +187,7 @@ class ChecklistService {
$item->setDeleteOnDone(!empty($data['deleteOnDone']));
// For fixed-schedule items, compute the first due time immediately.
if ($rrule !== null && !$repeatFromCompletion) {
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
$item->setNextDueAt($this->resolveNextDueAt($item, $now));
} else {
$item->setNextDueAt(null);
}
@@ -165,11 +197,15 @@ class ChecklistService {
$item->setUpdatedAt($now);
/** @var ChecklistItem $saved */
$saved = $this->itemMapper->insert($item);
$this->hooks->dispatchItemCreated($saved, $actorUid);
return $saved;
}
public function updateItem(int $itemId, array $patch): ChecklistItem {
public function updateItem(int $itemId, array $patch, ?string $actorUid = null): ChecklistItem {
$item = $this->getItem($itemId);
$previous = $item->jsonSerialize();
$filtered = $this->hooks->filterItemBeforeUpdate($item->getListId(), $patch, $previous, $actorUid);
$patch = $filtered->data;
if (isset($patch['name'])) {
$name = trim((string)$patch['name']);
@@ -217,7 +253,7 @@ class ChecklistService {
// 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());
$item->setNextDueAt($this->resolveNextDueAt($item, time()));
}
if (isset($patch['listId'])) {
$targetListId = (int)$patch['listId'];
@@ -231,6 +267,7 @@ class ChecklistService {
$item->setUpdatedAt(time());
$this->itemMapper->update($item);
$this->hooks->dispatchItemUpdated($item, $previous, $actorUid);
return $item;
}
@@ -264,8 +301,9 @@ class ChecklistService {
public function toggleItem(int $itemId, string $uid, ?int $now = null): ChecklistItem {
$item = $this->getItem($itemId);
$now ??= time();
$wasDone = $item->getDone();
if (!$item->getDone()) {
if (!$wasDone) {
$item->setDone(true);
$item->setDoneAt($now);
$item->setDoneBy($uid);
@@ -276,10 +314,11 @@ class ChecklistService {
$item->setDeletedAt($now);
$item->setUpdatedAt($now);
$this->itemMapper->update($item);
$this->hooks->dispatchItemCompleted($item, $uid);
return $item;
}
if ($item->getRrule() !== null) {
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
$item->setNextDueAt($this->resolveNextDueAt($item, $now));
}
} else {
$item->setDone(false);
@@ -289,6 +328,11 @@ class ChecklistService {
}
$item->setUpdatedAt($now);
$this->itemMapper->update($item);
if (!$wasDone) {
$this->hooks->dispatchItemCompleted($item, $uid);
} else {
$this->hooks->dispatchItemReopened($item, $uid);
}
return $item;
}
@@ -312,6 +356,16 @@ class ChecklistService {
return $this->recurrence->nextOccurrenceAfter($rrule, $anchor, $nowDt);
}
/**
* Compute the next-due timestamp for an item and pass it through the
* item.next-due-at filter so external schedulers can override Pantry's
* default rrule computation.
*/
private function resolveNextDueAt(ChecklistItem $item, int $now): ?int {
$default = $this->computeNextDueAt($item, $now)?->getTimestamp();
return $this->hooks->filterItemNextDueAt($item, $default, $now);
}
/**
* Reopen all recurring items whose next_due_at has passed.
*
@@ -333,10 +387,11 @@ class ChecklistService {
} else {
// Fixed schedule: immediately compute the next occurrence so the
// item keeps cycling even if the user never interacts with it.
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
$item->setNextDueAt($this->resolveNextDueAt($item, $now));
}
$item->setUpdatedAt($now);
$this->itemMapper->update($item);
$this->hooks->dispatchItemReopened($item, null);
}
return $items;
}
@@ -354,38 +409,41 @@ class ChecklistService {
$now ??= time();
$items = $this->itemMapper->findDueFixedScheduleUndone($now);
foreach ($items as $item) {
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
$item->setNextDueAt($this->resolveNextDueAt($item, $now));
$item->setUpdatedAt($now);
$this->itemMapper->update($item);
}
return $items;
}
public function deleteItem(int $itemId): void {
public function deleteItem(int $itemId, ?string $actorUid = null): void {
$item = $this->getItem($itemId);
$now = time();
$item->setDeletedAt($now);
$item->setUpdatedAt($now);
$this->itemMapper->update($item);
$this->hooks->dispatchItemDeleted($item, $actorUid);
}
/**
* Permanently remove an item, regardless of whether it is currently in
* trash. Bypasses the soft-delete row and erases it from the table.
*/
public function permanentlyDeleteItem(int $itemId): void {
public function permanentlyDeleteItem(int $itemId, ?string $actorUid = null): void {
$item = $this->getItem($itemId, includeDeleted: true);
$this->itemMapper->delete($item);
$this->hooks->dispatchItemPermanentlyDeleted($item, $actorUid);
}
/**
* Restore a soft-deleted item by clearing its deleted_at marker.
*/
public function restoreItem(int $itemId): ChecklistItem {
public function restoreItem(int $itemId, ?string $actorUid = null): ChecklistItem {
$item = $this->getItem($itemId, includeDeleted: true);
$item->setDeletedAt(null);
$item->setUpdatedAt(time());
$this->itemMapper->update($item);
$this->hooks->dispatchItemRestored($item, $actorUid);
return $item;
}
@@ -396,6 +454,79 @@ class ChecklistService {
$this->itemMapper->emptyTrashForList($listId);
}
/**
* Serialize a single item for API responses, applying the
* `item.render-name` filter and merging `extraActions` / `badges` from
* the corresponding collect points.
*
* @return array<string,mixed>
*/
public function serializeItem(ChecklistItem $item, ?string $viewerUid = null): array {
$base = $item->jsonSerialize();
$base['name'] = $this->hooks->filterItemRenderName($item, $viewerUid);
$base['extraActions'] = $this->hooks->collectItemExtraActions($item, $viewerUid);
$base['badges'] = $this->hooks->collectItemBadges($item, $viewerUid);
$base['contributedBy'] = null;
return $base;
}
/**
* Serialize a list-of-items response, merging contributed items from
* the `list.contributed-items` collect point. Each contributed item is
* tagged with `contributedBy` so the frontend can skip edit/delete UI
* for rows not backed by a stored ChecklistItem.
*
* @param ChecklistItem[] $items
* @return list<array<string,mixed>>
*/
public function serializeListItems(int $listId, int $houseId, array $items, ?string $viewerUid = null): array {
$out = [];
foreach ($items as $item) {
$out[] = $this->serializeItem($item, $viewerUid);
}
foreach ($this->hooks->collectContributedItems($listId, $houseId, $viewerUid) as $contrib) {
$row = $this->normalizeContributedItem($contrib, $listId);
$row['contributedBy'] = isset($contrib['contributedBy']) && is_string($contrib['contributedBy'])
? $contrib['contributedBy']
: 'external';
$out[] = $row;
}
return $out;
}
/**
* Coerce a handler-supplied contributed item into the PantryListItem
* shape so the response stays consistent.
*
* @param array<string,mixed> $contrib
* @return array<string,mixed>
*/
private function normalizeContributedItem(array $contrib, int $defaultListId): array {
return [
'id' => isset($contrib['id']) && is_int($contrib['id']) ? $contrib['id'] : 0,
'listId' => isset($contrib['listId']) && is_int($contrib['listId']) ? $contrib['listId'] : $defaultListId,
'name' => isset($contrib['name']) && is_string($contrib['name']) ? $contrib['name'] : '',
'description' => isset($contrib['description']) && is_string($contrib['description']) ? $contrib['description'] : null,
'categoryId' => isset($contrib['categoryId']) && is_int($contrib['categoryId']) ? $contrib['categoryId'] : null,
'quantity' => isset($contrib['quantity']) && is_string($contrib['quantity']) ? $contrib['quantity'] : null,
'done' => !empty($contrib['done']),
'doneAt' => isset($contrib['doneAt']) && is_int($contrib['doneAt']) ? $contrib['doneAt'] : null,
'doneBy' => isset($contrib['doneBy']) && is_string($contrib['doneBy']) ? $contrib['doneBy'] : null,
'rrule' => isset($contrib['rrule']) && is_string($contrib['rrule']) ? $contrib['rrule'] : null,
'repeatFromCompletion' => !empty($contrib['repeatFromCompletion']),
'deleteOnDone' => !empty($contrib['deleteOnDone']),
'nextDueAt' => isset($contrib['nextDueAt']) && is_int($contrib['nextDueAt']) ? $contrib['nextDueAt'] : null,
'imageFileId' => isset($contrib['imageFileId']) && is_int($contrib['imageFileId']) ? $contrib['imageFileId'] : null,
'imageUploadedBy' => isset($contrib['imageUploadedBy']) && is_string($contrib['imageUploadedBy']) ? $contrib['imageUploadedBy'] : null,
'sortOrder' => isset($contrib['sortOrder']) && is_int($contrib['sortOrder']) ? $contrib['sortOrder'] : 0,
'createdAt' => isset($contrib['createdAt']) && is_int($contrib['createdAt']) ? $contrib['createdAt'] : 0,
'updatedAt' => isset($contrib['updatedAt']) && is_int($contrib['updatedAt']) ? $contrib['updatedAt'] : 0,
'deletedAt' => isset($contrib['deletedAt']) && is_int($contrib['deletedAt']) ? $contrib['deletedAt'] : null,
'extraActions' => isset($contrib['extraActions']) && is_array($contrib['extraActions']) ? $contrib['extraActions'] : [],
'badges' => isset($contrib['badges']) && is_array($contrib['badges']) ? $contrib['badges'] : [],
];
}
private function strOrNull(mixed $v): ?string {
if (!is_string($v)) {
return null;

View File

@@ -19,6 +19,7 @@ use OCA\Pantry\Db\PhotoFolderMapper;
use OCA\Pantry\Db\PhotoMapper;
use OCA\Pantry\Exception\ForbiddenException;
use OCA\Pantry\Exception\NotFoundException;
use OCA\Pantry\Latch\PantrySource;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IDBConnection;
use OCP\IUserManager;
@@ -35,6 +36,7 @@ class HouseService {
private NoteMapper $noteMapper,
private IDBConnection $db,
private IUserManager $userManager,
private PantrySource $hooks,
) {
}
@@ -80,6 +82,7 @@ class HouseService {
$this->memberMapper->insert($member);
$this->db->commit();
$this->hooks->dispatchHouseCreated($house, $uid);
return $house;
} catch (\Throwable $e) {
$this->db->rollBack();
@@ -105,7 +108,7 @@ class HouseService {
return $house;
}
public function delete(int $houseId): void {
public function delete(int $houseId, ?string $actorUid = null): void {
$house = $this->get($houseId);
$this->db->beginTransaction();
@@ -122,6 +125,7 @@ class HouseService {
$this->memberMapper->deleteByHouse($houseId);
$this->houseMapper->delete($house);
$this->db->commit();
$this->hooks->dispatchHouseDeleted($house, $actorUid);
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
@@ -135,7 +139,7 @@ class HouseService {
return $this->memberMapper->findByHouse($houseId);
}
public function addMember(int $houseId, string $userId, string $role): HouseMember {
public function addMember(int $houseId, string $userId, string $role, ?string $actorUid = null): HouseMember {
$role = $this->normalizeAssignableRole($role);
if ($this->userManager->get($userId) === null) {
@@ -153,26 +157,30 @@ class HouseService {
$member->setJoinedAt(time());
/** @var HouseMember $saved */
$saved = $this->memberMapper->insert($member);
$this->hooks->dispatchHouseMemberAdded($saved, $actorUid);
return $saved;
}
public function updateMemberRole(int $houseId, int $memberId, string $role): HouseMember {
public function updateMemberRole(int $houseId, int $memberId, string $role, ?string $actorUid = null): HouseMember {
$role = $this->normalizeAssignableRole($role);
$member = $this->getMember($houseId, $memberId);
if ($member->isOwner()) {
throw new ForbiddenException('Cannot change the role of the house owner');
}
$previousRole = $member->getRole();
$member->setRole($role);
$this->memberMapper->update($member);
$this->hooks->dispatchHouseMemberRoleChanged($member, $previousRole, $actorUid);
return $member;
}
public function removeMember(int $houseId, int $memberId): void {
public function removeMember(int $houseId, int $memberId, ?string $actorUid = null): void {
$member = $this->getMember($houseId, $memberId);
if ($member->isOwner()) {
throw new ForbiddenException('Cannot remove the house owner');
}
$this->memberMapper->delete($member);
$this->hooks->dispatchHouseMemberRemoved($member, $actorUid);
}
public function leaveHouse(int $houseId, string $uid): void {
@@ -184,6 +192,7 @@ class HouseService {
throw new ForbiddenException('Owner cannot leave the house. Transfer ownership or delete the house.');
}
$this->memberMapper->delete($member);
$this->hooks->dispatchHouseMemberRemoved($member, $uid);
}
private function getMember(int $houseId, int $memberId): HouseMember {

View File

@@ -271,6 +271,44 @@
}
}
},
"ItemBadge": {
"type": "object",
"required": [
"label"
],
"properties": {
"label": {
"type": "string"
},
"color": {
"type": "string"
},
"icon": {
"type": "string"
}
}
},
"ItemExtraAction": {
"type": "object",
"required": [
"id",
"label"
],
"properties": {
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"icon": {
"type": "string"
},
"url": {
"type": "string"
}
}
},
"List": {
"type": "object",
"required": [
@@ -337,7 +375,10 @@
"sortOrder",
"createdAt",
"updatedAt",
"deletedAt"
"deletedAt",
"extraActions",
"badges",
"contributedBy"
],
"properties": {
"id": {
@@ -416,6 +457,22 @@
"type": "integer",
"format": "int64",
"nullable": true
},
"extraActions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ItemExtraAction"
}
},
"badges": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ItemBadge"
}
},
"contributedBy": {
"type": "string",
"nullable": true
}
}
},

View File

@@ -41,6 +41,19 @@ export interface Category {
updatedAt: number
}
export interface ChecklistItemExtraAction {
id: string
label: string
icon?: string
url?: string
}
export interface ChecklistItemBadge {
label: string
color?: string
icon?: string
}
export interface ChecklistItem {
id: number
listId: number
@@ -61,6 +74,9 @@ export interface ChecklistItem {
createdAt: number
updatedAt: number
deletedAt: number | null
extraActions: ChecklistItemExtraAction[]
badges: ChecklistItemBadge[]
contributedBy: string | null
}
export interface Note {

View File

@@ -41,6 +41,9 @@ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
createdAt: 0,
updatedAt: 0,
deletedAt: null,
extraActions: [],
badges: [],
contributedBy: null,
...overrides,
}
}

View File

@@ -100,6 +100,9 @@ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
createdAt: 0,
updatedAt: 0,
deletedAt: null,
extraActions: [],
badges: [],
contributedBy: null,
...overrides,
}
}

View File

@@ -83,6 +83,9 @@ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
createdAt: 0,
updatedAt: 0,
deletedAt: null,
extraActions: [],
badges: [],
contributedBy: null,
...overrides,
}
}

View File

@@ -72,6 +72,9 @@ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
createdAt: 0,
updatedAt: 0,
deletedAt: null,
extraActions: [],
badges: [],
contributedBy: null,
...overrides,
}
}

View File

@@ -56,6 +56,9 @@ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
createdAt: 0,
updatedAt: 0,
deletedAt: null,
extraActions: [],
badges: [],
contributedBy: null,
...overrides,
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Tests\Unit\Latch;
use Latch\Integration\Nextcloud\LatchBootstrap;
use OCA\Pantry\Db\Checklist;
use OCA\Pantry\Db\ChecklistItem;
use OCA\Pantry\Db\ChecklistItemMapper;
use OCA\Pantry\Db\ChecklistMapper;
use OCA\Pantry\Latch\HookPoints;
use OCA\Pantry\Latch\PantrySource;
use OCA\Pantry\Latch\Payload\CategorySuggestionContext;
use OCA\Pantry\Latch\Payload\ChecklistItemEventPayload;
use OCA\Pantry\Latch\Payload\ChecklistItemPayload;
use OCA\Pantry\Latch\Payload\ListItemsCollectContext;
use OCA\Pantry\Service\ChecklistService;
use OCA\Pantry\Service\RecurrenceService;
use OCP\AppFramework\Db\DoesNotExistException;
use PHPUnit\Framework\TestCase;
/**
* End-to-end Latch behavior driven through ChecklistService:
*
* - filter chains transform payloads in priority order
* - actions fire after persistence and carry the saved entity
* - collectors merge contributions into the response
*/
class PantrySourceTest extends TestCase {
private ChecklistMapper $listMapper;
private ChecklistItemMapper $itemMapper;
private PantrySource $hooks;
private ChecklistService $svc;
protected function setUp(): void {
LatchBootstrap::reset();
$this->listMapper = $this->createMock(ChecklistMapper::class);
$this->itemMapper = $this->createMock(ChecklistItemMapper::class);
$this->hooks = new PantrySource();
$this->svc = new ChecklistService(
$this->listMapper,
$this->itemMapper,
new RecurrenceService(),
$this->hooks,
);
}
protected function tearDown(): void {
LatchBootstrap::reset();
}
private function makeList(int $id = 1, int $houseId = 42): Checklist {
$list = new Checklist();
$list->setId($id);
$list->setHouseId($houseId);
$list->setName('Pantry');
return $list;
}
public function testItemBeforeCreateFilterChainIsApplied(): void {
// Two handlers at different priorities mutate the draft name.
$registry = LatchBootstrap::registry();
$first = $registry->registerHandler('first');
$first->hook(HookPoints::SOURCE, HookPoints::FILTER_ITEM_BEFORE_CREATE)
->priority(10)
->handle(fn (ChecklistItemPayload $p) => $p->withField('name', '[' . ($p->data['name'] ?? '') . ']'));
$second = $registry->registerHandler('second');
$second->hook(HookPoints::SOURCE, HookPoints::FILTER_ITEM_BEFORE_CREATE)
->priority(20)
->handle(fn (ChecklistItemPayload $p) => $p->withField('name', ($p->data['name'] ?? '') . '!'));
$this->listMapper->method('findById')->willReturn($this->makeList());
$this->itemMapper->method('insert')->willReturnCallback(fn (ChecklistItem $i) => $i);
$saved = $this->svc->addItem(1, ['name' => 'milk']);
// priority 10 runs first, then 20: "[milk]" -> "[milk]!"
$this->assertSame('[milk]!', $saved->getName());
}
public function testItemCreatedActionFiresWithSavedEntity(): void {
$received = null;
$handler = LatchBootstrap::registry()->registerHandler('observer');
$handler->hook(HookPoints::SOURCE, HookPoints::ACTION_ITEM_CREATED)
->handle(function (ChecklistItemEventPayload $p) use (&$received): void {
$received = $p;
});
$this->listMapper->method('findById')->willReturn($this->makeList());
$this->itemMapper->method('insert')->willReturnCallback(function (ChecklistItem $i) {
$i->setId(99);
return $i;
});
$this->svc->addItem(1, ['name' => 'eggs'], 'alice');
$this->assertNotNull($received);
$this->assertSame('eggs', $received->item->getName());
$this->assertSame(99, (int)$received->item->getId());
$this->assertSame('alice', $received->actorUid);
}
public function testContributedItemsAreMergedIntoListResponse(): void {
$handler = LatchBootstrap::registry()->registerHandler('cookbook');
$handler->hook(HookPoints::SOURCE, HookPoints::COLLECT_LIST_CONTRIBUTED_ITEMS)
->handle(fn (ListItemsCollectContext $ctx) => [[
'name' => 'Flour (recipe)',
'quantity' => '500g',
'contributedBy' => 'cookbook',
]]);
$this->itemMapper->method('findDueRecurring')->willReturn([]);
$stored = new ChecklistItem();
$stored->setId(7);
$stored->setListId(1);
$stored->setName('Sugar');
$this->itemMapper->method('findByList')->willReturn([$stored]);
$items = $this->svc->listItems(1);
$serialized = $this->svc->serializeListItems(1, 42, $items, 'alice');
$this->assertCount(2, $serialized);
$this->assertSame('Sugar', $serialized[0]['name']);
$this->assertNull($serialized[0]['contributedBy']);
$this->assertSame('Flour (recipe)', $serialized[1]['name']);
$this->assertSame('cookbook', $serialized[1]['contributedBy']);
}
public function testCategorySuggestionFillsMissingCategoryId(): void {
$handler = LatchBootstrap::registry()->registerHandler('classifier');
$handler->hook(HookPoints::SOURCE, HookPoints::COLLECT_CATEGORY_SUGGESTIONS)
->handle(fn (CategorySuggestionContext $ctx) => $ctx->itemName === 'milk' ? [17] : []);
$this->listMapper->method('findById')->willReturn($this->makeList());
$this->itemMapper->method('insert')->willReturnCallback(fn (ChecklistItem $i) => $i);
$saved = $this->svc->addItem(1, ['name' => 'milk']);
$this->assertSame(17, $saved->getCategoryId());
}
public function testRenderNameFilterAffectsSerialization(): void {
$handler = LatchBootstrap::registry()->registerHandler('decorator');
$handler->hook(HookPoints::SOURCE, HookPoints::FILTER_ITEM_RENDER_NAME)
->handle(fn ($p) => $p->withName('★ ' . $p->name));
$item = new ChecklistItem();
$item->setId(1);
$item->setListId(1);
$item->setName('Milk');
$out = $this->svc->serializeItem($item, 'alice');
$this->assertSame('★ Milk', $out['name']);
$this->assertSame([], $out['extraActions']);
$this->assertSame([], $out['badges']);
}
public function testListNotFoundStillRaisesAfterFilter(): void {
$this->listMapper->method('findById')->willThrowException(new DoesNotExistException('no'));
$this->expectException(\OCA\Pantry\Exception\NotFoundException::class);
$this->svc->addItem(999, ['name' => 'milk']);
}
}

View File

@@ -7,10 +7,12 @@ declare(strict_types=1);
namespace OCA\Pantry\Tests\Unit\Service;
use Latch\Integration\Nextcloud\LatchBootstrap;
use OCA\Pantry\Db\Checklist;
use OCA\Pantry\Db\ChecklistItem;
use OCA\Pantry\Db\ChecklistItemMapper;
use OCA\Pantry\Db\ChecklistMapper;
use OCA\Pantry\Latch\PantrySource;
use OCA\Pantry\Service\ChecklistService;
use OCA\Pantry\Service\RecurrenceService;
use PHPUnit\Framework\MockObject\MockObject;
@@ -24,15 +26,23 @@ class ChecklistServiceTest extends TestCase {
private ChecklistService $svc;
protected function setUp(): void {
// Isolate the Latch registry between tests so each PantrySource
// instantiation cleanly re-registers the source.
LatchBootstrap::reset();
$this->listMapper = $this->createMock(ChecklistMapper::class);
$this->itemMapper = $this->createMock(ChecklistItemMapper::class);
$this->svc = new ChecklistService(
$this->listMapper,
$this->itemMapper,
new RecurrenceService(),
new PantrySource(),
);
}
protected function tearDown(): void {
LatchBootstrap::reset();
}
private function makeItem(array $overrides = []): ChecklistItem {
$item = new ChecklistItem();
$item->setListId($overrides['listId'] ?? 1);

View File

@@ -106,16 +106,16 @@
},
{
"name": "php-cs-fixer/shim",
"version": "v3.95.1",
"version": "v3.95.2",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/shim.git",
"reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a"
"reference": "319bd80c8db64ab5f7b79a19178299045bdb9957"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a",
"reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a",
"url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/319bd80c8db64ab5f7b79a19178299045bdb9957",
"reference": "319bd80c8db64ab5f7b79a19178299045bdb9957",
"shasum": ""
},
"require": {
@@ -152,9 +152,9 @@
"description": "A tool to automatically fix PHP code style",
"support": {
"issues": "https://github.com/PHP-CS-Fixer/shim/issues",
"source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.1"
"source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.2"
},
"time": "2026-04-12T17:00:34+00:00"
"time": "2026-05-15T09:21:09+00:00"
}
],
"aliases": [],