Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
102 commits
Select commit Hold shift + click to select a range
7d05989
docs(logbook): зафиксировать начальные решения
kyoug3n Jul 18, 2026
c22067f
fix(reviews): сохраняем состояние карточки после оценки
kyoug3n Jul 18, 2026
f1b9ae6
feat(stats): считаем очередь и серию повторений
kyoug3n Jul 18, 2026
07128f3
feat(review): скрытие ответа карточки до раскрытия
kyoug3n Jul 18, 2026
c43be8b
feat(cards): добавление формы создания карточек
kyoug3n Jul 18, 2026
d536e73
feat(notes): добавлен фильтр по тегам
kyoug3n Jul 18, 2026
17cb7dc
feat(notes): редактирование заметок
kyoug3n Jul 18, 2026
fc414cf
feat(auth): добавлена сессионная модель
kyoug3n Jul 18, 2026
ca62590
feat(auth): добавлена регистрация пользователя
kyoug3n Jul 18, 2026
112469a
feat(auth): создание сессии после регистрации
kyoug3n Jul 18, 2026
c18a7f3
feat(auth): добавлен вход в систему
kyoug3n Jul 18, 2026
4afe2c1
feat(auth): получение текущего пользователя
kyoug3n Jul 18, 2026
9727505
feat(auth): выход из системы
kyoug3n Jul 19, 2026
60f578f
chore(auth): разрешена передача cookie через CORS
kyoug3n Jul 19, 2026
f169d27
feat(users): добавлена принадлежность данных пользователю
kyoug3n Jul 19, 2026
812a9dc
feat(auth): аутентификация запросов
kyoug3n Jul 19, 2026
bb60a56
feat(data): данные ограничены текущим пользователем
kyoug3n Jul 19, 2026
1c43e85
feat(auth): запрет доступа к чужим данным
kyoug3n Jul 19, 2026
7c30340
chore(deploy): база данных перенесена в docker volume
kyoug3n Jul 19, 2026
ab27262
feat(frontend): добавлен экран регистрации
kyoug3n Jul 19, 2026
5523e6c
feat(frontend): добавлен экран входа
kyoug3n Jul 19, 2026
678a99d
feat(frontend): добавлено состояние текущего пользователя
kyoug3n Jul 19, 2026
a75d89b
feat(frontend): добавлен выход из системы
kyoug3n Jul 19, 2026
e65e9e4
refactor(frontend): разделение логики на модули
kyoug3n Jul 19, 2026
5a01743
refactor(backend): сборка базы и ORM разделена на модули
kyoug3n Jul 19, 2026
a7e7a45
feat(profile): публичная статистика пользователя
kyoug3n Jul 19, 2026
2e72ee6
feat(frontend): публичная страница профиля
kyoug3n Jul 19, 2026
70ced02
style(frontend): подключен фреймворк PicoCSS
kyoug3n Jul 19, 2026
b154f16
style(frontend): оформлены экраны авторизации
kyoug3n Jul 19, 2026
303482b
style(frontend): оформлена рабочая область
kyoug3n Jul 19, 2026
ed1c2e6
style(frontend): оформлен публичный профиль
kyoug3n Jul 19, 2026
7b6a515
style(frontend): профиль адаптирован под экран 16:9
kyoug3n Jul 19, 2026
9829883
style(frontend): повышена контрастность основных элементов
kyoug3n Jul 19, 2026
446708c
feat(frontend): добавлены модальные формы и меню создания
kyoug3n Jul 19, 2026
834e1c3
style(frontend): настроены цвета кнопок и убраны рамки
kyoug3n Jul 19, 2026
de160b8
style(frontend): выровнены кнопки заметок
kyoug3n Jul 19, 2026
1c21c20
style(frontend): выровнен публичный профиль по вертикали
kyoug3n Jul 19, 2026
f1598e6
refactor(frontend): кнопка создания разделена по секциям
kyoug3n Jul 19, 2026
1cc5ec9
style(frontend): разделение рабочего пространства на 2 колонки в порт…
kyoug3n Jul 20, 2026
e43c8a3
refactor(frontend): кнопка профиля перенесена в шапку
kyoug3n Jul 20, 2026
91fe858
feat(frontend): очередь повторения показывает одну карточку
kyoug3n Jul 20, 2026
3a39221
style(frontend): анимировано открытие и закрытие попапов
kyoug3n Jul 20, 2026
5b8f8bf
feat(frontend): фильтр заметок открывается круглой кнопкой
kyoug3n Jul 20, 2026
67dc889
style(frontend): выровнен заголовок заметок относительно кнопок
kyoug3n Jul 20, 2026
a93a83d
style(frontend): выровнен + в кнопках создания
kyoug3n Jul 20, 2026
3f08029
style(frontend): редизайн кнопки удаления заметки
kyoug3n Jul 20, 2026
61c8f1a
style(frontend): убрана фокусная рамка у кнопок
kyoug3n Jul 20, 2026
adff2d0
feat(frontend): оформлено пустое состояние списка заметок
kyoug3n Jul 20, 2026
ad09eb1
style(frontend): редизайн кнопки редактирования заметок
kyoug3n Jul 20, 2026
cf11f9b
feat(frontend): переработаны заметки и формы их создания
kyoug3n Jul 20, 2026
1733f53
refactor(frontend): раздел очереди переименован в «Карточки»
kyoug3n Jul 20, 2026
e173319
fix(frontend): выровнен заголовок карточек
kyoug3n Jul 20, 2026
d2ee2f9
feat(frontend): счётчик карточек перенесён в заголовок
kyoug3n Jul 20, 2026
e4a5dfc
feat(cards): добавлено изменение карточек через API
kyoug3n Jul 20, 2026
5a81a8f
feat(frontend): добавлены операции изменения и удаления карточек
kyoug3n Jul 20, 2026
c43adab
feat(frontend): добавлен режим редактирования карточек
kyoug3n Jul 20, 2026
bcdec67
fix(frontend): скрыты недоступные действия карточек
kyoug3n Jul 20, 2026
dd83055
test(frontend): проверены операции изменения и удаления карточек
kyoug3n Jul 20, 2026
52d2e52
refactor(frontend): создание карточек перенесено в раскрываемую форму
kyoug3n Jul 20, 2026
f7fa3f7
fix(frontend): центрировано сообщение пустой очереди
kyoug3n Jul 20, 2026
e638a8d
feat(frontend): ответ карточки раскрывается компактной ссылкой
kyoug3n Jul 20, 2026
36e04c1
feat(frontend): обновлены подписи и компоновка кнопок оценки карточек
kyoug3n Jul 20, 2026
80acb06
fix(frontend): убран лишний отступ у кнопок оценок
kyoug3n Jul 20, 2026
4e3adb3
feat(frontend): связи заметок с независимым выбором
kyoug3n Jul 20, 2026
34f3b11
feat(frontend): связи заметок отображаются в содержании
kyoug3n Jul 20, 2026
a33a676
feat(frontend): навигация по связям заметок
kyoug3n Jul 21, 2026
d4d63fd
feat(frontend): показаны обратные связи заметок
kyoug3n Jul 21, 2026
60332f8
feat(frontend): переход к исходной заметке из карточки
kyoug3n Jul 21, 2026
bc6f847
feat(books): добавлены книги и их связь с заметками
kyoug3n Jul 21, 2026
99bed12
feat(books): поиск через Open Library
kyoug3n Jul 21, 2026
76aa40f
feat(frontend): добавлен выбор книги для заметок
kyoug3n Jul 21, 2026
46bcdf6
feat(frontend): отображаются обложки книг в заметках
kyoug3n Jul 21, 2026
45e11ba
fix(frontend): подписаны поля создания/редактирования заметок/карточек
kyoug3n Jul 21, 2026
37433c5
fix(frontend): объединены поиск и выбор книги
kyoug3n Jul 21, 2026
7e49ee2
fix(frontend): улучшен выбор и отображение связанных заметок
kyoug3n Jul 21, 2026
8a6d5ee
fix(reviews): изменены подпись и счётчик оценок
kyoug3n Jul 21, 2026
9f0515e
feat(frontend): взаимоисключающие меню заметок
kyoug3n Jul 21, 2026
295ecc9
feat(frontend): прокрутка к открытым меню
kyoug3n Jul 21, 2026
ae6300a
feat(frontend): отмена создания заметок и карточек
kyoug3n Jul 21, 2026
75f6287
fix(frontend): выровнен размер кнопки Снова
kyoug3n Jul 21, 2026
f8b5217
fix(frontend): сообщение об ошибке входа оформлено внутри формы
kyoug3n Jul 21, 2026
0fc2617
refactor(frontend): вынесены стили выбора книги
kyoug3n Jul 21, 2026
e7b5bd8
refactor(frontend): вынесена логика элемента заметки
kyoug3n Jul 21, 2026
289b707
style(frontend): настроены интервалы в формах авторизации
kyoug3n Jul 21, 2026
04dfd51
feat(frontend): форма входа дефолтная вместо регистрации
kyoug3n Jul 21, 2026
47accc3
feat(frontend): переключатель видимости пароля
kyoug3n Jul 21, 2026
4f9bb70
fix(frontend): скрыт нестилизованный экран & задан фон при переходах
kyoug3n Jul 21, 2026
1884025
test(acceptance): уникальный логин для сценария книг
kyoug3n Jul 21, 2026
01c059c
test(acceptance): исправлен логин сценария книг
kyoug3n Jul 21, 2026
c5d7904
fix(auth): защита cookie-мутаторов от CSRF
kyoug3n Jul 22, 2026
0687f89
fix(auth): старые данные скрыты до обновления workspace
kyoug3n Jul 22, 2026
dbd0e4b
test(e2e): устранена гонка в сценарии заметок
kyoug3n Jul 22, 2026
e15a75e
fix(notes): проверяется владение связанными заметками
kyoug3n Jul 22, 2026
9af5c44
fix(notes): строгая проверка связанных заметок
kyoug3n Jul 22, 2026
95b3ac5
fix(notes): переход к связанным заметкам вне фильтра
kyoug3n Jul 22, 2026
4d014e5
fix(stats): даты отзывов учитывают часовой пояс приложения
kyoug3n Jul 22, 2026
0a190d5
fix(deploy): таймаут готовности сервисов завершается ошибкой
kyoug3n Jul 22, 2026
18e0fa3
fix(auth): регистрация атомарно обрабатывает занятый логин
kyoug3n Jul 22, 2026
e56cdae
docs(logbook): окончательно заполнил логбук
kyoug3n Jul 22, 2026
6908285
fix(auth): подсказки и ошибки регистрации отображаются в форме
kyoug3n Jul 22, 2026
6085d0c
style(auth): обработчики ошибок соответствуют правилам ESLint
kyoug3n Jul 22, 2026
818ae6c
fix(cors): CORS-заголовки добавляются к ошибкам API
kyoug3n Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,17 @@ dev: _env
up: _env
{{ compose }} up -d
@echo "ожидание бэкенда {{ base }}"
@for i in $(seq 1 90); do curl -sf {{ base }}/stats >/dev/null 2>&1 && break || sleep 1; done
@for i in $(seq 1 90); do if curl -sf {{ base }}/health >/dev/null 2>&1; then exit 0; fi; sleep 1; done; echo "бэкенд не ответил за 90 секунд" >&2; exit 1
@echo "ожидание фронтенда {{ front }}"
@for i in $(seq 1 90); do curl -sf {{ front }} >/dev/null 2>&1 && break || sleep 1; done
@for i in $(seq 1 90); do if curl -sf {{ front }} >/dev/null 2>&1; then exit 0; fi; sleep 1; done; echo "фронтенд не ответил за 90 секунд" >&2; exit 1

