mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat(latch): expose pantry as a Latch hook source for cross-app integration
This commit is contained in:
@@ -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
63
composer.lock
generated
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
55
lib/Latch/HookPoints.php
Normal 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
33
lib/Latch/PantryLatch.php
Normal 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);
|
||||
}
|
||||
}
|
||||
100
lib/Latch/PantryProvider.php
Normal file
100
lib/Latch/PantryProvider.php
Normal 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
289
lib/Latch/PantrySource.php
Normal 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);
|
||||
}
|
||||
}
|
||||
28
lib/Latch/Payload/CategorySuggestionContext.php
Normal file
28
lib/Latch/Payload/CategorySuggestionContext.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
28
lib/Latch/Payload/ChecklistItemEventPayload.php
Normal file
28
lib/Latch/Payload/ChecklistItemEventPayload.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
45
lib/Latch/Payload/ChecklistItemPayload.php
Normal file
45
lib/Latch/Payload/ChecklistItemPayload.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
lib/Latch/Payload/ChecklistListEventPayload.php
Normal file
25
lib/Latch/Payload/ChecklistListEventPayload.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
41
lib/Latch/Payload/ChecklistListPayload.php
Normal file
41
lib/Latch/Payload/ChecklistListPayload.php
Normal 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);
|
||||
}
|
||||
}
|
||||
21
lib/Latch/Payload/HouseEventPayload.php
Normal file
21
lib/Latch/Payload/HouseEventPayload.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
23
lib/Latch/Payload/HouseListContext.php
Normal file
23
lib/Latch/Payload/HouseListContext.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
24
lib/Latch/Payload/HouseMemberEventPayload.php
Normal file
24
lib/Latch/Payload/HouseMemberEventPayload.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
24
lib/Latch/Payload/HouseMemberListContext.php
Normal file
24
lib/Latch/Payload/HouseMemberListContext.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
35
lib/Latch/Payload/HouseSummary.php
Normal file
35
lib/Latch/Payload/HouseSummary.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
24
lib/Latch/Payload/ItemActionCollectContext.php
Normal file
24
lib/Latch/Payload/ItemActionCollectContext.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
24
lib/Latch/Payload/ItemBadgeCollectContext.php
Normal file
24
lib/Latch/Payload/ItemBadgeCollectContext.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
30
lib/Latch/Payload/ItemNameRenderPayload.php
Normal file
30
lib/Latch/Payload/ItemNameRenderPayload.php
Normal 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);
|
||||
}
|
||||
}
|
||||
35
lib/Latch/Payload/ItemNextDueAtPayload.php
Normal file
35
lib/Latch/Payload/ItemNextDueAtPayload.php
Normal 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);
|
||||
}
|
||||
}
|
||||
26
lib/Latch/Payload/ListItemsCollectContext.php
Normal file
26
lib/Latch/Payload/ListItemsCollectContext.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
32
lib/Latch/Payload/MemberSummary.php
Normal file
32
lib/Latch/Payload/MemberSummary.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
59
openapi.json
59
openapi.json
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -41,6 +41,9 @@ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
deletedAt: null,
|
||||
extraActions: [],
|
||||
badges: [],
|
||||
contributedBy: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
deletedAt: null,
|
||||
extraActions: [],
|
||||
badges: [],
|
||||
contributedBy: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
deletedAt: null,
|
||||
extraActions: [],
|
||||
badges: [],
|
||||
contributedBy: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
deletedAt: null,
|
||||
extraActions: [],
|
||||
badges: [],
|
||||
contributedBy: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
deletedAt: null,
|
||||
extraActions: [],
|
||||
badges: [],
|
||||
contributedBy: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
168
tests/unit/Latch/PantrySourceTest.php
Normal file
168
tests/unit/Latch/PantrySourceTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
12
vendor-bin/cs-fixer/composer.lock
generated
12
vendor-bin/cs-fixer/composer.lock
generated
@@ -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": [],
|
||||
|
||||
Reference in New Issue
Block a user