mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat: notes wall
This commit is contained in:
179
lib/Controller/NotesWallController.php
Normal file
179
lib/Controller/NotesWallController.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Controller;
|
||||
|
||||
use OCA\Pantry\Exception\ForbiddenException;
|
||||
use OCA\Pantry\Exception\NotFoundException;
|
||||
use OCA\Pantry\ResponseDefinitions;
|
||||
use OCA\Pantry\Service\HouseAuthService;
|
||||
use OCA\Pantry\Service\NotesWallService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
|
||||
/**
|
||||
* @psalm-import-type PantryNote from ResponseDefinitions
|
||||
* @psalm-import-type PantrySuccess from ResponseDefinitions
|
||||
*/
|
||||
final class NotesWallController extends OCSController {
|
||||
use TranslatesDomainExceptions;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private NotesWallService $notes,
|
||||
private HouseAuthService $auth,
|
||||
private IUserSession $userSession,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all notes in a house
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int<1, 500> $limit Maximum number of notes to return.
|
||||
* @param int<0, max> $offset Number of notes to skip.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<PantryNote>, array{}>
|
||||
*
|
||||
* 200: Notes returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/notes')]
|
||||
#[NoAdminRequired]
|
||||
public function indexNotes(int $houseId, int $limit = 100, int $offset = 0): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $limit, $offset): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$all = $this->notes->listNotes($houseId);
|
||||
$sliced = array_slice($all, max(0, $offset), max(0, $limit));
|
||||
return new DataResponse(array_map(fn ($n) => $n->jsonSerialize(), $sliced));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a note
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param string $title Note title.
|
||||
* @param string|null $content Markdown content.
|
||||
* @param string|null $color Hex color (#RRGGBB).
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryNote, array{}>
|
||||
*
|
||||
* 200: Note created
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/notes')]
|
||||
#[NoAdminRequired]
|
||||
public function createNote(int $houseId, string $title, ?string $content = null, ?string $color = null): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $title, $content, $color): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$this->auth->requireMember($houseId, $uid);
|
||||
$note = $this->notes->createNote($houseId, $uid, $title, $content, $color);
|
||||
return new DataResponse($note->jsonSerialize());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a note
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $noteId Note id.
|
||||
* @param string|null $title New title.
|
||||
* @param string|null $content New content (empty string clears).
|
||||
* @param string|null $color New color (empty string clears).
|
||||
* @param int|null $sortOrder New sort order.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryNote, array{}>
|
||||
*
|
||||
* 200: Note updated
|
||||
*/
|
||||
#[ApiRoute(verb: 'PATCH', url: '/api/houses/{houseId}/notes/{noteId}')]
|
||||
#[NoAdminRequired]
|
||||
public function updateNote(int $houseId, int $noteId, ?string $title = null, ?string $content = null, ?string $color = null, ?int $sortOrder = null): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $noteId, $title, $content, $color, $sortOrder): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$existing = $this->notes->getNote($noteId);
|
||||
$this->assertInHouse($existing->getHouseId(), $houseId);
|
||||
$patch = [];
|
||||
if ($title !== null) {
|
||||
$patch['title'] = $title;
|
||||
}
|
||||
if ($content !== null) {
|
||||
$patch['content'] = $content;
|
||||
}
|
||||
if ($color !== null) {
|
||||
$patch['color'] = $color;
|
||||
}
|
||||
if ($sortOrder !== null) {
|
||||
$patch['sortOrder'] = $sortOrder;
|
||||
}
|
||||
$note = $this->notes->updateNote($noteId, $patch);
|
||||
return new DataResponse($note->jsonSerialize());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a note
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $noteId Note id.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
|
||||
*
|
||||
* 200: Note deleted
|
||||
*/
|
||||
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/notes/{noteId}')]
|
||||
#[NoAdminRequired]
|
||||
public function deleteNote(int $houseId, int $noteId): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $noteId): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$existing = $this->notes->getNote($noteId);
|
||||
$this->assertInHouse($existing->getHouseId(), $houseId);
|
||||
$this->notes->deleteNote($noteId);
|
||||
return new DataResponse(['success' => true]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch reorder notes
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param list<array{id: int, sortOrder: int}> $items Reorder entries.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
|
||||
*
|
||||
* 200: Notes reordered
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/notes/reorder')]
|
||||
#[NoAdminRequired]
|
||||
public function reorderNotes(int $houseId, array $items = []): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $items): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$this->notes->reorderNotes($houseId, $items);
|
||||
return new DataResponse(['success' => true]);
|
||||
});
|
||||
}
|
||||
|
||||
private function requireUid(): string {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
throw new ForbiddenException('Not authenticated');
|
||||
}
|
||||
return $user->getUID();
|
||||
}
|
||||
|
||||
private function assertInHouse(int $entityHouseId, int $routeHouseId): void {
|
||||
if ($entityHouseId !== $routeHouseId) {
|
||||
throw new NotFoundException('Note does not belong to this house');
|
||||
}
|
||||
}
|
||||
}
|
||||
60
lib/Db/Note.php
Normal file
60
lib/Db/Note.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Db;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method int getHouseId()
|
||||
* @method void setHouseId(int $houseId)
|
||||
* @method string getTitle()
|
||||
* @method void setTitle(string $title)
|
||||
* @method string|null getContent()
|
||||
* @method void setContent(?string $content)
|
||||
* @method string|null getColor()
|
||||
* @method void setColor(?string $color)
|
||||
* @method string getCreatedBy()
|
||||
* @method void setCreatedBy(string $createdBy)
|
||||
* @method int getSortOrder()
|
||||
* @method void setSortOrder(int $sortOrder)
|
||||
* @method int getCreatedAt()
|
||||
* @method void setCreatedAt(int $createdAt)
|
||||
* @method int getUpdatedAt()
|
||||
* @method void setUpdatedAt(int $updatedAt)
|
||||
*/
|
||||
class Note extends Entity implements \JsonSerializable {
|
||||
protected int $houseId = 0;
|
||||
protected string $title = '';
|
||||
protected ?string $content = null;
|
||||
protected ?string $color = null;
|
||||
protected string $createdBy = '';
|
||||
protected int $sortOrder = 0;
|
||||
protected int $createdAt = 0;
|
||||
protected int $updatedAt = 0;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('houseId', 'integer');
|
||||
$this->addType('sortOrder', 'integer');
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('updatedAt', 'integer');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'houseId' => $this->houseId,
|
||||
'title' => $this->title,
|
||||
'content' => $this->content,
|
||||
'color' => $this->color,
|
||||
'createdBy' => $this->createdBy,
|
||||
'sortOrder' => $this->sortOrder,
|
||||
'createdAt' => $this->createdAt,
|
||||
'updatedAt' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
56
lib/Db/NoteMapper.php
Normal file
56
lib/Db/NoteMapper.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Db;
|
||||
|
||||
use OCA\Pantry\AppInfo\Application;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<Note>
|
||||
*/
|
||||
class NoteMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, Application::tableName('notes'), Note::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Note[]
|
||||
*/
|
||||
public function findByHouse(int $houseId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)))
|
||||
->orderBy('sort_order', 'ASC')
|
||||
->addOrderBy('created_at', 'DESC');
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function findById(int $id): Note {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
public function deleteByHouse(int $houseId): void {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete($this->getTableName())
|
||||
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
}
|
||||
@@ -321,6 +321,50 @@ class Version1Date20260405000000 extends SimpleMigrationStep {
|
||||
$table->addIndex(['folder_id'], 'pantry_photos_folder_idx');
|
||||
}
|
||||
|
||||
// ---- pantry_notes ----
|
||||
$notesTable = Application::tableName('notes');
|
||||
if (!$schema->hasTable($notesTable)) {
|
||||
$table = $schema->createTable($notesTable);
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('house_id', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('title', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 255,
|
||||
]);
|
||||
$table->addColumn('content', Types::TEXT, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
$table->addColumn('color', Types::STRING, [
|
||||
'notnull' => false,
|
||||
'length' => 16,
|
||||
]);
|
||||
$table->addColumn('created_by', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
]);
|
||||
$table->addColumn('sort_order', Types::INTEGER, [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
]);
|
||||
$table->addColumn('created_at', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('updated_at', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addIndex(['house_id'], 'pantry_notes_house_idx');
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,18 @@ namespace OCA\Pantry;
|
||||
* updatedAt: int,
|
||||
* }
|
||||
*
|
||||
* @psalm-type PantryNote = array{
|
||||
* id: int,
|
||||
* houseId: int,
|
||||
* title: string,
|
||||
* content: string|null,
|
||||
* color: string|null,
|
||||
* createdBy: string,
|
||||
* sortOrder: int,
|
||||
* createdAt: int,
|
||||
* updatedAt: int,
|
||||
* }
|
||||
*
|
||||
* @psalm-type PantryPhoto = array{
|
||||
* id: int,
|
||||
* houseId: int,
|
||||
|
||||
@@ -12,6 +12,7 @@ use OCA\Pantry\Db\House;
|
||||
use OCA\Pantry\Db\HouseMapper;
|
||||
use OCA\Pantry\Db\HouseMember;
|
||||
use OCA\Pantry\Db\HouseMemberMapper;
|
||||
use OCA\Pantry\Db\NoteMapper;
|
||||
use OCA\Pantry\Db\PhotoFolderMapper;
|
||||
use OCA\Pantry\Db\PhotoMapper;
|
||||
use OCA\Pantry\Db\ShoppingListItemMapper;
|
||||
@@ -31,6 +32,7 @@ class HouseService {
|
||||
private CategoryMapper $categoryMapper,
|
||||
private PhotoMapper $photoMapper,
|
||||
private PhotoFolderMapper $photoFolderMapper,
|
||||
private NoteMapper $noteMapper,
|
||||
private IDBConnection $db,
|
||||
private IUserManager $userManager,
|
||||
) {
|
||||
@@ -116,6 +118,7 @@ class HouseService {
|
||||
$this->categoryMapper->deleteByHouse($houseId);
|
||||
$this->photoMapper->deleteByHouse($houseId);
|
||||
$this->photoFolderMapper->deleteByHouse($houseId);
|
||||
$this->noteMapper->deleteByHouse($houseId);
|
||||
$this->memberMapper->deleteByHouse($houseId);
|
||||
$this->houseMapper->delete($house);
|
||||
$this->db->commit();
|
||||
|
||||
125
lib/Service/NotesWallService.php
Normal file
125
lib/Service/NotesWallService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Service;
|
||||
|
||||
use OCA\Pantry\Db\Note;
|
||||
use OCA\Pantry\Db\NoteMapper;
|
||||
use OCA\Pantry\Exception\NotFoundException;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
|
||||
class NotesWallService {
|
||||
public function __construct(
|
||||
private NoteMapper $noteMapper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Note[]
|
||||
*/
|
||||
public function listNotes(int $houseId): array {
|
||||
return $this->noteMapper->findByHouse($houseId);
|
||||
}
|
||||
|
||||
public function getNote(int $noteId): Note {
|
||||
try {
|
||||
return $this->noteMapper->findById($noteId);
|
||||
} catch (DoesNotExistException) {
|
||||
throw new NotFoundException('Note not found');
|
||||
}
|
||||
}
|
||||
|
||||
public function createNote(int $houseId, string $uid, string $title, ?string $content, ?string $color): Note {
|
||||
$title = trim($title);
|
||||
if ($title === '') {
|
||||
throw new \InvalidArgumentException('Note title cannot be empty');
|
||||
}
|
||||
if ($color !== null) {
|
||||
$this->validateColor($color);
|
||||
}
|
||||
$now = time();
|
||||
$note = new Note();
|
||||
$note->setHouseId($houseId);
|
||||
$note->setTitle($title);
|
||||
$note->setContent($content !== null && trim($content) !== '' ? $content : null);
|
||||
$note->setColor($color);
|
||||
$note->setCreatedBy($uid);
|
||||
$note->setSortOrder(0);
|
||||
$note->setCreatedAt($now);
|
||||
$note->setUpdatedAt($now);
|
||||
/** @var Note $saved */
|
||||
$saved = $this->noteMapper->insert($note);
|
||||
return $saved;
|
||||
}
|
||||
|
||||
public function updateNote(int $noteId, array $patch): Note {
|
||||
$note = $this->getNote($noteId);
|
||||
if (isset($patch['title'])) {
|
||||
$title = trim((string)$patch['title']);
|
||||
if ($title === '') {
|
||||
throw new \InvalidArgumentException('Note title cannot be empty');
|
||||
}
|
||||
$note->setTitle($title);
|
||||
}
|
||||
if (array_key_exists('content', $patch)) {
|
||||
$c = $patch['content'];
|
||||
$note->setContent(is_string($c) && trim($c) !== '' ? $c : null);
|
||||
}
|
||||
if (array_key_exists('color', $patch)) {
|
||||
$color = $patch['color'];
|
||||
if ($color !== null && $color !== '') {
|
||||
$this->validateColor((string)$color);
|
||||
$note->setColor((string)$color);
|
||||
} else {
|
||||
$note->setColor(null);
|
||||
}
|
||||
}
|
||||
if (isset($patch['sortOrder'])) {
|
||||
$note->setSortOrder((int)$patch['sortOrder']);
|
||||
}
|
||||
$note->setUpdatedAt(time());
|
||||
$this->noteMapper->update($note);
|
||||
return $note;
|
||||
}
|
||||
|
||||
public function deleteNote(int $noteId): void {
|
||||
$note = $this->getNote($noteId);
|
||||
$this->noteMapper->delete($note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch reorder notes.
|
||||
*
|
||||
* @param list<array{id: int, sortOrder: int}> $items
|
||||
*/
|
||||
public function reorderNotes(int $houseId, array $items): void {
|
||||
foreach ($items as $entry) {
|
||||
$id = (int)($entry['id'] ?? 0);
|
||||
$sortOrder = (int)($entry['sortOrder'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$note = $this->noteMapper->findById($id);
|
||||
} catch (DoesNotExistException) {
|
||||
continue;
|
||||
}
|
||||
if ($note->getHouseId() !== $houseId) {
|
||||
continue;
|
||||
}
|
||||
$note->setSortOrder($sortOrder);
|
||||
$note->setUpdatedAt(time());
|
||||
$this->noteMapper->update($note);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateColor(string $color): void {
|
||||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
|
||||
throw new \InvalidArgumentException('Color must be a hex color code (#RRGGBB)');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user