# Остановить приложение и удалить его тома.
down:
{{ compose }} down -v

# API-приёмка — приложение уже должно быть запущено (см. `just up`).
acceptance:
hurl --variable base={{ base }} --test spec/acceptance/*.hurl
hurl --variable base={{ base }} --variable frontend={{ front }} --variable username=acceptance-$(date +%s%N) --test spec/acceptance/*.hurl

# Браузерные сценарии — приложение уже должно быть запущено (см. `just up`).
e2e:
Expand Down
28 changes: 24 additions & 4 deletions LOGBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,36 @@

## Решения

-
- В проекте уже используется алгоритм SM-2, оставил его, т.к он соответствует цели пользователя;
- Вопрос и ответ в карточке показаны одновременно, что неинтутивно для цели пользователя - скрыл ответ до клика;
- После оценки карточки пользователем пересчитываю ее новое состояние: интервал, легкость и дату ее следующего показа;
- Новое состояние карточки сохраняется в БД;
- Добавил отдельную страницу с общими статистиками за всё время и за неделю;
- В публичной странице не показаны заметки, теги и другие личные данные, как требует заказчик;
- Обложки книг реализованы - их можно получать с помощью API OpenLibrary;
- Добавил авторизацию - без нее вовсе не секьюрно. Хотя добавлять ее намного увеличивает объем работы, это необходимо для подобного приложения;
- Решил сделать регистрацию доступной для нескольких аккаунтов - то есть, приложение не однопользовательское;
- Каждая заметка, карточка, и запись повторения принадлежит конкретному пользователю;
- При переходе на страницу другого пользователя показан только его публичный профиль;
- Добавил связи заметок для упрощенной навигации;
- Добавил постраничный просмотр заметок, чтобы не перегружать рабочее пространство;
- Поиск книг сделал необязательным при создании/редактировании заметки;
- Добавил ссылку на заметку, связанную с карточкой, чтобы пользователь мог повторить материал при необходимости.

## Допущения

-
- Под требованием "сделать красиво" понял современный, но не перегруженный дизайн - выполнил;
- Требование заказчика о "напоминаниях" понял как простое появление карточек в очереди, т.е без уведомлений вне приложения.

## С чем поспорил / что отклонил

-
- Решил не добавлять ИИ-конспекты из-за высокого шанса галлюцинаций при работе с большими объемами данных книг, а также лишней сложности/зависимости от сторонних ИИ-сервисов;
- Считаю, что автоматическое удаление заметок после 30 дней может привести к неожиданной потере собранной базы знаний, поэтому отклонил;
- Интеграция со словарём требует очередной интеграции со сторонним API, что раздувает функционал приложения и не необходимо к основному сценарию использования;
- Цитата дня не относится к цели приложения - отклонил.

## Мнение

-
- Пытался максимально отполировать реализацию, постарался уделить побольше внимания секьюрности - устранил возможные уязвимости и т.п;
- Проект очень интересный, задание более полезно, чем многое, чему меня учили в университете;
- Разрешение на пользование агентами очень ценю, т.к в текущих реалиях без них на самом деле никак.
84 changes: 74 additions & 10 deletions app/backend/public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,30 @@
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerAwareInterface;
use Recall\Http\AuthenticationMiddleware;
use Recall\Http\Controller\AuthController;
use Recall\Http\Controller\BooksController;
use Recall\Http\Controller\CardsController;
use Recall\Http\Controller\HealthController;
use Recall\Http\Controller\NotesController;
use Recall\Http\Controller\ProfileController;
use Recall\Http\Controller\ReviewsController;
use Recall\Http\Controller\StatsController;
use Recall\Http\Json;
use Recall\Http\OriginProtectionMiddleware;
use Recall\Http\Serializer;
use Recall\Http\SessionCookie;
use Recall\Http\ValidationException;
use Recall\Infrastructure\OpenLibraryClient;
use Recall\Infrastructure\Persistence\BookRepository;
use Recall\Infrastructure\Persistence\CardRepository;
use Recall\Infrastructure\Persistence\DatabaseContext;
use Recall\Infrastructure\Persistence\NoteRepository;
use Recall\Infrastructure\Persistence\Orm;
use Recall\Infrastructure\Persistence\QueryCounter;
use Recall\Infrastructure\Persistence\ReviewRepository;
use Recall\Infrastructure\Persistence\Seeder;
use Recall\Infrastructure\Persistence\SessionRepository;
use Recall\Infrastructure\Persistence\UserRepository;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Slim\Factory\AppFactory;
Expand All @@ -29,33 +40,61 @@
$databasePath = $envDb === false || $envDb === '' ? dirname(__DIR__) . '/var/recall.sqlite' : $envDb;
$now = new DateTimeImmutable('now');

$boot = Orm::boot($databasePath);
$boot = DatabaseContext::boot($databasePath);
$queries = new QueryCounter();
$driver = $boot->dbal->driver('sqlite');
if ($driver instanceof LoggerAwareInterface) {
$driver->setLogger($queries);
}

$noteRepo = new NoteRepository($boot->orm);
$bookRepo = new BookRepository($boot->orm);
$cardRepo = new CardRepository($boot->orm);
$reviewRepo = new ReviewRepository($boot->orm);
$sessionRepo = new SessionRepository($boot->orm);
$userRepo = new UserRepository($boot->orm);

(new Seeder($noteRepo, $cardRepo))->seedIfEmpty($now);

$serializer = new Serializer();
$notes = new NotesController($noteRepo, $serializer, $now);
$notes = new NotesController($noteRepo, $bookRepo, $serializer, $now);
$books = new BooksController($bookRepo, $serializer, new OpenLibraryClient());
$cards = new CardsController($cardRepo, $noteRepo, $serializer, $now);
$reviews = new ReviewsController($cardRepo, $reviewRepo, $serializer, $now);
$stats = new StatsController($cardRepo, $serializer);
$stats = new StatsController($cardRepo, $reviewRepo, $serializer, $now);
$profiles = new ProfileController($userRepo, $cardRepo, $reviewRepo, $serializer, $now);
$auth = new AuthController($userRepo, $sessionRepo, new SessionCookie(), $now);
$health = new HealthController($boot->dbal->database('default'));

$app = AppFactory::create();
$app->addBodyParsingMiddleware();
$app->add(new AuthenticationMiddleware(
new SessionCookie(),
$sessionRepo,
$userRepo,
$now,
$app->getResponseFactory(),
));

// CORS — фронтенд-дев-сервер ходит к нам с другого источника.
$app->add(static fn(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface => $handler->handle($request)
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->withHeader('Access-Control-Allow-Headers', 'Content-Type'));
$configuredOrigin = getenv('RECALL_FRONTEND_ORIGIN');
$frontendOrigin = is_string($configuredOrigin) && $configuredOrigin !== ''
? $configuredOrigin
: 'http://localhost:5173';
$app->add(new OriginProtectionMiddleware(new SessionCookie(), $frontendOrigin, $app->getResponseFactory()));
$app->add(static function (ServerRequestInterface $request, RequestHandlerInterface $handler) use ($frontendOrigin): ResponseInterface {
$response = $handler->handle($request);
if ($request->getHeaderLine('Origin') !== $frontendOrigin) {
return $response;
}

return $response
->withHeader('Access-Control-Allow-Origin', $frontendOrigin)
->withHeader('Access-Control-Allow-Credentials', 'true')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->withHeader('Access-Control-Allow-Headers', 'Content-Type')
->withHeader('Vary', 'Origin');
});

// Метрики: время запроса и число запросов к БД (наблюдение, не гейт).
$app->add(static function (ServerRequestInterface $request, RequestHandlerInterface $handler) use ($queries): ResponseInterface {
Expand All @@ -73,32 +112,57 @@
$app->put('/notes/{id}', $notes->update(...));
$app->delete('/notes/{id}', $notes->delete(...));

$app->get('/books/search', $books->search(...));
$app->get('/books', $books->index(...));
$app->post('/books', $books->create(...));
$app->get('/books/{id}', $books->show(...));
$app->delete('/books/{id}', $books->delete(...));

$app->get('/cards', $cards->index(...));
$app->post('/cards', $cards->create(...));
$app->get('/cards/{id}', $cards->show(...));
$app->put('/cards/{id}', $cards->update(...));
$app->delete('/cards/{id}', $cards->delete(...));

$app->get('/reviews/queue', $reviews->queue(...));
$app->post('/reviews/{id}', $reviews->grade(...));

$app->get('/stats', $stats->index(...));
$app->get('/users/{username}', $profiles->show(...));
$app->get('/health', $health->show(...));

$app->post('/auth/register', $auth->register(...));
$app->post('/auth/login', $auth->login(...));
$app->get('/auth/me', $auth->currentUser(...));
$app->post('/auth/logout', $auth->logout(...));

$app->options('/{routes:.+}', static fn(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface => $response->withStatus(204));

$app->addRoutingMiddleware();

$errors = $app->addErrorMiddleware(false, false, false);
$errors->setDefaultErrorHandler(
static function (ServerRequestInterface $request, Throwable $exception) use ($app): ResponseInterface {
static function (ServerRequestInterface $request, Throwable $exception) use ($app, $frontendOrigin): ResponseInterface {
$response = $app->getResponseFactory()->createResponse();

return match (true) {
$response = match (true) {
$exception instanceof HttpNotFoundException => Json::error($response, 'route not found', 404),
$exception instanceof HttpMethodNotAllowedException => Json::error($response, 'method not allowed', 405),
$exception instanceof ValidationException => Json::error($response, $exception->getMessage(), 422, $exception->details()),
$exception instanceof InvalidArgumentException => Json::error($response, $exception->getMessage(), 422),
default => Json::error($response, 'internal error', 500),
};

if ($request->getHeaderLine('Origin') !== $frontendOrigin) {
return $response;
}

return $response
->withHeader('Access-Control-Allow-Origin', $frontendOrigin)
->withHeader('Access-Control-Allow-Credentials', 'true')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->withHeader('Access-Control-Allow-Headers', 'Content-Type')
->withHeader('Vary', 'Origin');
},
);

Expand Down
87 changes: 87 additions & 0 deletions app/backend/src/Domain/Book.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace Recall\Domain;

use DateTimeImmutable;
use InvalidArgumentException;
use Recall\Domain\ValueObject\BookId;
use Recall\Domain\ValueObject\Title;
use Recall\Domain\ValueObject\UserId;

/** Книга-источник заметок и её обложка из внешнего каталога. */
class Book
{
private const int MAX_AUTHOR_LENGTH = 200;
private const int MAX_OPEN_LIBRARY_KEY_LENGTH = 200;

public function __construct(
public BookId $id,
public Title $title,
public string $author,
public ?string $openLibraryKey,
public ?int $coverId,
public ?UserId $userId = null,
) {
$this->author = $this->author($author);
$this->openLibraryKey = $this->openLibraryKey($openLibraryKey);
$this->coverId = $this->coverId($coverId);
}

public static function create(
Title $title,
string $author,
?string $openLibraryKey,
?int $coverId,
): self {
return new self(BookId::generate(), $title, $author, $openLibraryKey, $coverId);
}

/** Момент создания берётся из UUIDv7 — отдельного поля не держим. */
public function createdAt(): DateTimeImmutable
{
return $this->id->createdAt();
}

private function author(string $author): string
{
$author = trim($author);
if (mb_strlen($author) > self::MAX_AUTHOR_LENGTH) {
throw new InvalidArgumentException('имя автора длиннее ' . self::MAX_AUTHOR_LENGTH . ' символов');
}

return $author;
}

private function openLibraryKey(?string $key): ?string
{
if ($key === null) {
return null;
}

$key = trim($key);
if ($key === '') {
return null;
}
if (mb_strlen($key) > self::MAX_OPEN_LIBRARY_KEY_LENGTH) {
throw new InvalidArgumentException(
'идентификатор Open Library длиннее ' . self::MAX_OPEN_LIBRARY_KEY_LENGTH . ' символов',
);
}

return $key;
}

private function coverId(?int $coverId): ?int
{
if ($coverId === null) {
return null;
}
if ($coverId < 1) {
throw new InvalidArgumentException('идентификатор обложки должен быть положительным');
}

return $coverId;
}
}
16 changes: 16 additions & 0 deletions app/backend/src/Domain/BookSearchResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Recall\Domain;

