feat: notes wall

This commit is contained in:
2026-04-06 16:16:49 +03:00
parent 68300940d1
commit 8403ee0aa4
31 changed files with 3116 additions and 2 deletions

View 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
View 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
View 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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();

View 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)');
}
}
}