/** Нормализованный результат поиска книги во внешнем каталоге. */
final readonly class BookSearchResult
{
public function __construct(
public string $title,
public string $author,
public string $openLibraryKey,
public ?int $coverId,
) {}
}
2 changes: 2 additions & 0 deletions app/backend/src/Domain/Card.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Recall\Domain\ValueObject\Interval;
use Recall\Domain\ValueObject\NoteId;
use Recall\Domain\ValueObject\ReviewId;
use Recall\Domain\ValueObject\UserId;

/** Карточка с текущим расписанием повторения. */
class Card
Expand All @@ -24,6 +25,7 @@ public function __construct(
public Ease $ease,
public Interval $interval,
public Day $due,
public ?UserId $userId = null,
) {}

public static function create(NoteId $noteId, CardText $front, CardText $back, DateTimeImmutable $now): self
Expand Down
18 changes: 15 additions & 3 deletions app/backend/src/Domain/Note.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
namespace Recall\Domain;

use DateTimeImmutable;
use Recall\Domain\ValueObject\BookId;
use Recall\Domain\ValueObject\NoteId;
use Recall\Domain\ValueObject\NoteIdList;
use Recall\Domain\ValueObject\TagList;
use Recall\Domain\ValueObject\Title;
use Recall\Domain\ValueObject\UserId;

/** Заметка: заголовок, текст, теги и ссылки на другие заметки. */
class Note
Expand All @@ -20,6 +22,8 @@ public function __construct(
public TagList $tags,
public NoteIdList $links,
public DateTimeImmutable $updatedAt,
public ?BookId $bookId = null,
public ?UserId $userId = null,
) {}

public static function create(
Expand All @@ -28,8 +32,9 @@ public static function create(
TagList $tags,
NoteIdList $links,
DateTimeImmutable $now,
?BookId $bookId = null,
): self {
return new self(NoteId::generate(), $title, $body, $tags, $links, $now);
return new self(NoteId::generate(), $title, $body, $tags, $links, $now, $bookId);
}

/** Момент создания берётся из UUIDv7 — отдельного поля не держим. */
Expand All @@ -38,12 +43,19 @@ public function createdAt(): DateTimeImmutable
return $this->id->createdAt();
}

public function revise(Title $title, string $body, TagList $tags, NoteIdList $links, DateTimeImmutable $now): void
{
public function revise(
Title $title,
string $body,
TagList $tags,
NoteIdList $links,
DateTimeImmutable $now,
?BookId $bookId = null,
): void {
$this->title = $title;
$this->body = $body;
$this->tags = $tags;
$this->links = $links;
$this->updatedAt = $now;
$this->bookId = $bookId;
}
}
Loading